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:
2026-07-29 10:11:38 +08:00
parent 1cc9126abb
commit c3e53fbef3
39 changed files with 436 additions and 0 deletions
+23
View File
@@ -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_baostockmarker-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()
@@ -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()