docs(data): 归档数据层验证产物 + 数据层总览README
- scripts/data_platform/_archive/legacy/: 归档20个独立探针/诊断/旧降级脚本(零引用验证) - docs/archive/data/: 归档17个数据相关旧设计/plan/report(保留fusion spec作深读) - docs/data-platform/README.md: 数据层单一权威记录(8节:架构/布局/源/管线/铁律/API/缺口/待办) - 删除 _mootdx_depth_result.txt - Phase2待办: 15m灌库链+旧回填import链(有测试/wrapper依赖,VPS schtask确认后归档)
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# 数据层归档脚本(_archive/)
|
||||
|
||||
本目录存放**已完成使命的验证/诊断/一次性脚本**,不再参与日常增量管线。
|
||||
保留于 git 历史便于回溯;如需重跑,移动回 `scripts/data_platform/` 顶层即可。
|
||||
|
||||
## legacy/ — 探针 / 诊断 / 旧降级 / 一次性验证(2026-07-29 归档)
|
||||
|
||||
| 脚本 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `probe_*.py`(12) | 探针 | 数据源/库/接口一次性 smoke 验证(akshare 状态/成份股/dbbardata 唯一性/退市/ETF/基本面/涨跌停/unified schema 等) |
|
||||
| `dbbardata_probe.py` | 探针 | dbbardata 表结构与行数抽查 |
|
||||
| `run_with_diag.py` / `diag_daily_update.ps1` | 诊断 | 带诊断输出的运行包装 |
|
||||
| `test_mootdx_depth.py` / `test_baostock_daily_constituent_sample.py` | 一次性验证 | mootdx 深度 / baostock 日线成份股采样(非 tests/ 正式套件) |
|
||||
| `resume_5yr_watcher.py` | 一次性 | 5 年全市场下载断点续传 watcher(已完成) |
|
||||
| `fallback.py` / `realtime.py` | 旧降级 | 旧多源降级管理器(日线 akshare→腾讯 / 实时 新浪→东财→腾讯),方案 A 后由 bs_eod/xt_eod 接管 |
|
||||
|
||||
归档前已验证:**零 import、无活跃 wrapper 引用**。
|
||||
|
||||
## backfill_15m/ — 15min 一次性灌库链(Phase 2 待归档)
|
||||
|
||||
⚠️ 未归档。`backfill_15min_baostock` 被 `tests/data/test_backfill_15min_hardening.py` 正式 import,
|
||||
`refresh_15min_daily` / `download_minute` / `download_15m_xtdata` / `raw_redownload` / `audit_data_layout`
|
||||
存在交叉引用或 ops wrapper 依赖,需 Phase 2 评估后统一处理。
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""dbbardata_probe.py — 只读探测 dbbardata 现状(schema + 抽样各 interval 覆盖)。
|
||||
|
||||
走 symbol 索引,避免全表 COUNT(亿级慢)。回答:
|
||||
- dbbardata 有哪些 interval(d/15m/5m)
|
||||
- 个股日线(interval='d' 类)含不含退市股(000005/000023/600811/600074)
|
||||
- 在市股(600519/000001)日线日期范围
|
||||
- ETF(510300/159915)有没有(判断 dbbardata 是否覆盖 ETF)
|
||||
用于决定迁移单元5(dbbardata 个股日线切 baostock 源)的工作量。
|
||||
"""
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
DB = Path(r"C:\sanguo_vnpy_v2\data\quant_trading.db")
|
||||
c = sqlite3.connect(str(DB), timeout=60)
|
||||
c.execute("PRAGMA busy_timeout = 60000")
|
||||
|
||||
cols = [r[1] for r in c.execute("PRAGMA table_info(dbbardata)")]
|
||||
print(f"DB: {DB}")
|
||||
print(f"dbbardata cols({len(cols)}): {cols}")
|
||||
|
||||
print("\n=== 抽样: 各 symbol 的 interval 覆盖 (走索引, 快) ===")
|
||||
samples = {
|
||||
"退市": ["000005", "000023", "600811", "600074"],
|
||||
"在市个股": ["600519", "000001"],
|
||||
"ETF/基金": ["510300", "159915", "510050"],
|
||||
}
|
||||
for label, syms in samples.items():
|
||||
print(f"\n[{label}]")
|
||||
for sym in syms:
|
||||
rows = c.execute(
|
||||
"SELECT interval, COUNT(*), MIN(datetime), MAX(datetime) "
|
||||
"FROM dbbardata WHERE symbol=? GROUP BY interval",
|
||||
(sym,),
|
||||
).fetchall()
|
||||
print(f" {sym}: {rows}")
|
||||
|
||||
c.close()
|
||||
@@ -0,0 +1,25 @@
|
||||
# diag_daily_update.ps1 — 诊断 sanguo-daily-update 为何跑 2.5h
|
||||
Write-Output "=== NOW: $(Get-Date) ==="
|
||||
Write-Output "=== python 进程 (CPU秒/内存MB/启动时间) ==="
|
||||
Get-Process python -ErrorAction SilentlyContinue |
|
||||
Select-Object Id, @{n='CPU_s'; e={[math]::Round($_.CPU, 1)}},
|
||||
@{n='WS_MB'; e={[math]::Round($_.WS / 1MB, 0)}}, StartTime |
|
||||
Format-Table -Auto
|
||||
Write-Output "=== schtask sanguo-daily-update (状态+执行的命令) ==="
|
||||
$i = Get-ScheduledTaskInfo -TaskName sanguo-daily-update
|
||||
Write-Output ("last={0} result=0x{1:X} next={2}" -f $i.LastRunTime, $i.LastTaskResult, $i.NextRunTime)
|
||||
Write-Output "--- Task Action (实际执行) ---"
|
||||
(Get-ScheduledTask -TaskName sanguo-daily-update).Actions |
|
||||
Select-Object Execute, Arguments | Format-List
|
||||
Write-Output "=== daily_update.log tail 40 ==="
|
||||
Get-Content C:\sanguo_vnpy_v2\data\daily_update.log -Tail 40 -ErrorAction SilentlyContinue
|
||||
Write-Output "=== qfq/ 各年: 文件数 + 最新写入时间 ==="
|
||||
Get-ChildItem C:\sanguo_vnpy_v2\data\qfq -Directory -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$files = Get-ChildItem $_.FullName -File -ErrorAction SilentlyContinue
|
||||
$mx = ($files | Sort-Object LastWriteTime -Descending | Select-Object -First 1).LastWriteTime
|
||||
Write-Output (" {0}: files={1} lastwrite={2}" -f $_.Name, $files.Count, $mx)
|
||||
}
|
||||
Write-Output "=== qfq/2026 最新写入 top 5 (看在不在写) ==="
|
||||
Get-ChildItem C:\sanguo_vnpy_v2\data\qfq\2026 -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 5 Name, LastWriteTime |
|
||||
Format-Table -Auto
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""多源降级管理器 - 日线(akshare→腾讯) + 实时(新浪→东财→腾讯)"""
|
||||
import pandas as pd
|
||||
import urllib.request
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FallbackManager:
|
||||
def __init__(self):
|
||||
self._source_used = ""
|
||||
|
||||
def get_source_used(self) -> str:
|
||||
return self._source_used
|
||||
|
||||
def get_daily(self, symbol: str, start_date: str, end_date: str) -> pd.DataFrame:
|
||||
"""获取日线数据,降级链:akshare → 腾讯K线"""
|
||||
# 1. akshare
|
||||
try:
|
||||
df = self._fetch_akshare_daily(symbol, start_date, end_date)
|
||||
if df is not None and not df.empty:
|
||||
self._source_used = "akshare"
|
||||
return df
|
||||
except Exception as e:
|
||||
logger.warning(f"akshare日线失败 {symbol}: {e}")
|
||||
|
||||
# 2. 腾讯K线
|
||||
try:
|
||||
df = self._fetch_tencent_daily(symbol, start_date, end_date)
|
||||
if df is not None and not df.empty:
|
||||
self._source_used = "tencent_kline"
|
||||
return df
|
||||
except Exception as e:
|
||||
logger.warning(f"腾讯K线失败 {symbol}: {e}")
|
||||
|
||||
raise RuntimeError(f"所有日线数据源失败: {symbol} {start_date}~{end_date}")
|
||||
|
||||
def get_realtime(self, symbol: str) -> dict:
|
||||
"""获取实时行情,降级链:新浪→东财→腾讯"""
|
||||
from realtime import get_realtime_quote
|
||||
result = get_realtime_quote(symbol)
|
||||
self._source_used = result.get("source", "unknown")
|
||||
return result
|
||||
|
||||
def _fetch_akshare_daily(self, symbol: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
||||
import akshare as ak
|
||||
code = symbol.replace("SH", "").replace("SZ", "").replace("sh", "").replace("sz", "")
|
||||
s = start_date.replace("-", "")
|
||||
e = end_date.replace("-", "")
|
||||
df = ak.stock_zh_a_hist(symbol=code, period="daily", start_date=s, end_date=e, adjust="")
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
df = df.rename(columns={"日期": "date", "开盘": "open", "收盘": "close",
|
||||
"最高": "high", "最低": "low", "成交量": "volume",
|
||||
"成交额": "amount"})
|
||||
df["date"] = pd.to_datetime(df["date"]).dt.strftime("%Y-%m-%d")
|
||||
for c in ["open", "high", "low", "close", "volume", "amount"]:
|
||||
df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0)
|
||||
return df[["date", "open", "high", "low", "close", "volume", "amount"]]
|
||||
|
||||
def _fetch_tencent_daily(self, symbol: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
|
||||
"""腾讯K线API获取日线"""
|
||||
code = symbol.replace("SH", "").replace("SZ", "").replace("sh", "").replace("sz", "")
|
||||
if code.startswith(("6", "5", "1")):
|
||||
prefix = "sh"
|
||||
else:
|
||||
prefix = "sz"
|
||||
tq_symbol = f"{prefix}{code}"
|
||||
|
||||
days = (datetime.strptime(end_date, "%Y-%m-%d") - datetime.strptime(start_date, "%Y-%m-%d")).days + 10
|
||||
url = f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={tq_symbol},day,{start_date},,{days},"
|
||||
try:
|
||||
import urllib.request, json as _json
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with opener.open(req, timeout=10) as r:
|
||||
resp = _json.loads(r.read())
|
||||
d = resp.get("data")
|
||||
if not isinstance(d, dict):
|
||||
return None
|
||||
klines = d.get(tq_symbol, {}).get("day", [])
|
||||
if not klines:
|
||||
return None
|
||||
df = pd.DataFrame(klines)
|
||||
ncols = len(df.columns)
|
||||
if ncols >= 7:
|
||||
df.columns = ["date", "open", "close", "high", "low", "volume", "amount"][:ncols]
|
||||
else:
|
||||
df.columns = ["date", "open", "close", "high", "low", "volume"][:ncols]
|
||||
if "amount" not in df.columns:
|
||||
df["amount"] = 0.0
|
||||
for c in ["open", "close", "high", "low", "volume", "amount"]:
|
||||
df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0)
|
||||
df["date"] = pd.to_datetime(df["date"]).dt.strftime("%Y-%m-%d")
|
||||
mask = (df["date"] >= start_date) & (df["date"] <= end_date)
|
||||
return df.loc[mask, ["date", "open", "high", "low", "close", "volume", "amount"]]
|
||||
except Exception as e:
|
||||
logger.warning(f"腾讯K线请求失败: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""probe_akshare_status.py — 探查 akshare 低频任务现状(sanguo-bs-akshare + sanguo-index)。
|
||||
|
||||
看: 三表 static 各子目录文件数+最新mtime / 事件类数据有没有 / constituent_unified source 分布。
|
||||
"""
|
||||
import datetime
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
BASE = Path(r"C:\sanguo_vnpy_v2\data")
|
||||
print("===== data/ 子目录 =====")
|
||||
if BASE.exists():
|
||||
for p in sorted(BASE.iterdir()):
|
||||
if p.is_dir():
|
||||
n = sum(1 for _ in p.rglob("*") if _.is_file())
|
||||
print(f" {p.name}/ ({n} files)")
|
||||
else:
|
||||
print(f" {p.name}")
|
||||
|
||||
print("\n===== static/ 各表文件数 + 最新 mtime =====")
|
||||
for sub in ["balance", "income", "cashflow", "valuation", "financial_abstract"]:
|
||||
d = BASE / "static" / sub
|
||||
if not d.exists():
|
||||
print(f" static/{sub}: MISSING")
|
||||
continue
|
||||
files = list(d.glob("*.parquet"))
|
||||
if not files:
|
||||
print(f" static/{sub}: 0 parquet")
|
||||
continue
|
||||
mt = max(f.stat().st_mtime for f in files)
|
||||
print(f" static/{sub}: {len(files)} parquet, latest mtime={datetime.datetime.fromtimestamp(mt):%Y-%m-%d %H:%M}")
|
||||
|
||||
print("\n===== 事件类数据(龙虎榜/北向/两融/解禁/大宗/可转债/研报) =====")
|
||||
for sub in ["longhubang", "north_flow", "margin", "blockade", "block_trade", "convertible_bond", "research"]:
|
||||
d = BASE / "events" / sub
|
||||
if d.exists():
|
||||
files = list(d.glob("*"))
|
||||
print(f" events/{sub}: {len(files)} files")
|
||||
else:
|
||||
print(f" events/{sub}: MISSING")
|
||||
# data 根下找可能的 events/其它事件目录
|
||||
for cand in ["events", "akshare_events", "longhubang", "north"]:
|
||||
d = BASE / cand
|
||||
if d.exists():
|
||||
print(f" {cand}/ exists")
|
||||
|
||||
print("\n===== constituent_unified source 分布(看 akshare 部分啥时点) =====")
|
||||
c = sqlite3.connect(str(BASE / "quant_trading.db"))
|
||||
try:
|
||||
print(" source 分布:", c.execute("SELECT source, COUNT(*) FROM constituent_unified GROUP BY source").fetchall())
|
||||
print(" per-index×source:")
|
||||
for row in c.execute("SELECT index_code, source, COUNT(*) FROM constituent_unified GROUP BY index_code, source ORDER BY index_code"):
|
||||
print(" ", row)
|
||||
finally:
|
||||
c.close()
|
||||
print("\nPROBE DONE")
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""probe_constituent.py — 单元3 前置: 探查成份股两源 schema (写 migrate 前必须看清)。
|
||||
|
||||
1. bs_index_constituent (DB, baostock 300/500/50 历史): cols/rows/distinct index_code/抽样
|
||||
2. data/index_const_hist/*.parquet (akshare cni 深证 union): 文件列表 + 每个 shape/cols/抽样
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
import pandas as pd
|
||||
|
||||
DB = r"C:\sanguo_vnpy_v2\data\quant_trading.db"
|
||||
HIST = r"C:\sanguo_vnpy_v2\data\index_const_hist"
|
||||
|
||||
c = sqlite3.connect(DB, timeout=60)
|
||||
c.execute("PRAGMA busy_timeout = 60000")
|
||||
cols = [r[1] for r in c.execute("PRAGMA table_info(bs_index_constituent)")]
|
||||
print("[bs_index_constituent] cols:", cols)
|
||||
print(" rows:", c.execute("SELECT COUNT(*) FROM bs_index_constituent").fetchone()[0])
|
||||
idx = [r[0] for r in c.execute(
|
||||
"SELECT DISTINCT index_code FROM bs_index_constituent ORDER BY index_code")]
|
||||
print(" distinct index_code:", idx)
|
||||
print(" distinct updateDate count:", c.execute(
|
||||
"SELECT COUNT(DISTINCT updateDate) FROM bs_index_constituent").fetchone()[0])
|
||||
print(" sample rows:", c.execute(
|
||||
"SELECT * FROM bs_index_constituent LIMIT 3").fetchall())
|
||||
c.close()
|
||||
|
||||
print("\n[index_const_hist parquets]")
|
||||
files = sorted(glob.glob(os.path.join(HIST, "*.parquet")))
|
||||
print("files:", [os.path.basename(f) for f in files])
|
||||
for f in files:
|
||||
df = pd.read_parquet(f)
|
||||
print(f" {os.path.basename(f)}: shape={df.shape} cols={list(df.columns)}")
|
||||
print(f" head:\n{df.head(2).to_string()}")
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""probe_dbbardata_unique.py — 单元4 merge 前置: 查 dbbardata UNIQUE 约束 + verify staging。
|
||||
|
||||
INSERT OR REPLACE 依赖 dbbardata 有 UNIQUE(symbol,exchange,datetime,interval) 才能去重,
|
||||
否则插重复。vnpy DbBarData 通常有 UniqueConstraint, 此处确认。
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
DB = r"C:\sanguo_vnpy_v2\data\quant_trading.db"
|
||||
c = sqlite3.connect(DB, timeout=120)
|
||||
c.execute("PRAGMA busy_timeout = 120000")
|
||||
|
||||
print("dbbardata sql:", c.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE name='dbbardata'").fetchone())
|
||||
print("\ndbbardata indexes:")
|
||||
for idx in c.execute("PRAGMA index_list('dbbardata')").fetchall():
|
||||
cols = c.execute(f"PRAGMA index_info('{idx[1]}')").fetchall()
|
||||
print(f" {idx} cols={cols}")
|
||||
|
||||
print("\n--- staging verify ---")
|
||||
print("staging rows:", c.execute(
|
||||
"SELECT COUNT(*) FROM dbbardata_staging_daily").fetchone()[0])
|
||||
print("staging distinct symbol:", c.execute(
|
||||
"SELECT COUNT(DISTINCT symbol) FROM dbbardata_staging_daily").fetchone()[0])
|
||||
for sym, label in [("000005", "退市"), ("000023", "退市"),
|
||||
("600519", "在市"), ("510300", "ETF")]:
|
||||
r = c.execute(
|
||||
"SELECT COUNT(*), MIN(datetime), MAX(datetime) "
|
||||
"FROM dbbardata_staging_daily WHERE symbol=?", (sym,)).fetchone()
|
||||
print(f" {sym}({label}): {r}")
|
||||
c.close()
|
||||
@@ -0,0 +1,185 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
P0 Task3 退市股 K 线 baostock 接口探针(只读)
|
||||
硬约束:
|
||||
- 直连不走代理(baostock 服务端在境内)
|
||||
- 单进程单登录串行(防黑名单)
|
||||
- 只读探针:不灌库不下全量
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import io
|
||||
|
||||
# Windows 控制台 utf-8 + 强制 flush
|
||||
if sys.platform == "win32":
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", write_through=True)
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace", write_through=True)
|
||||
|
||||
# 清代理(baostock 直连境内,走代理必挂)
|
||||
for k in ("http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
import baostock as bs
|
||||
import pandas as pd
|
||||
import time
|
||||
|
||||
def section(title):
|
||||
print("\n" + "=" * 60)
|
||||
print(f"### {title}")
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
def dump_rs(rs, label, max_rows=5):
|
||||
"""把 ResultData 读完并打印字段 + 前 N 行"""
|
||||
rows = []
|
||||
while (rs.error_code == '0') & rs.next():
|
||||
rows.append(rs.get_row_data())
|
||||
df = pd.DataFrame(rows, columns=rs.fields) if rows else pd.DataFrame(columns=rs.fields)
|
||||
print(f"[{label}] error={rs.error_code} {rs.error_msg} | fields={rs.fields} | rows={len(df)}")
|
||||
if len(df) > 0:
|
||||
print(f"[{label}] head({max_rows}):")
|
||||
print(df.head(max_rows).to_string())
|
||||
return df
|
||||
|
||||
# ============ 1. 登录 ============
|
||||
section("LOGIN")
|
||||
t0 = time.time()
|
||||
lg = bs.login()
|
||||
print(f"login: error_code={lg.error_code} error_msg={lg.error_msg} elapsed={time.time()-t0:.2f}s", flush=True)
|
||||
if lg.error_code != '0':
|
||||
print("LOGIN_FAILED_ABORT")
|
||||
sys.exit(1)
|
||||
|
||||
# ============ 2. query_all_stock(某日全市场列表) ============
|
||||
section("query_all_stock day=2026-07-18")
|
||||
rs = bs.query_all_stock(day="2026-07-18")
|
||||
df_all = dump_rs(rs, "query_all_stock(2026-07-18)", max_rows=5)
|
||||
# 看字段含不含 status / 退市日
|
||||
if len(df_all) > 0:
|
||||
print("\n字段分析:")
|
||||
print(f" columns = {list(df_all.columns)}")
|
||||
print(f" has_status = {'status' in df_all.columns}")
|
||||
print(f" has_outDate = {'outDate' in df_all.columns}")
|
||||
# code 前缀分布
|
||||
if 'code' in df_all.columns:
|
||||
df_all['prefix'] = df_all['code'].str.split('.').str[0]
|
||||
print(f" code 前缀分布:\n{df_all['prefix'].value_counts().to_string()}")
|
||||
# tradeStatus 分布(若有)
|
||||
if 'tradeStatus' in df_all.columns:
|
||||
print(f" tradeStatus 分布:\n{df_all['tradeStatus'].value_counts().to_string()}")
|
||||
|
||||
# ============ 3. query_all_stock 不同日期对比(取旧日,看是否还能查到已退市股) ============
|
||||
section("query_all_stock day=2021-01-04(旧日,看是否含现已退市)")
|
||||
rs_old = bs.query_all_stock(day="2021-01-04")
|
||||
df_old = dump_rs(rs_old, "query_all_stock(2021-01-04)", max_rows=3)
|
||||
# 对比两日 code 差集(2021 在但 2026 不在 = 期间退市的候选)
|
||||
if len(df_all) > 0 and len(df_old) > 0 and 'code' in df_all.columns:
|
||||
s_new = set(df_all['code'])
|
||||
s_old = set(df_old['code'])
|
||||
only_old = sorted(s_old - s_new)
|
||||
only_new = sorted(s_new - s_old)
|
||||
print(f"\n2021 有 / 2026 无(候选退市): {len(only_old)} 只 | 前 10 示例: {only_old[:10]}")
|
||||
print(f"2026 有 / 2021 无(期间上市): {len(only_new)} 只 | 前 10 示例: {only_new[:10]}")
|
||||
# 存下来供后面 K 线验证用
|
||||
candidate_delisted = only_old[:10] # 前 10 只候选退市股
|
||||
else:
|
||||
candidate_delisted = []
|
||||
|
||||
# ============ 4. query_stock_basic(单只基本信息 —— 找 status + outDate 字段) ============
|
||||
section("query_stock_basic 字段探查(活跃股 + 已知退市股)")
|
||||
# 先打活跃股(确认字段集)
|
||||
active_codes = ["sh.600000", "sz.000001", "sz.000002"]
|
||||
# 已知退市股候选(内存里的 + 常见历史退市)
|
||||
# sh.600074 退市保千 / sh.600432 退市吉恩 / sz.002450 *ST康得新 / sz.000033 新都退 / sh.600632 *ST 神城
|
||||
# 另外从 only_old 候选里挑几只(2021 有 2026 无 = 确认退市)
|
||||
delisted_candidates = ["sh.600074", "sh.600432", "sz.002450", "sz.000033", "sh.600632"]
|
||||
# 如果上面候选 only_old 里有,优先用(那些是 baostock 自己承认 2021 存在过的)
|
||||
probe_codes = active_codes + delisted_candidates
|
||||
if candidate_delisted:
|
||||
probe_codes = probe_codes + candidate_delisted[:5]
|
||||
|
||||
found_delisted_samples = []
|
||||
for code in probe_codes:
|
||||
rs2 = bs.query_stock_basic(code=code)
|
||||
b = []
|
||||
while (rs2.error_code == '0') & rs2.next():
|
||||
b.append(rs2.get_row_data())
|
||||
df2 = pd.DataFrame(b, columns=rs2.fields) if b else pd.DataFrame(columns=rs2.fields)
|
||||
print(f"\n--- query_stock_basic({code}) error={rs2.error_code} {rs2.error_msg}")
|
||||
print(f" fields = {rs2.fields}")
|
||||
if len(df2) > 0:
|
||||
print(f" data = {df2.to_dict('records')}")
|
||||
# 记录退市样本(outDate 非空 或 status=0)
|
||||
rec = df2.iloc[0].to_dict()
|
||||
status_val = str(rec.get('status', ''))
|
||||
outdate_val = str(rec.get('outDate', ''))
|
||||
if status_val == '0' or (outdate_val and outdate_val not in ('', 'nan', 'None')):
|
||||
found_delisted_samples.append((code, rec))
|
||||
else:
|
||||
print(" (empty)")
|
||||
time.sleep(0.3) # 温柔一点
|
||||
|
||||
print(f"\n找到的退市样本: {len(found_delisted_samples)}")
|
||||
for code, rec in found_delisted_samples:
|
||||
print(f" {code}: status={rec.get('status')} outDate={rec.get('outDate')} type={rec.get('type')}")
|
||||
|
||||
# ============ 5. 退市股 K 线验证 ============
|
||||
section("query_history_k_data_plus 退市股 K 线")
|
||||
fields = "date,code,open,high,low,close,preclose,volume,amount,adjustflag,turn,tradestatus,pctChg,peTTM,pbMRQ,psTTM,pcfNcfTTM,isST"
|
||||
|
||||
# 优先用真实退市股(status=0),否则用候选
|
||||
kline_targets = []
|
||||
for code, rec in found_delisted_samples:
|
||||
kline_targets.append((code, rec.get('outDate', '')))
|
||||
# 如果没找到 status=0 的退市股,直接用候选列表
|
||||
if not kline_targets:
|
||||
for code in delisted_candidates + candidate_delisted[:3]:
|
||||
kline_targets.append((code, ''))
|
||||
|
||||
# 去重
|
||||
seen = set()
|
||||
kline_targets_uniq = []
|
||||
for code, outdate in kline_targets:
|
||||
if code not in seen:
|
||||
seen.add(code)
|
||||
kline_targets_uniq.append((code, outdate))
|
||||
|
||||
print(f"K 线验证标的({len(kline_targets_uniq)}): {kline_targets_uniq}")
|
||||
|
||||
for code, outdate in kline_targets_uniq[:6]:
|
||||
print(f"\n--- K 线 {code} (outDate={outdate}) ---")
|
||||
rs4 = bs.query_history_k_data_plus(
|
||||
code, fields,
|
||||
start_date='2020-01-01', end_date='2026-07-18',
|
||||
frequency="d", adjustflag="3"
|
||||
)
|
||||
k = []
|
||||
while (rs4.error_code == '0') & rs4.next():
|
||||
k.append(rs4.get_row_data())
|
||||
df4 = pd.DataFrame(k, columns=rs4.fields) if k else pd.DataFrame(columns=rs4.fields)
|
||||
print(f" error={rs4.error_code} {rs4.error_msg} | rows={len(df4)}")
|
||||
if len(df4) > 0:
|
||||
# 字符串转日期比大小
|
||||
try:
|
||||
df4['date_dt'] = pd.to_datetime(df4['date'], errors='coerce')
|
||||
maxd = df4['date_dt'].max()
|
||||
mind = df4['date_dt'].min()
|
||||
print(f" date range: {mind.date()} ~ {maxd.date()}")
|
||||
print(f" tail(3):")
|
||||
print(df4.drop(columns=['date_dt']).tail(3).to_string())
|
||||
except Exception as e:
|
||||
print(f" date parse err: {e}")
|
||||
print(df4.tail(3).to_string())
|
||||
time.sleep(0.3)
|
||||
|
||||
# ============ 6. 近 5 年退市股数量估计(小抽样,不扫全量) ============
|
||||
section("近 5 年退市股数量估计(基于 query_all_stock 差集 + 候选 basic)")
|
||||
if len(df_all) > 0 and len(df_old) > 0:
|
||||
print(f"2021-01-04 全市场: {len(df_old)} 只")
|
||||
print(f"2026-07-18 全市场: {len(df_all)} 只")
|
||||
print(f"2021 在 2026 不在的差集(候选期间退市/暂停/更名): {len(candidate_delisted)} 只(实际 {len(set(df_old['code']) - set(df_all['code']))} 只)")
|
||||
print(f"注:这差集是『上限』——含真退市 + 更名/合并/暂停 + 历史数据缺日等。精确退市数要逐只 query_stock_basic 看 status=0 + outDate。")
|
||||
|
||||
# ============ 7. logout ============
|
||||
section("LOGOUT")
|
||||
bs.logout()
|
||||
print("DONE_PROBE")
|
||||
@@ -0,0 +1,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P0 Task3 补充探针:早期退市股 K 线完整性 + 全市场 status=0 数量抽样估计"""
|
||||
import sys, os, io
|
||||
if sys.platform == "win32":
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", write_through=True)
|
||||
for k in ("http_proxy","https_proxy","all_proxy","HTTP_PROXY","HTTPS_PROXY","ALL_PROXY"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
import baostock as bs
|
||||
import pandas as pd
|
||||
import time
|
||||
|
||||
lg = bs.login()
|
||||
print(f"login: {lg.error_code} {lg.error_msg}")
|
||||
if lg.error_code != '0':
|
||||
print("ABORT"); sys.exit(1)
|
||||
|
||||
# === 1. 早期退市股用更早 start_date 验证 K 线完整性 ===
|
||||
print("\n=== A. 早期退市股 K 线(start_date=2010-01-01) ===")
|
||||
fields = "date,code,open,high,low,close,preclose,volume,amount,adjustflag,turn,tradestatus,pctChg,peTTM,pbMRQ,psTTM,pcfNcfTTM,isST"
|
||||
early_delisted = [
|
||||
("sh.600432", "2018-07-13"),
|
||||
("sz.000033", "2017-07-07"),
|
||||
("sh.600632", "2004-11-18"),
|
||||
]
|
||||
for code, outdate in early_delisted:
|
||||
rs = bs.query_history_k_data_plus(
|
||||
code, fields,
|
||||
start_date='2010-01-01', end_date='2026-07-18',
|
||||
frequency="d", adjustflag="3"
|
||||
)
|
||||
k = []
|
||||
while (rs.error_code == '0') & rs.next():
|
||||
k.append(rs.get_row_data())
|
||||
df = pd.DataFrame(k, columns=rs.fields) if k else pd.DataFrame(columns=rs.fields)
|
||||
if len(df) > 0:
|
||||
df['date_dt'] = pd.to_datetime(df['date'], errors='coerce')
|
||||
mind = df['date_dt'].min(); maxd = df['date_dt'].max()
|
||||
print(f" {code} outDate={outdate}: rows={len(df)} range={mind.date()}~{maxd.date()} | maxdate==outDate? {str(maxd.date())==outdate}")
|
||||
print(f" tail(2):")
|
||||
print(df.drop(columns=['date_dt']).tail(2).to_string())
|
||||
else:
|
||||
print(f" {code} outDate={outdate}: rows=0 err={rs.error_msg}")
|
||||
time.sleep(0.3)
|
||||
|
||||
# === 2. 全市场 status=0 退市股数量抽样估计 ===
|
||||
# 从 2021-01-04 全市场 code 列表 + 扩展号段扫描 status=0
|
||||
print("\n=== B. 全市场退市股数量估计 ===")
|
||||
# 先用 query_all_stock 拿基准列表(2021-01-04)
|
||||
rs0 = bs.query_all_stock(day="2021-01-04")
|
||||
base_codes = []
|
||||
while (rs0.error_code == '0') & rs0.next():
|
||||
base_codes.append(rs0.get_row_data()[0])
|
||||
print(f" 2021-01-04 all_stock code 数: {len(base_codes)}")
|
||||
|
||||
# 对这些 code 全部查 basic,统计 status=0 + outDate 分布
|
||||
# 但 4687 只全扫会耗费 ~4687 query,Mac 单登录串行 ~30 分钟
|
||||
# 改为抽样:每 10 只取 1 只,共约 470 只
|
||||
sample_codes = base_codes[::10]
|
||||
print(f" 抽样步长=10, 抽样数: {len(sample_codes)}")
|
||||
|
||||
status0_cnt = 0
|
||||
status0_samples = []
|
||||
ipo_cnt = 0
|
||||
for i, code in enumerate(sample_codes):
|
||||
rs2 = bs.query_stock_basic(code=code)
|
||||
b = []
|
||||
while (rs2.error_code == '0') & rs2.next():
|
||||
b.append(rs2.get_row_data())
|
||||
if b:
|
||||
df2 = pd.DataFrame(b, columns=rs2.fields)
|
||||
rec = df2.iloc[0].to_dict()
|
||||
if str(rec.get('status','')) == '0':
|
||||
status0_cnt += 1
|
||||
status0_samples.append(rec)
|
||||
elif str(rec.get('status','')) == '1':
|
||||
ipo_cnt += 1
|
||||
if (i+1) % 50 == 0:
|
||||
print(f" progress {i+1}/{len(sample_codes)} | status0={status0_cnt} status1={ipo_cnt}", flush=True)
|
||||
time.sleep(0.05) # 50ms 间隔 = 20 qps,温柔
|
||||
|
||||
print(f"\n 抽样结果: {len(sample_codes)} 只中 status=0 {status0_cnt} 只, status=1 {ipo_cnt} 只")
|
||||
print(f" 退市率(抽样): {status0_cnt/len(sample_codes)*100:.2f}%")
|
||||
print(f" 外推全市场(基于 2021-01-04 的 {len(base_codes)} 只): {int(status0_cnt/len(sample_codes)*len(base_codes))} 只 status=0")
|
||||
print(f" 注:2021-01-04 当日已退市的不会出现在列表里,所以这是『2021-01-04 还在交易,但之后退市』的估计")
|
||||
print(f" status=0 样本前 10:")
|
||||
for rec in status0_samples[:10]:
|
||||
print(f" {rec.get('code')} {rec.get('code_name')} ipoDate={rec.get('ipoDate')} outDate={rec.get('outDate')}")
|
||||
|
||||
bs.logout()
|
||||
print("DONE_PROBE_EXT")
|
||||
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""最小探针:早期退市股 K 线完整性(3 query,瞬时)"""
|
||||
import sys, os, io
|
||||
if sys.platform == "win32":
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", write_through=True)
|
||||
for k in ("http_proxy","https_proxy","all_proxy","HTTP_PROXY","HTTPS_PROXY","ALL_PROXY"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
import baostock as bs
|
||||
import pandas as pd
|
||||
|
||||
lg = bs.login()
|
||||
print(f"login: {lg.error_code} {lg.error_msg}", flush=True)
|
||||
if lg.error_code != '0':
|
||||
sys.exit(1)
|
||||
|
||||
fields = "date,code,open,high,low,close,volume,amount,turn,pctChg,peTTM,pbMRQ,isST"
|
||||
early = [
|
||||
("sh.600432", "2018-07-13"),
|
||||
("sz.000033", "2017-07-07"),
|
||||
("sh.600632", "2004-11-18"),
|
||||
]
|
||||
for code, outdate in early:
|
||||
rs = bs.query_history_k_data_plus(
|
||||
code, fields,
|
||||
start_date='2010-01-01', end_date='2026-07-18',
|
||||
frequency="d", adjustflag="3"
|
||||
)
|
||||
k = []
|
||||
while (rs.error_code == '0') & rs.next():
|
||||
k.append(rs.get_row_data())
|
||||
df = pd.DataFrame(k, columns=rs.fields) if k else pd.DataFrame(columns=rs.fields)
|
||||
if len(df) > 0:
|
||||
print(f"{code} outDate={outdate}: rows={len(df)} range={df['date'].min()}~{df['date'].max()}", flush=True)
|
||||
print(f" maxdate==outDate? {df['date'].max()==outdate} | tail:", flush=True)
|
||||
print(df.tail(2).to_string(), flush=True)
|
||||
else:
|
||||
print(f"{code} outDate={outdate}: rows=0 err={rs.error_msg}", flush=True)
|
||||
|
||||
bs.logout()
|
||||
print("DONE", flush=True)
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ETF universe + 前复权日线探针(P0 Task2.1)。
|
||||
VPS 跑: C:\\Python310\\python.exe -X utf8 probe_etf.py
|
||||
"""
|
||||
import sys
|
||||
from xtquant import xtdata as xd
|
||||
|
||||
|
||||
def main():
|
||||
etf = xd.get_stock_list_in_sector("沪深ETF") or []
|
||||
fund = xd.get_stock_list_in_sector("沪深基金") or []
|
||||
a = xd.get_stock_list_in_sector("沪深A股") or []
|
||||
u = list(set(a + etf + fund))
|
||||
print(f"A={len(a)} ETF={len(etf)} fund={len(fund)} union={len(u)}")
|
||||
print(f"sample ETF: {etf[:5]}")
|
||||
print(f"sample fund: {fund[:5]}")
|
||||
|
||||
# 抽样: 510300.SH(沪深300ETF) / 513050.SH(中概互联网ETF) / 159919.SZ(300ETF)
|
||||
samples = ["510300.SH", "513050.SH", "159919.SZ"]
|
||||
r = xd.get_market_data_ex([], samples, period="1d",
|
||||
start_time="20240101", end_time="20260721",
|
||||
dividend_type="front")
|
||||
for sym in samples:
|
||||
df = r.get(sym) if r else None
|
||||
bars = 0 if df is None else len(df)
|
||||
tail_close = None if df is None or not len(df) else float(df["close"].iloc[-1])
|
||||
head_date = None if df is None or not len(df) else str(df.index[0])[:8]
|
||||
tail_date = None if df is None or not len(df) else str(df.index[-1])[:8]
|
||||
nan_close = None if df is None else bool(df["close"].isnull().any())
|
||||
print(f"{sym}: bars={bars} date=[{head_date}~{tail_date}] tail_close={tail_close} nan_close={nan_close}")
|
||||
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ETF 探针 v2: 先 download 再读,对比 dividend_type,确认 ETF 在 sector 中。"""
|
||||
import sys
|
||||
from xtquant import xtdata as xd
|
||||
|
||||
|
||||
def main():
|
||||
etf = xd.get_stock_list_in_sector("沪深ETF") or []
|
||||
fund = xd.get_stock_list_in_sector("沪深基金") or []
|
||||
a = xd.get_stock_list_in_sector("沪深A股") or []
|
||||
u = list(set(a + etf + fund))
|
||||
print(f"A={len(a)} ETF={len(etf)} fund={len(fund)} union={len(u)}")
|
||||
|
||||
# 1) 检查目标 samples 是否在 universe
|
||||
for sym in ("510300.SH", "513050.SH", "159919.SZ"):
|
||||
print(f" {sym} in A={sym in a} in ETF={sym in etf} in fund={sym in fund}")
|
||||
|
||||
# 2) 重叠分析: ETF 与 fund 是否相同
|
||||
overlap = set(etf) & set(fund)
|
||||
only_etf = set(etf) - set(fund)
|
||||
only_fund = set(fund) - set(etf)
|
||||
print(f"overlap(ETF&fund)={len(overlap)} only_etf={len(only_etf)} only_fund={len(only_fund)}")
|
||||
if only_etf:
|
||||
print(f" only_etf sample: {list(only_etf)[:5]}")
|
||||
if only_fund:
|
||||
print(f" only_fund sample: {list(only_fund)[:5]}")
|
||||
|
||||
# 3) 先 download 再读
|
||||
samples = ["510300.SH", "513050.SH", "159919.SZ"]
|
||||
print(f"\n=== download_history_data(1d, 20240101~20260721) ===")
|
||||
for sym in samples:
|
||||
try:
|
||||
n = xd.download_history_data(sym, "1d", "20240101", "20260721")
|
||||
print(f" {sym} download returned: {n}")
|
||||
except Exception as e:
|
||||
print(f" {sym} download err: {e}")
|
||||
|
||||
# 4) 读两种 dividend_type
|
||||
for dt in ("front", "none"):
|
||||
print(f"\n=== get_market_data_ex dividend_type={dt} ===")
|
||||
r = xd.get_market_data_ex([], samples, period="1d",
|
||||
start_time="20240101", end_time="20260721",
|
||||
dividend_type=dt)
|
||||
for sym in samples:
|
||||
df = r.get(sym) if r else None
|
||||
bars = 0 if df is None else len(df)
|
||||
tail_close = None if df is None or not len(df) else float(df["close"].iloc[-1])
|
||||
head_date = None if df is None or not len(df) else str(df.index[0])[:8]
|
||||
tail_date = None if df is None or not len(df) else str(df.index[-1])[:8]
|
||||
print(f" {sym}: bars={bars} date=[{head_date}~{tail_date}] tail_close={tail_close}")
|
||||
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Smoke + timing: get_fundamentals_df fields= short-circuit + ThreadPool.
|
||||
|
||||
ASCII-only (VPS GBK console safe). Run on VPS:
|
||||
C:\\Python310\\python.exe -X utf8 probe_fundamentals_panel.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
os.environ.pop("http_proxy", None)
|
||||
os.environ.pop("https_proxy", None)
|
||||
os.environ.pop("all_proxy", None)
|
||||
|
||||
sys.path.insert(0, r"C:\sanguo_vnpy_v2")
|
||||
|
||||
from sanguo_portfolio.providers.local_unified_provider import LocalUnifiedProvider
|
||||
|
||||
DB = r"C:\sanguo_vnpy_v2\data\quant_trading.db"
|
||||
DATA = r"C:\sanguo_vnpy_v2\data"
|
||||
|
||||
p = LocalUnifiedProvider({"db_path": DB, "data_dir": DATA})
|
||||
|
||||
# candidate pool: pull a few hundred codes from constituent_unified (000985 = full mkt)
|
||||
try:
|
||||
codes = p.get_index_stocks("000985", "2024-06-03")
|
||||
except Exception as exc:
|
||||
print("get_index_stocks failed:", exc)
|
||||
codes = []
|
||||
codes = codes[:300] if codes else []
|
||||
jq = [c if "." in c else c + ".XSHE" for c in codes]
|
||||
print("pool size:", len(jq))
|
||||
if not jq:
|
||||
sys.exit(0)
|
||||
|
||||
date = "2024-06-03"
|
||||
|
||||
# warm caches once (first hit pays file open) to measure steady-ish state? No -
|
||||
# measure COLD first-rebalance (the real pain): fields=None full read.
|
||||
t0 = time.time()
|
||||
df_none = p.get_fundamentals_df(jq, date=date)
|
||||
t_none = time.time() - t0
|
||||
|
||||
# fresh provider to drop per-instance caches, measure fields= short-circuit cold
|
||||
p2 = LocalUnifiedProvider({"db_path": DB, "data_dir": DATA})
|
||||
t0 = time.time()
|
||||
df_fld = p2.get_fundamentals_df(jq, date=date, fields=["market_cap", "eps"])
|
||||
t_fld = time.time() - t0
|
||||
|
||||
print("fields=None : %6.2fs rows=%d cols=%d" % (t_none, len(df_none), len(df_none.columns)))
|
||||
print("fields=[mkt,ep]: %6.2fs rows=%d cols=%d" % (t_fld, len(df_fld), len(df_fld.columns)))
|
||||
if t_fld > 0:
|
||||
print("speedup : %.1fx" % (t_none / t_fld))
|
||||
|
||||
# correctness: market_cap + eps match between the two
|
||||
import pandas as pd
|
||||
common = [c for c in df_fld.columns if c in df_none.columns]
|
||||
for col in ("market_cap", "eps"):
|
||||
a = df_none[col].reindex(df_fld.index)
|
||||
b = df_fld[col]
|
||||
mask = a.notna() & b.notna()
|
||||
diff = (a[mask] - b[mask]).abs().max() if mask.any() else 0.0
|
||||
print("match %-12s: max_diff=%.6g" % (col, diff))
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Smoke: get_limit_status_batch on real dbbardata (window query + detection).
|
||||
|
||||
ASCII-only (VPS GBK console safe). Run on VPS:
|
||||
C:\\Python310\\python.exe -X utf8 probe_limit_status.py
|
||||
"""
|
||||
import collections
|
||||
import os
|
||||
import sys
|
||||
|
||||
for k in ("http_proxy", "https_proxy", "all_proxy"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
sys.path.insert(0, r"C:\sanguo_vnpy_v2")
|
||||
from sanguo_portfolio.providers.local_unified_provider import LocalUnifiedProvider
|
||||
|
||||
DB = r"C:\sanguo_vnpy_v2\data\quant_trading.db"
|
||||
DATA = r"C:\sanguo_vnpy_v2\data"
|
||||
p = LocalUnifiedProvider({"db_path": DB, "data_dir": DATA})
|
||||
|
||||
DATE = "2024-06-03"
|
||||
try:
|
||||
codes = p.get_index_stocks("000985", DATE)
|
||||
except Exception as exc:
|
||||
print("get_index_stocks failed:", exc)
|
||||
codes = []
|
||||
jq = [c if "." in c else c + ".XSHE" for c in codes[:800]]
|
||||
print("pool:", len(jq), "date:", DATE)
|
||||
|
||||
out = p.get_limit_status_batch(jq, DATE)
|
||||
cnt = collections.Counter()
|
||||
examples = {"up": [], "down": [], "paused": []}
|
||||
for k, v in (out or {}).items():
|
||||
if v is None:
|
||||
cnt["none"] += 1
|
||||
continue
|
||||
if v["is_limit_up"]:
|
||||
cnt["up"] += 1
|
||||
if len(examples["up"]) < 5:
|
||||
examples["up"].append(k)
|
||||
elif v["is_limit_down"]:
|
||||
cnt["down"] += 1
|
||||
if len(examples["down"]) < 5:
|
||||
examples["down"].append(k)
|
||||
if v["is_paused"]:
|
||||
cnt["paused"] += 1
|
||||
if len(examples["paused"]) < 5:
|
||||
examples["paused"].append(k)
|
||||
if not (v["is_limit_up"] or v["is_limit_down"] or v["is_paused"]):
|
||||
cnt["normal"] += 1
|
||||
|
||||
print("counts:", dict(cnt))
|
||||
print("limit_up examples:", examples["up"])
|
||||
print("limit_down examples:", examples["down"])
|
||||
print("paused examples:", examples["paused"])
|
||||
@@ -0,0 +1,134 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""UnifiedProvider + all_weather 数据链路诊断探针(VPS 跑, 快速版)。
|
||||
|
||||
每步带时间戳 + flush, 超时也能看卡哪。慢步骤降样本。
|
||||
定位: equity 重复日 / B_mean=0 / 数据缺失。
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from collections import Counter
|
||||
|
||||
try:
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def step(name):
|
||||
print(f"\n=== {name} [+{time.time()-t0:.1f}s]", flush=True)
|
||||
|
||||
|
||||
def line(k, v):
|
||||
print(f"[{k}] {v}", flush=True)
|
||||
|
||||
|
||||
VPS_ROOT = r"C:\sanguo_vnpy_v2"
|
||||
DB = os.path.join(VPS_ROOT, "data", "quant_trading.db")
|
||||
|
||||
os.environ.setdefault("DEFAULT_DATA_PROVIDER", "jqdata")
|
||||
from unittest.mock import MagicMock
|
||||
if "jqdatasdk" not in sys.modules:
|
||||
_m = MagicMock()
|
||||
_m.utils.assert_auth = lambda f: f
|
||||
sys.modules["jqdatasdk"] = _m
|
||||
|
||||
sys.path.insert(0, VPS_ROOT)
|
||||
|
||||
step("STEP0 环境")
|
||||
line("python", sys.version.split()[0])
|
||||
line("PKG", os.path.isdir(os.path.join(VPS_ROOT, "sanguo_portfolio")))
|
||||
line("DB", os.path.exists(DB))
|
||||
conn = sqlite3.connect(DB)
|
||||
tabs = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||
line("tables", tabs)
|
||||
|
||||
step("STEP0.5 混合 datetime 检测(单只抽样, 不全表 COUNT)")
|
||||
# 单只 600519 抽样看格式(走索引, 快)
|
||||
sample = conn.execute(
|
||||
"SELECT datetime FROM dbbardata WHERE symbol='600519' AND exchange='SSE' "
|
||||
"AND interval='d' ORDER BY datetime DESC LIMIT 5"
|
||||
).fetchall()
|
||||
line("600519 最近5条 datetime", [r[0] for r in sample])
|
||||
# DISTINCT 对比(单只, 索引内)
|
||||
d_raw = conn.execute(
|
||||
"SELECT COUNT(DISTINCT datetime) FROM dbbardata "
|
||||
"WHERE symbol='600519' AND exchange='SSE' AND interval='d'"
|
||||
).fetchone()[0]
|
||||
d_sub = conn.execute(
|
||||
"SELECT COUNT(DISTINCT substr(datetime,1,10)) FROM dbbardata "
|
||||
"WHERE symbol='600519' AND exchange='SSE' AND interval='d'"
|
||||
).fetchone()[0]
|
||||
line("DISTINCT datetime(原始)", d_raw)
|
||||
line("DISTINCT substr(datetime,1,10)(按日)", d_sub)
|
||||
line("重复日数(原始-按日)", d_raw - d_sub)
|
||||
|
||||
step("STEP1 get_trade_days 重复日期(equity_curve 重复 bug 根因)")
|
||||
from sanguo_portfolio.providers import LocalUnifiedProvider
|
||||
p = LocalUnifiedProvider({})
|
||||
days = p.get_trade_days(start_date="2024-01-02", end_date="2024-03-31")
|
||||
strs = [str(d)[:10] for d in days]
|
||||
line("trade_days total", len(days))
|
||||
line("unique dates", len(set(strs)))
|
||||
dup = [d for d, c in Counter(strs).items() if c > 1]
|
||||
line("DUP dates count", len(dup))
|
||||
line("DUP sample", dup[:5])
|
||||
|
||||
step("STEP2 成分股(constituent_unified 覆盖)")
|
||||
for idx in ["000300", "399101", "399001", "000852"]:
|
||||
try:
|
||||
s = p.get_index_stocks(idx)
|
||||
line(f"index_stocks {idx}", len(s))
|
||||
except Exception as e:
|
||||
line(f"index_stocks {idx} ERR", repr(e))
|
||||
|
||||
step("STEP3 fundamentals 600519(单股, 关键字段)")
|
||||
fdf = p.get_fundamentals_df(["600519.XSHG"], date="2024-03-29")
|
||||
cols_chk = [
|
||||
"code", "market_cap", "circulating_market_cap", "pe_ratio", "pb_ratio",
|
||||
"ps_ratio", "pcf_ratio", "eps", "roe", "roa", "gross_profit_margin",
|
||||
"net_profit_margin", "inc_revenue_year_on_year", "roic",
|
||||
]
|
||||
for c in cols_chk:
|
||||
if c in fdf.columns:
|
||||
line(f" {c}", fdf[c].iloc[0])
|
||||
else:
|
||||
line(f" {c}", "MISSING_COL")
|
||||
|
||||
step("STEP4 _trend_mean 小样本复算(hs300 前40, B_mean=0 根因)")
|
||||
import numpy as np
|
||||
hs300 = p.get_index_stocks("000300")
|
||||
line("hs300 size", len(hs300))
|
||||
# 只取前 40 只做 fundamentals(提速), top20 by circ_mktcap
|
||||
sample40 = hs300[:40]
|
||||
fdf2 = p.get_fundamentals_df(sample40, date="2024-03-29")
|
||||
line("fdf2 shape", fdf2.shape)
|
||||
if "circulating_market_cap" in fdf2.columns:
|
||||
line("circ_mktcap nonNaN", int(fdf2["circulating_market_cap"].notna().sum()))
|
||||
fdf2s = fdf2.sort_values("circulating_market_cap", ascending=False, na_position="last")
|
||||
blst = list(fdf2s.index)[:20]
|
||||
line("blst(20)", blst)
|
||||
df = p.get_price(blst, end_date="2024-03-29", frequency="1d", fields=["close"], count=10, panel=False)
|
||||
line("trend get_price isNone", df is None)
|
||||
if df is not None:
|
||||
line("trend get_price shape", df.shape)
|
||||
line("trend cols", list(df.columns))
|
||||
line("time dtype", df["time"].dtype if "time" in df.columns else "NO_TIME")
|
||||
print(df.head(3).to_string(), flush=True)
|
||||
try:
|
||||
pivot = df.pivot(index="time", columns="code", values="close")
|
||||
line("pivot shape", pivot.shape)
|
||||
if len(pivot) >= 2:
|
||||
change = (pivot.iloc[-1] / pivot.iloc[0] - 1) * 100
|
||||
arr = np.nan_to_num(change.to_numpy())
|
||||
line("B_mean manual", float(np.mean(arr)))
|
||||
line("change nonZero count", int((arr != 0).sum()))
|
||||
else:
|
||||
line("pivot rows<2", len(pivot))
|
||||
except Exception as e:
|
||||
line("pivot ERR", repr(e))
|
||||
|
||||
step("DONE")
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""probe_unified_schema.py — 探查方案A 新权威数据层 schema (LocalUnifiedProvider 实现依赖)。
|
||||
|
||||
输出 constituent_unified / dbbardata('d') / bs_adjust_factor / valuation_baostock 的
|
||||
列结构 + 样本 + 覆盖, 供 spec §6 使用层 provider 实现参考。
|
||||
"""
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
BASE = Path(r"C:\sanguo_vnpy_v2")
|
||||
DB = BASE / "data" / "quant_trading.db"
|
||||
VAL_DIR = BASE / "data" / "valuation_baostock"
|
||||
|
||||
|
||||
def section(title):
|
||||
print(f"\n===== {title} =====")
|
||||
|
||||
|
||||
# 1. constituent_unified
|
||||
section("constituent_unified schema")
|
||||
c = sqlite3.connect(str(DB))
|
||||
try:
|
||||
info = c.execute("PRAGMA table_info(constituent_unified)").fetchall()
|
||||
print("columns:", [(r[1], r[2]) for r in info])
|
||||
print("count/index:",
|
||||
c.execute("SELECT COUNT(*), COUNT(DISTINCT index_code) FROM constituent_unified").fetchone())
|
||||
print("per-index (total / in_current / was_removed):")
|
||||
for row in c.execute("SELECT index_code, COUNT(*), SUM(in_current), SUM(was_removed) "
|
||||
"FROM constituent_unified GROUP BY index_code ORDER BY index_code"):
|
||||
print(" ", row)
|
||||
print("sample 300:")
|
||||
for row in c.execute("SELECT * FROM constituent_unified WHERE index_code LIKE '%300%' LIMIT 3"):
|
||||
print(" ", row)
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
# 2. dbbardata('d') raw 个股 + ETF
|
||||
section("dbbardata('d') sample")
|
||||
c = sqlite3.connect(str(DB))
|
||||
try:
|
||||
print("columns:", [r[1] for r in c.execute("PRAGMA table_info(dbbardata)").fetchall()])
|
||||
for sym, lbl in [("600519", "个股在市"), ("000005", "退市"), ("510300", "ETF")]:
|
||||
r = c.execute("SELECT COUNT(*), MIN(datetime), MAX(datetime) FROM dbbardata "
|
||||
"WHERE symbol=? AND interval='d'", (sym,)).fetchone()
|
||||
print(f" {sym}({lbl}): {r}")
|
||||
print("sample 600519 last 3:")
|
||||
for row in c.execute("SELECT symbol,exchange,datetime,volume,turnover,open_price,close_price "
|
||||
"FROM dbbardata WHERE symbol='600519' AND interval='d' "
|
||||
"ORDER BY datetime DESC LIMIT 3"):
|
||||
print(" ", row)
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
# 3. bs_adjust_factor
|
||||
section("bs_adjust_factor schema")
|
||||
c = sqlite3.connect(str(DB))
|
||||
try:
|
||||
tabs = [r[0] for r in c.execute("SELECT name FROM sqlite_master WHERE type='table' "
|
||||
"AND name LIKE '%adjust%'").fetchall()]
|
||||
print("adjust tables:", tabs)
|
||||
if "bs_adjust_factor" in tabs:
|
||||
print("columns:", [r[1] for r in c.execute("PRAGMA table_info(bs_adjust_factor)").fetchall()])
|
||||
print("count:", c.execute("SELECT COUNT(*) FROM bs_adjust_factor").fetchone())
|
||||
print("sample 600519:")
|
||||
for row in c.execute("SELECT * FROM bs_adjust_factor WHERE code LIKE '%600519%' LIMIT 3"):
|
||||
print(" ", row)
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
# 4. valuation_baostock parquet
|
||||
section("valuation_baostock parquet")
|
||||
print("years:", sorted(p.name for p in VAL_DIR.glob("*.parquet")) if VAL_DIR.exists() else "DIR MISSING")
|
||||
p2026 = VAL_DIR / "2026.parquet"
|
||||
if p2026.exists():
|
||||
df = pd.read_parquet(p2026)
|
||||
print("columns:", list(df.columns))
|
||||
print("shape:", df.shape)
|
||||
print("sample 600519:")
|
||||
sub = df[df["symbol"].astype(str).str.contains("600519")] if "symbol" in df.columns else df.head(0)
|
||||
print(sub.head(3).to_dict("records"))
|
||||
|
||||
# 5. static akshare 三表 + valuation (market_cap/total_share 来源 + 文件格式)
|
||||
from collections import Counter
|
||||
section("static akshare (格式 + market_cap/total_share 来源)")
|
||||
for sub in ["valuation", "balance", "income"]:
|
||||
d = BASE / "data" / "static" / sub
|
||||
if not d.exists():
|
||||
print(f"{sub}: DIR MISSING")
|
||||
continue
|
||||
allf = list(d.iterdir())
|
||||
exts = Counter(p.suffix for p in allf)
|
||||
print(f"{sub}: {len(allf)} files, ext分布={dict(exts)}")
|
||||
matches = sorted([p for p in allf if "600519" in p.name])
|
||||
if not matches:
|
||||
print(" 600519 无文件")
|
||||
continue
|
||||
f0 = matches[0]
|
||||
with open(f0, "rb") as fh:
|
||||
head = fh.read(8)
|
||||
print(f" 600519 file={f0.name} magic={head[:4]!r}")
|
||||
if head[:4] == b"PAR1":
|
||||
df = pd.read_parquet(f0)
|
||||
print(f" parquet cols({len(df.columns)}):", list(df.columns)[:20])
|
||||
if sub == "valuation":
|
||||
hit = [c for c in df.columns if any(k in str(c) for k in ("市值", "股本"))]
|
||||
print(" 市值/股本列:", hit, "sample:", df.head(1).to_dict("records"))
|
||||
if sub == "balance":
|
||||
hit = [c for c in df.columns if any(k in str(c) for k in ("TOTAL_SHARES", "TOTAL_SHARE", "总股本", "实收资本"))]
|
||||
print(" 股本相关列:", hit)
|
||||
else:
|
||||
print(f" 非 parquet (magic={head[:4]!r})")
|
||||
|
||||
# 6. 复权因子构造验证 (600519 foreAdjustFactor 语义)
|
||||
section("复权因子 foreAdjustFactor 语义验证")
|
||||
c = sqlite3.connect(str(DB))
|
||||
try:
|
||||
rows = c.execute("SELECT dividOperateDate, foreAdjustFactor FROM bs_adjust_factor "
|
||||
"WHERE code='sh.600519' ORDER BY dividOperateDate").fetchall()
|
||||
print("600519 除权事件数:", len(rows))
|
||||
print("前3:", rows[:3])
|
||||
print("后3:", rows[-3:])
|
||||
print("语义推断: foreAdjustFactor 递增=累计前复权因子; 最新事件后=1.0; "
|
||||
"qfq[t]=raw[t]*factor[t]")
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
print("\nPROBE DONE")
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""实时行情三源降级 - 新浪→东财→腾讯"""
|
||||
import urllib.request
|
||||
import json
|
||||
import re
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HEADERS_SINA = {
|
||||
"User-Agent": "Mozilla/5.0", "Referer": "https://finance.sina.com.cn",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9"
|
||||
}
|
||||
HEADERS_EM = {"Referer": "https://www.eastmoney.com"}
|
||||
FETCHED_AT = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _fetch_url(url: str, headers: dict = None, timeout: int = 10) -> str:
|
||||
req = urllib.request.Request(url, headers=headers or {})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
charset = "gbk" if "sina" in url or "sinajs" in url else "utf-8"
|
||||
return r.read().decode(charset, errors="replace")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_sina(raw: str, symbol: str) -> Optional[dict]:
|
||||
m = re.search(r'"([^"]*)"', raw)
|
||||
if not m:
|
||||
return None
|
||||
parts = m.group(1).split(",")
|
||||
if len(parts) < 32:
|
||||
return None
|
||||
try:
|
||||
prev_close = float(parts[2]) if parts[2] else 0
|
||||
current = float(parts[3]) if parts[3] else 0
|
||||
return {
|
||||
"symbol": symbol, "name": parts[0],
|
||||
"current": round(current, 2), "prev_close": round(prev_close, 2),
|
||||
"open": round(float(parts[1]), 2) if parts[1] else 0,
|
||||
"high": round(float(parts[4]), 2) if parts[4] else 0,
|
||||
"low": round(float(parts[5]), 2) if parts[5] else 0,
|
||||
"volume": int(float(parts[8])) if parts[8] else 0,
|
||||
"amount": round(float(parts[9]), 2) if parts[9] else 0,
|
||||
"timestamp": f"{parts[30]} {parts[31]}" if len(parts) > 31 else "",
|
||||
"source": "sina", "fetched_at": FETCHED_AT,
|
||||
}
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_tencent(raw: str, symbol: str) -> Optional[dict]:
|
||||
m = re.search(r'"([^"]*)"', raw)
|
||||
if not m:
|
||||
return None
|
||||
fields = m.group(1).split("~")
|
||||
if len(fields) < 35:
|
||||
return None
|
||||
try:
|
||||
current = float(fields[3])
|
||||
prev_close = float(fields[4])
|
||||
if current <= 0:
|
||||
return None
|
||||
return {
|
||||
"symbol": symbol, "name": fields[1],
|
||||
"current": round(current, 2), "prev_close": round(prev_close, 2),
|
||||
"open": round(float(fields[5]), 2),
|
||||
"high": round(float(fields[33]), 2) if fields[33] else 0,
|
||||
"low": round(float(fields[34]), 2) if fields[34] else 0,
|
||||
"volume": int(float(fields[6])) if fields[6] else 0,
|
||||
"amount": round(float(fields[37]) * 10000, 2) if fields[37] else 0,
|
||||
"timestamp": fields[30][:8] + " " + fields[30][8:] if fields[30] else "",
|
||||
"source": "tencent", "fetched_at": FETCHED_AT,
|
||||
}
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_eastmoney(raw: str, symbol: str) -> Optional[dict]:
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
d = obj.get("data", {}) or {}
|
||||
if not d.get("f43"):
|
||||
return None
|
||||
return {
|
||||
"symbol": symbol, "name": d.get("f58", ""),
|
||||
"current": round(d["f43"] / 100, 2),
|
||||
"prev_close": round(d["f60"] / 100, 2),
|
||||
"open": round(d["f46"] / 100, 2),
|
||||
"high": round(d["f44"] / 100, 2),
|
||||
"low": round(d["f45"] / 100, 2),
|
||||
"volume": d.get("f47", 0),
|
||||
"amount": round(d.get("f48", 0) / 1e8, 2),
|
||||
"timestamp": "",
|
||||
"source": "eastmoney", "fetched_at": FETCHED_AT,
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_prefix(code: str) -> str:
|
||||
code = re.sub(r"[^0-9]", "", code)
|
||||
if code.startswith(("60", "68", "51", "58", "11")):
|
||||
return "sh", code
|
||||
return "sz", code
|
||||
|
||||
|
||||
def _em_secid(code: str) -> str:
|
||||
code = re.sub(r"[^0-9]", "", code)
|
||||
m = 1 if code.startswith(("60", "68")) else 0
|
||||
return f"{m}.{code}"
|
||||
|
||||
|
||||
def get_realtime_quote(code: str) -> dict:
|
||||
"""获取实时行情,三源降级:新浪→东财→腾讯"""
|
||||
prefix, clean = _get_prefix(code)
|
||||
symbol = f"{prefix}{clean}"
|
||||
|
||||
# 1. 新浪
|
||||
raw = _fetch_url(f"http://hq.sinajs.cn/list={symbol}", HEADERS_SINA)
|
||||
if raw:
|
||||
data = _parse_sina(raw, symbol)
|
||||
if data and data["current"] > 0:
|
||||
logger.info(f"新浪成功: {symbol} = {data['current']}")
|
||||
return data
|
||||
|
||||
# 2. 东财
|
||||
secid = _em_secid(code)
|
||||
raw = _fetch_url(
|
||||
f"http://push2.eastmoney.com/api/qt/stock/get?secid={secid}"
|
||||
f"&fields=f43,f44,f45,f46,f47,f48,f57,f58,f60,f169,f170",
|
||||
HEADERS_EM)
|
||||
if raw:
|
||||
data = _parse_eastmoney(raw, symbol)
|
||||
if data and data["current"] > 0:
|
||||
logger.info(f"东财成功: {symbol} = {data['current']}")
|
||||
return data
|
||||
|
||||
# 3. 腾讯
|
||||
raw = _fetch_url(f"http://qt.gtimg.cn/q={symbol}")
|
||||
if raw:
|
||||
data = _parse_tencent(raw, symbol)
|
||||
if data and data["current"] > 0:
|
||||
logger.info(f"腾讯成功: {symbol} = {data['current']}")
|
||||
return data
|
||||
|
||||
return {"error": f"所有数据源均无法获取 {code}", "symbol": symbol, "fetched_at": FETCHED_AT}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
code = sys.argv[1] if len(sys.argv) > 1 else "600519"
|
||||
result = get_realtime_quote(code)
|
||||
for k, v in result.items():
|
||||
print(f" {k}: {v}")
|
||||
@@ -0,0 +1,68 @@
|
||||
"""5yr 15min 回填冷却后自动续跑 watcher。
|
||||
|
||||
baostock 被限流/冷却时登录会卡死或失败。本 watcher 每 30min 探测一次 baostock,
|
||||
可登就启动 backfill_15min_baostock(marker-based resume 自动续跑缺口),断路器触发
|
||||
或登录失败就继续等。backfill 成功(exit 0,全部缺口填完)则退出。
|
||||
|
||||
detached 启动:
|
||||
nohup ./venv311/bin/python3 scripts/data_platform/resume_5yr_watcher.py \
|
||||
> /Users/chufeng/data_cache/stock/logs/daily_update/resume_watcher.log 2>&1 &!
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
ROOT = "/Users/chufeng/.openclaw/sanguo_projects/sanguo_vnpy_v2"
|
||||
PY = os.path.join(ROOT, "venv311/bin/python3")
|
||||
SCRIPT = os.path.join(ROOT, "scripts/data_platform/backfill_15min_baostock.py")
|
||||
PROBE_INTERVAL = 1800 # 30min
|
||||
|
||||
# 探测脚本:SIGALRM 给 bs.login() 套 15s 闹钟,避免卡死
|
||||
_PROBE = r'''
|
||||
import signal
|
||||
def _h(*a): raise TimeoutError()
|
||||
signal.signal(signal.SIGALRM, _h)
|
||||
signal.alarm(15)
|
||||
try:
|
||||
import baostock as bs
|
||||
lg = bs.login()
|
||||
ok = lg.error_code == "0"
|
||||
try: bs.logout()
|
||||
except Exception: pass
|
||||
print("LOGIN_OK" if ok else "LOGIN_FAIL")
|
||||
except Exception as e:
|
||||
print("LOGIN_ERR", type(e).__name__, e)
|
||||
finally:
|
||||
signal.alarm(0)
|
||||
'''
|
||||
|
||||
|
||||
def baostock_up() -> bool:
|
||||
try:
|
||||
r = subprocess.run([PY, "-c", _PROBE], capture_output=True, text=True, timeout=30)
|
||||
return "LOGIN_OK" in r.stdout
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
round_ = 0
|
||||
while True:
|
||||
round_ += 1
|
||||
if baostock_up():
|
||||
print(f"[round {round_}] baostock 可登,启动回填(marker-resume 续跑缺口)", flush=True)
|
||||
env = dict(os.environ)
|
||||
env["STOCK_ROOT"] = "/Users/chufeng/data_cache/stock"
|
||||
env["BS_START_DATE"] = "20210101"
|
||||
rc = subprocess.call([PY, SCRIPT], env=env, cwd=ROOT)
|
||||
if rc == 0:
|
||||
print(f"[round {round_}] 回填完成(exit 0),退出 watcher", flush=True)
|
||||
return
|
||||
print(f"[round {round_}] 回填 exit={rc}(断路器/失败),等 {PROBE_INTERVAL}s 再试", flush=True)
|
||||
else:
|
||||
print(f"[round {round_}] baostock 不可登(冷却中),等 {PROBE_INTERVAL}s", flush=True)
|
||||
time.sleep(PROBE_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""monkey-patch engine 关键方法加诊断, 跑回测看 ETF cancel 根因(不改源码)。"""
|
||||
import sys, os, logging
|
||||
os.environ.setdefault("DEFAULT_DATA_PROVIDER", "jqdata")
|
||||
from unittest.mock import MagicMock
|
||||
m = MagicMock(); m.utils.assert_auth = lambda f: f
|
||||
sys.modules.setdefault("jqdatasdk", m)
|
||||
logging.basicConfig(level=logging.WARNING, format="%(message)s")
|
||||
|
||||
from bullet_trade.core import engine as eng
|
||||
|
||||
_orig_calc = eng.BacktestEngine._calculate_order_amount
|
||||
def calc(self, order, cp):
|
||||
r = _orig_calc(self, order, cp)
|
||||
print(f"[ENG_DIAG] {order.security} cp={cp} amount={r} tgt_val={getattr(order,'_target_value',None)} is_tgt={getattr(order,'_is_target_value',None)} order_amt={getattr(order,'amount',None)}", flush=True)
|
||||
return r
|
||||
eng.BacktestEngine._calculate_order_amount = calc
|
||||
|
||||
_orig_bp = eng.BacktestEngine._resolve_base_exec_price
|
||||
def bp(self, security, current_dt, fq_mode):
|
||||
r = _orig_bp(self, security, current_dt, fq_mode)
|
||||
print(f"[ENG_DIAG_BP] {security} dt={current_dt} fq={fq_mode} -> {r}", flush=True)
|
||||
return r
|
||||
eng.BacktestEngine._resolve_base_exec_price = bp
|
||||
|
||||
sys.argv = ['runner', '--provider', 'unified', '--max-pool', '30', '--start', '2024-01-02', '--end', '2024-01-31', '--cash', '1000000']
|
||||
from sanguo_portfolio.runner_backtest import main
|
||||
main()
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""小样本验证脚本: 测试日K线和成分股下载 (各 10 只/1 指数)
|
||||
|
||||
验证项:
|
||||
1. 日K线 10 只: parquet 生成 + 字段齐全 + 行数合理 + 日期范围对
|
||||
2. 成分股 1 指数 1 年: parquet 生成 + 成分股数量合理
|
||||
3. query 计数器工作
|
||||
|
||||
用法:
|
||||
python test_baostock_daily_constituent_sample.py
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 颜色输出
|
||||
GREEN = "\033[92m"
|
||||
RED = "\033[91m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
def run_test(name: str, cmd: list, expected_checks: dict):
|
||||
"""运行测试脚本并验证结果"""
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"测试: {name}")
|
||||
print(f"命令: {' '.join(cmd)}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode not in [0, 2]: # 0=完成, 2=断路器(可接受)
|
||||
print(f"{RED}❌ 失败: returncode={result.returncode}{RESET}")
|
||||
print("STDOUT:", result.stdout[-500:] if len(result.stdout) > 500 else result.stdout)
|
||||
print("STDERR:", result.stderr[-500:] if len(result.stderr) > 500 else result.stderr)
|
||||
return False
|
||||
|
||||
print(f"{GREEN}✅ 完成: returncode={result.returncode}{RESET}")
|
||||
|
||||
# 检查输出
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
for check_key, check_pattern in expected_checks.items():
|
||||
if check_pattern in output:
|
||||
print(f"{GREEN}✅ {check_key}: 找到'{check_pattern}'{RESET}")
|
||||
else:
|
||||
print(f"{RED}❌ {check_key}: 未找到'{check_pattern}'{RESET}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def check_parquet_files(out_dir: Path, pattern: str, min_count: int):
|
||||
"""检查 parquet 文件生成"""
|
||||
print(f"\n检查 parquet 文件: {out_dir}/{pattern}")
|
||||
parquet_files = list(out_dir.rglob(pattern))
|
||||
|
||||
if len(parquet_files) >= min_count:
|
||||
print(f"{GREEN}✅ 找到 {len(parquet_files)} 个 parquet 文件 (≥{min_count}){RESET}")
|
||||
# 显示前几个文件
|
||||
for f in parquet_files[:3]:
|
||||
print(f" - {f.name}")
|
||||
return True
|
||||
else:
|
||||
print(f"{RED}❌ 只找到 {len(parquet_files)} 个 parquet 文件 (<{min_count}){RESET}")
|
||||
return False
|
||||
|
||||
|
||||
def check_parquet_fields(file_path: Path, required_fields: list):
|
||||
"""检查 parquet 字段齐全"""
|
||||
import pandas as pd
|
||||
|
||||
print(f"\n检查字段: {file_path}")
|
||||
try:
|
||||
df = pd.read_parquet(file_path)
|
||||
missing_fields = [f for f in required_fields if f not in df.columns]
|
||||
|
||||
if missing_fields:
|
||||
print(f"{RED}❌ 缺失字段: {missing_fields}{RESET}")
|
||||
print(f"实际字段: {list(df.columns)}")
|
||||
return False
|
||||
else:
|
||||
print(f"{GREEN}✅ 字段齐全: {len(required_fields)} 个必选字段都在{RESET}")
|
||||
print(f"字段列表: {list(df.columns)}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"{RED}❌ 读 parquet 失败: {e}{RESET}")
|
||||
return False
|
||||
|
||||
|
||||
def check_parquet_rowcount(file_path: Path, min_rows: int, max_rows: int):
|
||||
"""检查 parquet 行数合理"""
|
||||
import pandas as pd
|
||||
|
||||
print(f"\n检查行数: {file_path}")
|
||||
try:
|
||||
df = pd.read_parquet(file_path)
|
||||
row_count = len(df)
|
||||
|
||||
if min_rows <= row_count <= max_rows:
|
||||
print(f"{GREEN}✅ 行数合理: {row_count} 行 (期望 {min_rows}-{max_rows}){RESET}")
|
||||
return True
|
||||
else:
|
||||
print(f"{RED}❌ 行数异常: {row_count} 行 (期望 {min_rows}-{max_rows}){RESET}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"{RED}❌ 读 parquet 失败: {e}{RESET}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""主测试流程"""
|
||||
print(f"\n{'=' * 60}")
|
||||
print("BaoStock 脚本小样本验证")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
tests_passed = 0
|
||||
tests_failed = 0
|
||||
|
||||
# ======================= 测试 1: 日K线 10 只 =======================
|
||||
print(f"\n{'#' * 60}")
|
||||
print("# 测试 1: 日K线下载 (10 只股票)")
|
||||
print(f"{'#' * 60}")
|
||||
|
||||
daily_cmd = [
|
||||
sys.executable,
|
||||
"scripts/data_platform/baostock_daily_fullmarket_download.py",
|
||||
"--limit", "10",
|
||||
"--start", "2020-01-01",
|
||||
"--end", "2020-12-31"
|
||||
]
|
||||
|
||||
daily_checks = {
|
||||
"登录成功": "baostock 登录成功",
|
||||
"全市场A股": "全市场 A 股",
|
||||
"处理10只": "limit=10 截断",
|
||||
"query计数": "query 总计:",
|
||||
}
|
||||
|
||||
if run_test("日K线下载", daily_cmd, daily_checks):
|
||||
tests_passed += 1
|
||||
|
||||
# 检查 parquet 文件
|
||||
daily_out_dir = Path("data/daily_baostock")
|
||||
if check_parquet_files(daily_out_dir, "*.parquet", 10):
|
||||
tests_passed += 1
|
||||
|
||||
# 检查第一个文件的字段和行数
|
||||
first_parquet = list(daily_out_dir.glob("*.parquet"))[0]
|
||||
daily_fields = "date,code,open,high,low,close,preclose,volume,amount,adjustflag,turn,tradestatus,pctChg,peTTM,psTTM,pcfNcfTTM,pbMRQ,isST".split(",")
|
||||
|
||||
if check_parquet_fields(first_parquet, daily_fields):
|
||||
tests_passed += 1
|
||||
|
||||
# 2020-01-01~2020-12-31 约 244 个交易日
|
||||
if check_parquet_rowcount(first_parquet, 200, 300):
|
||||
tests_passed += 1
|
||||
else:
|
||||
tests_failed += 1
|
||||
else:
|
||||
tests_failed += 1
|
||||
else:
|
||||
tests_failed += 1
|
||||
else:
|
||||
tests_failed += 1
|
||||
|
||||
# ======================= 测试 2: 成分股 1 指数 1 年 =======================
|
||||
print(f"\n{'#' * 60}")
|
||||
print("# 测试 2: 成分股下载 (1 指数 1 年)")
|
||||
print(f"{'#' * 60}")
|
||||
|
||||
constituent_cmd = [
|
||||
sys.executable,
|
||||
"scripts/data_platform/baostock_constituent_download.py",
|
||||
"--indices", "hs300",
|
||||
"--start", "2020-01-01",
|
||||
"--end", "2020-12-31"
|
||||
]
|
||||
|
||||
constituent_checks = {
|
||||
"登录成功": "baostock 登录成功",
|
||||
"快照日期": "快照日期列表:",
|
||||
"每周一": "每周一快照",
|
||||
}
|
||||
|
||||
if run_test("成分股下载", constituent_cmd, constituent_checks):
|
||||
tests_passed += 1
|
||||
|
||||
# 检查 parquet 文件
|
||||
constituent_out_dir = Path("data/constituent_baostock")
|
||||
if check_parquet_files(constituent_out_dir, "hs300_*.parquet", 50): # 2020年约52个周一
|
||||
tests_passed += 1
|
||||
else:
|
||||
tests_failed += 1
|
||||
else:
|
||||
tests_failed += 1
|
||||
|
||||
# ======================= 测试结果汇总 =======================
|
||||
print(f"\n{'=' * 60}")
|
||||
print("测试结果汇总")
|
||||
print(f"{'=' * 60}")
|
||||
print(f"通过: {tests_passed}")
|
||||
print(f"失败: {tests_failed}")
|
||||
|
||||
if tests_failed == 0:
|
||||
print(f"\n{GREEN}✅ 所有测试通过!{RESET}\n")
|
||||
return 0
|
||||
else:
|
||||
print(f"\n{RED}❌ 有 {tests_failed} 个测试失败{RESET}\n")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""mootdx 分钟K线历史深度测试 —— 周一开盘后跑(非交易时段通达信全服务器返空)
|
||||
|
||||
目的:对比 miniQMT 模拟端 1m/5m/15m 统一只给 ~12 个月(2025-07-16 起),
|
||||
看通达信公共行情服务器能给多深的分钟历史。
|
||||
|
||||
用法:
|
||||
venv311/bin/python scripts/data_platform/test_mootdx_depth.py
|
||||
结果写 scripts/data_platform/_mootdx_depth_result.txt 并打印。
|
||||
|
||||
注意:
|
||||
- 频率值表(mootdx 0.11.7 实测): 0=5m 1=15m 2=30m 3=60m 4=日 8=1分钟 9=日线
|
||||
- bars 返回【不复权】原始价;offset 硬上限 800,更深历史靠 start 分页
|
||||
- 非交易时段(收盘后/周末)通达信服务器 quotes+bars 全频率返空,连日线都不给
|
||||
"""
|
||||
import socket
|
||||
import sys
|
||||
import datetime
|
||||
from mootdx.quotes import Quotes
|
||||
|
||||
_TDX_SERVERS = [
|
||||
('119.97.185.59', 7709), ('124.70.133.119', 7709), ('116.205.183.150', 7709),
|
||||
('123.60.73.44', 7709), ('116.205.163.254', 7709), ('121.36.225.169', 7709),
|
||||
('123.60.70.228', 7709), ('124.71.9.153', 7709), ('110.41.147.114', 7709),
|
||||
('124.71.187.122', 7709),
|
||||
]
|
||||
|
||||
|
||||
def _probe(ip, port, timeout=2.0):
|
||||
try:
|
||||
with socket.create_connection((ip, port), timeout=timeout):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _n(x):
|
||||
"""统一求长度, 规避空 DataFrame 的 bool 歧义坑"""
|
||||
if x is None:
|
||||
return 0
|
||||
try:
|
||||
return len(x)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def find_server(symbol='600519'):
|
||||
"""遍历服务器, 返回第一个能返回日线 bars 的(交易日内才有)"""
|
||||
for ip, port in _TDX_SERVERS:
|
||||
if not _probe(ip, port):
|
||||
continue
|
||||
try:
|
||||
c = Quotes.factory(market='std', server=(ip, port))
|
||||
d = c.bars(symbol=symbol, frequency=9, offset=5)
|
||||
if _n(d) > 0:
|
||||
return ip, c
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
def test_depth(c, symbol, freq, name, max_pages=200):
|
||||
"""start 分页翻到底, 找最早/最新 datetime. 200页: 1m≈20月/5m≈8年/15m≈24年"""
|
||||
start = 0
|
||||
total = 0
|
||||
pages = 0
|
||||
earliest = None
|
||||
latest = None
|
||||
while pages < max_pages:
|
||||
try:
|
||||
df = c.bars(symbol=symbol, frequency=freq, offset=800, start=start)
|
||||
except Exception:
|
||||
break
|
||||
n = _n(df)
|
||||
if n == 0:
|
||||
break
|
||||
total += n
|
||||
try:
|
||||
ft = str(df.iloc[0]['datetime'])
|
||||
lt = str(df.iloc[-1]['datetime'])
|
||||
if earliest is None or ft < earliest:
|
||||
earliest = ft
|
||||
if latest is None or lt > latest:
|
||||
latest = lt
|
||||
except Exception:
|
||||
pass
|
||||
if n < 800:
|
||||
break
|
||||
start += n
|
||||
pages += 1
|
||||
return name, total, earliest, latest, pages
|
||||
|
||||
|
||||
def main():
|
||||
out = ['mootdx 深度测试 @ %s' % datetime.datetime.now()]
|
||||
ip, c = find_server()
|
||||
if c is None:
|
||||
out.append('!!! 没有服务器返回日线数据 —— 非交易时段(周末/收盘后)通达信全服务器返空')
|
||||
out.append('!!! 请周一 09:30 开盘后重跑此脚本')
|
||||
msg = '\n'.join(out)
|
||||
print(msg)
|
||||
with open('_mootdx_depth_result.txt', 'w') as f:
|
||||
f.write(msg)
|
||||
sys.exit(1)
|
||||
|
||||
out.append('server: %s' % ip)
|
||||
out.append('')
|
||||
for sym in ['600519']: # 茅台(2001上市, 老股, 测深度上限最佳)
|
||||
out.append('=== %s (茅台) ===' % sym)
|
||||
for fr, nm in [(8, '1分钟'), (0, '5分钟'), (1, '15分钟')]:
|
||||
name, total, earliest, latest, pages = test_depth(c, sym, fr, nm)
|
||||
out.append(' %-6s: %7d 根 | 最早=%s | 最新=%s | 翻%d页'
|
||||
% (name, total, earliest, latest, pages))
|
||||
out.append('')
|
||||
out.append('对比: miniQMT 模拟端 1m/5m/15m 统一 ~12 个月(2025-07-16 起)')
|
||||
msg = '\n'.join(out)
|
||||
print(msg)
|
||||
with open('_mootdx_depth_result.txt', 'w') as f:
|
||||
f.write(msg)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user