# A-data-sync **Repository Path**: mkee/A-data-sync ## Basic Information - **Project Name**: A-data-sync - **Description**: 青青草原数据实时同步 - **Primary Language**: Python - **License**: MulanPSL-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-28 - **Last Updated**: 2026-10-31 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # A 股市场数据同步 本地通达信行情文件 + 四个公开 HTTP 接口(腾讯 / 新浪 / 同花顺 / 东财·天天基金)落到一个 DuckDB, 供量化研究直接 SQL 查询;要喂给外部引擎时再用 `run.py export` 把主表投影成按年切分的 Parquet(§8,只读出口,不是第二份数据)。原始报文全部归档,字段口径可在 `tests/selftest.py` 里离线复现。 设计取舍:**能离线拿到的绝不联网,联网拿到的必须留原文**。 > 要**每天怎么跑、历史怎么批量补**,看 [同步手册.md](同步手册.md)(操作视角,含命令表与实测耗时); > 本文讲设计、口径与踩过的坑。 --- ## 1. 四条通道与各自职责 | 通道 | 取什么 | 为什么是它 | | --- | --- | --- | | 通达信本地文件 `D:\new_tdx_mock` | 日线全历史(沪深北 + ETF)、行业归属、题材、主营、板块 | 客户端已经下载好的 `.day` 是原始未复权价,速度最快、无接口风险;`T0002/hq_cache` 里有在线接口给不了的通达信题材表 | | 腾讯 `qt.gtimg.cn` | 实时快照(主)、市盈率/市净率/市值/换手/量比等估值截面 | 一次请求 60 只、88 字段、含股本,可用「股本 × 价 ÷ 1e8 == 总市值(亿)」自检 | | 新浪 `money.finance.sina.com.cn` | 三大报表、分红送配、F10、实时快照(备份) | 见 §4:本机通达信的专业财务与 F10 缓存目录是空的,**新浪在这里是主路径而不是备份** | | 同花顺 | 人气热榜(每股题材标签)、涨停池、概念板块 | 热榜是当日强势股 + 题材归因的唯一在线来源;涨停池只覆盖沪深主板,边界见 §6 | | 东财(kline + 天天基金 pingzhongdata) | 场内基金历史单位净值 + 日增长率(`fund_nav_daily`,2,229 只、2003–今);通达信本地没有的指数日线补录(`HTTP_INDEX_CODES`,当前 sh932000 中证2000) | 通达信 `.day` 只有价格没有净值,而 ETF 的分红/拆分重建(回测项目 `core/parquet_center.py`)必须有净值与日增长率才能做;指数补录用东财 kline(`secid=2.`,全历史自带成交额,20261028 实测 `v_pv_none_match` 是正常返回);东财 kline 路径整体拒连时自动改走中证指数官网(`sources/csindex.py`,`source='csindex_kline'`,量÷100=手、亿元×1e8=元,已逐字段对齐) | 数据通道分工的前提是 §4 的本机文件盘点。换一台机器、或者通达信客户端补齐了下载, 请先重跑 `python run.py inventory` 再决定要不要改用本地财务文件。 ## 2. 目录结构与安装 ``` config.py 路径、单位口径、HTTP 限速与批次大小 run.py CLI 编排 + 单实例锁 + 退出码 core/ db.py DuckDB 建表/迁移、按主键幂等 upsert、水位与运行日志 http.py 按域限速、只重试 429/5xx/超时、原始报文归档、fixture 离线命中 symbol.py 代码规范:全项目只用 (market, code6) export.py DuckDB -> Parquet 只读投影(按年切文件) log.py 控制台 + logs/sync.log(utf-8) tdx/ dayfile.py vipdoc//lday 的 32 字节记录解析 catalog.py T0002 目录:行业/题材/主营/板块/文件盘点 sources/ tencent.py 88 字段报文 -> 快照 + 估值 sina.py 三大报表 / 分红送配 / F10 / 快照备份 ths.py 热榜 / 涨停池 / 概念板块 eastmoney.py 东财 kline(指数补录)+ 天天基金 pingzhongdata(基金净值) sync/ quotes.py 日线入库、实时、估值、快照补棒、头部缺口检查、指数联网补录 fundamentals.py 报表、分红、F10、前复权因子 hotspot.py 热榜、涨停池、概念、基础信息、板块 fundnav.py 基金历史净值入库(水位幂等,fund_type 来自基金目录一次全量) tests/ selftest.py 22 项离线回归(fixtures = 真实报文快照) scripts/schedule.bat Windows 计划任务 install/status/remove scripts/validate_vs_xbx.py 抽样与第三方 xbx 源逐字段交叉校验(只读主库,用 .day 字节做仲裁) scripts/install_tasks.py 计划任务的真正实现(生成 Task Scheduler XML 再 schtasks /Create /XML) scripts/clean_orphan_bars.py 孤儿棒清理(默认 dry-run,--apply 才删;不在夜跑链里) 同步手册.md 操作视角:同步了哪些数据 / 从哪来 / 存哪里 / 每日怎么跑 / 批量怎么跑 data/ ashare.duckdb + raw/// 原始报文 parquet//.parquet 只读投影,可整目录删除后重导 ``` ```bash pip install -r requirements.txt # duckdb / pandas / numpy / requests / bs4 / lxml python run.py inventory # 先确认本机通达信目录里到底有什么 python run.py selftest # 离线自测(不联网,23 项) python run.py daily --full # 日线全历史入库(第一次必须 --full) python run.py all_quotes # 盘后行情:日线 -> 快照 -> 估值 -> 快照补棒 python run.py finance # 盘后财务:报表 -> 分红 -> F10 -> 复权因子 -> 基础信息 python run.py hot # 盘面热点:热榜(小时/日) + 涨停池 + 概念目录 python run.py export # DuckDB -> data/parquet/(只读投影,默认主表) python run.py backfill_xbx # 退市股日线补录:xbx CSV 整只导入 stock_daily_xbx python run.py status # 各表行数与水位 ``` 环境变量:`ASHARE_TDX_PATH`(通达信目录)、`ASHARE_DB_PATH`、`ASHARE_DATA_DIR`、 `ASHARE_FIXTURE_DIR`(离线自测报文目录)、`ASHARE_PARQUET_DIR`(投影输出目录)、 `ASHARE_ARCHIVE=0`(关掉原始报文归档)。 退出码:`0` 全部成功;`1` 有子任务或网络请求失败(报告里 `failures` / `http.failed`); `2` 环境不可用(通达信目录缺失、库文件被别的进程占用)。 ## 3. 统一口径(写库前换算完毕,读的时候不用再乘) | 量 | 单位 | 换算来源 | | --- | --- | --- | | 价格 | 元 | `.day` 里的整数价 ÷100(股票/指数)或 ÷1000(基金/ETF,`tdx/dayfile.is_fund`) | | 成交额 | 元 | `.day` 第 5 个字段是 `//`,保留 30 天(`run.py prune`)。 ### 5.1 逐日名称 / 逐日股本两个视图 事件表存的是「变动」,日线表存的是「每棒」,两者用 ASOF JOIN 拼成逐日: ```sql SELECT * FROM v_name_daily WHERE code='600638' ORDER BY trade_date; -- name = 当天在用的简称 SELECT trade_date, close, total_share, float_market_cap FROM v_share_daily WHERE code='600000'; ``` 口径:`v_name_daily` 取 `start_date <= trade_date` 的最大一段(区间链首尾相接,所以只看 `start_date` 就够);`v_share_daily` 取 `change_date <= trade_date` 的最后一期,并顺手用当日 收盘价乘出总市值/流通市值。两张视图都是 `ASOF LEFT JOIN`,没有事件棒的行不会被吃掉。 `v_share_daily` 里的事件值先做 `LAST_VALUE(... IGNORE NULLS)` 前向填充:新浪某一期把流通A股 倒算成负数时解析层清成 NULL(见 §5.2),如果不填充,ASOF 会把「这期没披露」读成 「这期之后流通盘是空的」—— 那不是缺数据,是假数据。 代价实测(20261026,24,114,652 行日线):建视图 4.9s、名称视图全扫 3.1s、股本视图全扫 2.9s、 单只票 0.01s。所以**不落地成表**:落地要再复制一份 2,400 万行,而回测按票/按区间取数时视图已经够快。 名称覆盖 23,992,850/24,114,652 = **99.49%** 的棒(剩下的是 `name_event` 里根本没有的品种); 股本覆盖 16,403,098/24,114,652 = **68.02%**,缺口几乎全是基金/指数/无股本事件的品种, 不是股票漏抓(`share_event` 覆盖 5,594 只股票 + 一批现值锚,见 §5.2)。 ### 5.2 这两条链是怎么拼出来的(以及缺口在哪) **逐日名称 —— 完全离线,三个本地文件接力:** | 段 | 源 | 说明 | | --- | --- | --- | | 中间各段 | `T0002/hq_cache/profile.dat` | 64 字节定长,4,918 条 / 1,650 只,只记**已停用**的简称 | | 尾段(在用名) | `T0002/hq_cache/{sh,sz,bj}s.tnf` | 品种表 360 字节定长记录的 `[31:]`,52,544 只全有名 | | 头段起点 | `T0002/hq_cache/base.dbf` 的 `SSDATE` | 上市日;没有上市日就写 `'00000000'` | `profile.dat` 的日期字段读作「该简称被替换掉的那一天」,也就是**下一段名称启用的那天**: 用 xbx 逐日名称标定(20261026,抽样 300 只 872 条记录),791 条(**90.7%**)等于下一个简称在 xbx 里的首个交易日,等于旧名最后一日的是 **0 条**。所以链写成 `start_i = end_(i-1)`, 而不是闭区间 —— 差一个方向就把每段名称整体推后一根棒。简称字段只有 8 字节 = 4 个汉字, 长名在源文件里就被截掉;通达信还给 2/3 字名称塞了补齐空格(`'*ST江 纸'`,实测 194 条), 解析时一并删掉,否则下游按名称 join 对不上。 实测一致率(20261026,`validate_vs_xbx.py --names 400`,seed 20261027): | 档 | 抽样 | 棒数 | 行级一致 | 切换点一致 | 不一致的构成 | | --- | --- | --- | --- | --- | --- | | profile | 400 只(只抽「`profile.dat` 里有旧名」的票) | 2,058,188 | **99.28%** | 5,910/6,078 = **97.24%** | 14,771 根:本库 NULL 40、xbx 临时前缀 2,984、xbx 首行缩写 47、真差异 11,700 | | universe | 400 只(从全库有日线的票里抽) | 1,217,899 | **94.53%** | 4,325/4,538 = **95.31%** | 65,608 根:本库 NULL 56,225(284 只在 `profile.dat` 里没有)、xbx 临时前缀 926、xbx 首行 14、真差异 9,443 | 归一化 = 剥掉 N/C/XD/XR/DR/G 临时前缀(xbx 会把当日临时前缀写进历史,需反复剥)、全角字母折半角、 互为子串算一致。universe 档那 213 个 miss 的切换点按原因拆开是:168 个「`profile.dat` 里根本没这条 代码」(退市/无历史,和本机 `.day` 丢退市股是同一个幸存者偏差)、44 个「有这条代码但漏了这次改名」、 1 个「库里那一段没有任何名字」。剩下的真差异集中在 2005–2006 股权分置期间 xbx 自己用的 3–4 字 简称(`南京高科` 写成 `宁高科`、`广州控股` 写成 `广控`),以及 `profile.dat` 里少数占位日期 (`19970101`、`20061009` 这类非交易日/批量日)。**这两类都修不了**,只能靠 `source` 列知道 自己站在哪一段上。 **逐日股本 —— 新浪事件 + 本地尾锚:** * 事件源 `vCI_StockStructure` 一页给上市至今的全部股本变动(含增发/配股/解禁/回购), 600000 实测 15 张表 74 期(1999→2025)。**总股本已三方对齐**:与腾讯 70/70 逐位相等、 与 `base.dbf` 69/70 相等(`600461` 是 base.dbf 自己滞后 1.97%)。 * **流通A股新浪会滞后解禁**:920225/002735/300244/688981 等期次差到 4 倍(8.0B vs 真 2.0B)。 仲裁用 xbx「流通市值/收盘」反推值,先得确认这个裁判站得住:xbx 的 `float_mv/total_mv` 分位 5/50/95 = **0.25 / 0.85 / 1.00**(31% 的棒流通盘就是总股本);另抽一组独立的 150 只 / 441,959 根棒(与下面 `--shares` 不是同一批抽样),反推值与我们的流通A股完全相等 **69.0%**、 与「总股本−限售A股」相等 52.2%、与总股本相等 31.0% —— 是流通市值,不是总市值。 所以 `apply_share_anchors` 用 `base.dbf` 的 `GXRQ` 现值补一条尾锚, 三道门槛(任一满足,且都要求 GXRQ 严格晚于该股新浪最后一期):(a) 数值真的变了(tol 1e-4, 实测 2,408 只);(b) 该股根本没有新浪事件(2,371 只);(c) 新浪最后一期该项是 NULL 而 `base.dbf` 有值(63 只,专治倒算负数被清空后连尾值都没了)。同日/等值/更早都不插。 * **剩下 33.6% 的逐日流通盘差异是两家的「隐藏限售」归类不同,不是解析 bug**: `--shares 150` 抽 504,202 根棒,exact 289,567 + within 45,155 = **66.4%**;bad 169,480 拆成 边界差(距**值来源日** ≤20 自然日)23,111 / 错位(本库值在 xbx 历史里出现过)1,103 / 持续性 level 差 145,266。level 差再抽样(150 只 / 85,870 根):**37.7% 的行反推值恰好等于 sina 自己的「总股本 − 限售A股」**(11.0% 等于总股本),83.8% 的行是本库 < 反推值。也就是 sina 的「流通A股」列小于用它自己两列倒算出来的流通盘,差额是高管锁定股/回购专户这类 「法律上限售、两家归类不同」的股份;剩下 51.2% 两类都对不上,是两家事件日期/来源不同。 **要交易所口径就用 `total_share - limited_share`**(同一行事件里两列都有),别指望新浪那一列; 本库保留发布列,不做「取较大值」之类没有出处的修补。 * 曾试过、已否证的方案:`base.dbf` 现值 ÷ `dividend_event` 送转回溯。224 个抽样日的中位误差 0.215% 看着能用,但 **34% 超 2%**,长尾是增发/回购/借壳(600483 差 6.413 倍), 而且 `dividend_event.allotment_share` 全为 NULL(配股 5,451 只没建模)。回溯法不准, 所以股本走事件源,不用推导。 * ETF/LOF 的 `vCI_StockStructure` 返回空页,`base.dbf` 也不含基金份额历史 —— 基金逐日份额 只能靠每日 `valuation_daily` 往后攒,见 §12。 ## 6. 已实测的接口边界(不要越界使用) * **同花顺涨停池只覆盖沪深主板。** `filter` 参数实测只接受 `HS`(`NEW` 通过但恒空, `GEM/STAR/BJ/ZB/CY/KC/1/2` 全部返回「filter参数不合法」);`limit` 上限 200,超出走分页。 20261023 返回 64 条,代码首位只有 `0`/`6`。创业板、科创板、北交所的当日强势股请用 `ths_hot_list`(实测含 `30`/`68` 开头)或直接在 `stock_daily` 上判定。 * **同花顺概念目录接口不可依赖(20261026 复测)。** 无 `hexin-v` Cookie 时,翻页返回与第 1 页 逐字节相同的 HTML,或 401/403;当天复测连第 1 页目录(401)和概念成分股 `.../ajax/1/code/`(300387→401、309274/309269→403)都被拒。 所以 `ths_concept` 只有在补上 Cookie 后才可能有数据,`ths_concept_index` 只剩此前抓到的 约 10 条,**不是概念全集**。`concept_list()` 仍按 `concept_code` 去重、发现「本页无新概念」 即停,并把状态写成 `capped`/`blocked`;`sync_concepts` 当天已被判 `blocked` 时直接跳过, `concept --force` 才重新探测,夜跑链 `all` 已移除该步,避免退出码长期红灯盖掉真实故障。 题材归因请用 `ths_hot_list.concept_tags`(每股多题材)与通达信本地 `specgpsxzt.txt` (`catalog.parse_specgpsxzt`,5860 只票有题材)。 * **腾讯三个 PE 口径不做取舍。** f[39] 市盈率(TTM)、f[52] 动态市盈率、f[53] 静态市盈率 算法不同,全部存列,用哪个是策略层的事。 * **同花顺热榜只有「当下」。** `stock_type=a&type=hour|day` 无日期参数,接口不接受回溯, 所以 `ths_hot_list.trade_date` 只是落库时的标记,补不回来历史榜单 —— 想留历史必须每天定时跑。 * 新浪 `go.php` 系列只认 6 位纯代码。传 `sh600519` 会返回另一个页面(不是报错,是 45KB 的无关页面), 曾经因此静默产出 0 行;现在 `core/symbol.pure_code` 统一处理,且 `fetch_*` 不再吞异常。 * **腾讯涨跌停价的 `-1` 是哨兵。** 第 47/48 位对退市整理股、`C` 字头新股、无涨跌幅限制的票返回 `-1`(20261026 全市场 18 只)。`sources/tencent.py::_limit` 把它换成 `NULL` 再入库, 否则「现价必须落在 [跌停, 涨停] 内」这条校验会永久失败;`tests/selftest.py::test_tencent_valuation` 锁住了这个行为。 * **盘中不入库当日未收盘的棒(`sync/common.py::live_bar_date`)。** 通达信客户端在交易时段 就会把当日进行中的 K 线写进 `.day`,这根棒的量额只到当前时刻。真正的坑不是数值偏小, 而是水位:它一旦以 `source='tdx_day'` 入库,`sync_state.tdx_daily` 的水位就钉在当天, 收盘后客户端补写的正式棒会被 `file_last <= db_latest` 判成「已覆盖」而**永久跳过** —— 主表永远留着那半根,而且没有任何报错。所以 `sync_daily` 在收盘(15:05)前丢掉当日棒, 计数写进 `live_bars`;`fill_daily_from_snapshot` 同样拒绝补当日(快照口径也只有半天)。 判重只看 `tdx_day` 行,已补过的 `*_rt` 行会被当日更晚的快照覆盖 —— 否则盘中第一次 `fill` 就会把早盘那一根钉死。`tests/selftest.py::test_live_bar_guard` 锁住这两条路径。 * **通达信板块指数 `sh88xxxx` 的 `.day` 里 `open` 有非价格语义。** 20261026 全量重刷丢弃 331 行 `open < low`(`880006` 147 / `880016` 127 / `880036` 57;沪市 1,121 个 `88` 开头文件里只有这 3 只有非法棒, 深市与北交所 0 行)。 `sync/quotes.py::sync_daily` 在入库前按 `low ≤ open,close ≤ high` 且四项为正的规则丢弃非法棒, 计数写进 `daily` 返回值的 `bad_bars`、`sync_state.tdx_daily/.message` 和 WARNING 日志, 所以这类行不会静默进入 `stock_daily`;重刷(`daily --full`)时同一规则仍然生效。 * **`.day` 的成交量字段是 uint32,装不下的那天通达信把单位退写成「手」。** 上限 4,294,967,295;ETF 单日成交份数超过它时,客户端不写溢出值,而是改按手(÷100)记录。 指纹是同一天 `amount/volume` 算出的均价落在 `[low, high]` 之外且正好高两个数量级 (现场 `sh513330` 全历史 1,381 根里 860 根是中枪的)。这类棒直接把 volume 当股用, 换手率/量能因子会错 100 倍。`tdx/dayfile.fix_hand_volume` 只用文件内部的 OHLC 与 amount 自校验:均价要高于最高价 10 倍以上、且 ÷100 后落回 `[low, high]` 才动手 —— 这样价格标度 搞错(老 LOF 该 ÷100 却按 ÷1000 解,只差 10 倍)不会被顺手「修」成量对。 **只对 `kind ∈ stock/fund` 调用**:指数棒的价格是点位、量本来就是手,同一条规则会误伤 19,850 根指数棒(库内计数实测)。调用点在 `sync/quotes.py::sync_daily` 而非解析器里, 就是因为解析时还不知道品种;还原行数计入 `volume_from_hand`。 `tests/selftest.py::test_dayfile` 用「2 千万手(真 20 亿份)+ 均价只高 10 倍的标度反例 + 正常行」三条记录锁住这个判定,并断言不修改调用方的原表。 * **`.day` 会被客户端重写,库里可能留着源文件已经删掉的行。** 20261026 一天之内就碰到了: 上午 11:30 全量入库后,客户端把一批 `sz16xxxx` 封闭基金的 0 成交占位棒(OHLC 全等、 volume/amount=0)从 `.day` 里删掉了;`daily --full` 只做 upsert,**不会删除源文件里已不存在的行**, 所以这些棒留在库里且 `synced_at` 永远停在旧时间(发现时 1,770 行 / 占 24,116,422 行的 0.007%, 20261026 19:55 已用下面的命令删除,库内回到 **24,114,652 行 —— 正好等于一次 `daily --full` 写入的行数**, 差值归零就是这个缺陷闭环的证据)。 识别方法就是按 `synced_at` 分组:全量重刷后仍然不是本次时间的行,就是源文件已不再产出的孤儿行。 现在增量的返回值与状态里直接给了 `orphan_codes` / `orphan_bars`(`_orphan_scan`:库内行数 > 文件记录数 且**文件还在**的代码),本轮实测 sz 121 只 / 1,770 行,sh、bj 为 0。 本轮实测用 `synced_at` 分组反查出来的 1,770 行,与 `_orphan_scan` 报的 1,770 行**逐位对上**。 删除它们需要一次显式的反连接 `DELETE`,属于破坏性操作,本项目没有把它放进夜跑链,也不自动执行 —— 命令单独放在 `scripts/clean_orphan_bars.py`(默认 dry-run 打印证据,`--apply` 才真删,自己抢 `data/run.lock`),它按「文件里到底有没有这根」做反连接而不是按值猜, 并且带 `AND source='tdx_day'`(`fill` 补的 `*_rt` 棒不在射程内)。 尤其不能对「文件已经不存在的代码」做,那些是退市股,库里的行是唯一副本 —— 这条边界由 `tests/selftest.py::test_orphan_clean` 锁住。 * **客户端一天写两次文件,而且第二次是改写同一根棒 —— 所以增量必须复查尾棒内容。** `sync_log` 取证 (20261026):13:19 全量重刷时沪市 `.day` 里还没有当日棒(`live_bars=0`)、深市已有 57 根只到午间的 半根(`live_bars=57`),当日正式棒是 13:19–17:33 之间才到位的,17:33 那次增量一次写了 9,524 行当日棒。 原来的「整个文件跳过」只比日期(`file_last <= db_latest`),一旦某次运行恰好把半根/早版本当成正式棒收了, 水位钉在当天,之后客户端补写的正确版本永远进不了库,而且没有计数会动。 现在增量多跑一步 `sync/quotes.py::_reconcile_tail`:读每个文件最后 `TAIL_BARS=2` 根 (`dayfile.parse_tail`,seek 只读 64 字节),与库里同日行**逐字段**比,不一致就改写、库里缺就补写, 计数进 `daily` 返回值的 `tail.rewritten` / `tail.missing` 与 `sync_state...message` 的 `tail_fixed=…+…`,并发 WARNING。代价实测:全跳过的一次增量从 1.0s 涨到 37.9–41.8s (9,649 次小读 + 3 次批量查询),相对 `realtime` 的 26s 可以接受;嫌贵可以把 `TAIL_BARS` 调成 1,工作量减半。 对账**不越界**:只查 `trade_date <= 该股水位` 的行,水位之后的新日期仍由主循环整文件解析; 盘中(`live` 非空)当日半根照旧不进对账。`tests/selftest.py::test_tail_reconcile` 锁住这五条行为, 端到端也验过:人为把 sh600000 当日棒 volume 改成 3 倍后跑增量,日志报 `sh 尾棒对账改写 1 根 … 样例 sh600000@20261026`,库里回到 79,376,048(=文件值)。 ## 7. 前复权因子口径 ``` r = (除权前收盘价 - 每股现金红利) / (除权前收盘价 × (1 + 每股送转)) adj_factor[t] = Π r(除权日 > t) 最新一日 = 1.0 ``` * 新浪的派息/送股/转增都是「每 10 股」,函数内 ÷10 换成每股。 * 除权日可能不是本地交易日(周末或本地板缺失),按 `>` 下界归入其后第一个交易日生效。 * 除权日晚于行情水位的「未来事件」不计入因子,只在 `sync_state.adj_factor.compute.message` 里计数提醒。 * `r < 0.05` 视为脏数据丢弃该事件(`skipped_events`),避免一次错误分红毁掉整条因子链。 * **全市场跑之前有分红覆盖率闸门。** 缺分红的票会被算成 `adj_factor=1.0`,而 1.0 在下游读起来 等于「这只票从不分红」——是静默的错数据。所以不带 `--codes` 的整体重算时,若 `dividend_event` 覆盖的股票数 / 股票池 < 50%,函数直接拒绝写入,状态记 `blocked`、 退出码 `1`,提示先跑 `python run.py dividend`;确认接受部分覆盖再加 `--force`。 覆盖率同时写进 `sync_state.adj_factor.compute.message`(成功时也是)。 * **配股未建模。** 需要配股价与配股比例才能算入除权参考价,新浪 `sharebonus_2` 未证实可靠; 含配股的股票数量记在同一 message 里,用到这类票时先手工核对。 > 这条曾经是真 bug:旧实现的回退循环把「该日之前」的**全部**历史事件都乘了进去, > 浦发银行(600000) 20261022 的因子算成 0.064(正确值 1.0,因为该日之后已无除权事件)。 > `tests/selftest.py::test_adj_factor` 现在用 5 个交易日 + 1 次除权把期望值写死, > 并覆盖「除权日落在非交易日」与「未来除权日」两种情况。 > 校验方式(可自己复算):600000 除权日 20260716,前收 9.31,每股派 0.42 > → r = (9.31-0.42)/9.31 = 0.954887,与库内 `adj_factor` 在 20260715 及以前的值一致。 ## 8. Parquet 只读投影 ```bash python run.py export # 默认 stock_daily + fund_nav_daily + valuation_daily python run.py export --tables stock_daily --start 20260101 --end 20261231 ``` **DuckDB 是唯一真库,`data/parquet/` 只是给回测/分析进程读的只读出口。** Parquet 没有主键、没有 upsert、没有事务,它不可能是数据源:整个目录可以随时删掉, 用 `run.py export` 重新导出来,删了不丢任何数据。所以夜跑链里没有它,也不设定时任务 —— 需要给外部进程喂数据时手工跑一次(20261028 实测 stock_daily 22,693,703 行 / 37 个年度文件 1990-2026 / 759.9 MB,物化 18.9s + 导出 44.2s;物化前显式 `SET memory_limit='3GB'` + `preserve_insertion_order=false` 走磁盘溢写,物理内存小于 DuckDB 默认 80% 配额的机器必须保留); 20261028 起默认导出含 `fund_nav_daily`,约 279 万行 / 2,229 只 / 2003-07-12 起)。 **stock_daily 投影是重建口径,不是主库直拷贝**(20261028 起):kind='stock' 行经 3 步 TEMP 表物化——① 复权因子比重建前收盘价(`昨收×F(t-1)/F(t)`,配股未建模为已知边界)、 ② name_event ASOF 补逐日名称、③ share_event ASOF + LAST_VALUE IGNORE NULLS 重建 流通/总市值(`close×股本`)——再 UNION 退市补录行(`stock_daily_xbx`,由 `run.py backfill_xbx` 从 xbx CSV 整只导入,tdx 客户端只保存在市证券,缺这 325 只会 幸存者偏差)与 kind='index'/'fund' 原始行(投影四列置 NULL)。与 xbx CSV 的全市场 仲裁数字见回测项目 README「数据后端」。 布局是 `data/parquet/
/.parquet`,一年一个不可变文件;重导只重写本次 `--start/--end` 覆盖到的年份文件,范围外的文件一个字节都不动。状态记在 `sync_state.parquet_export/
`(行数、文件数、体积、非 8 位日期的行数)。 为什么值得加这一层(不是性能):实测同一段 2024 年区间计数(两侧都是 1,996,768 行), 查 `stock_daily` 表 19.2ms,查单文件 `read_parquet('.../2024.parquet')` 1.5ms —— 快在少扫了 其它年份的数据,不是格式魔法,量级上换不出东西。 换的是三件事:DuckDB 那个 2.9GB 文件同一时刻只能一个写者(`data/run.lock` 为此而生), Parquet 是普通文件、任意多进程可同时读;pandas / polars / DuckDB / ClickHouse 直读, 对方不用装本项目;按年留版本,不怕下游把主库读锁占住。 等价性(20261026 19:55,**旧口径=主库直拷贝时代**的历史验证,现投影为重建口径不再适用): 投影与主库都是 **24,114,652 行 / 20000104–20261026 / 9,649 只代码**,对 9 个核心列取 `sum(hash(market, code, trade_date, open, high, low, close, volume, amount))` 两侧逐位相等(`222400843742849936527285317`),`typeof(trade_date)` 两侧都是 `VARCHAR`, 投影总体积 536.8MB(stock_daily 27 个年度文件)+ valuation_daily 0.5MB,主库文件 2,965.5MB —— 差的那部分是索引、`sync_*` 表和未导出的其它表,不是投影丢了数据。 三条实测出来的硬约束(改这段代码前请先读): * **不要用 `PARTITION_BY (trade_date)`。** DuckDB 会把分区列**从文件体里剔除**,日期只剩 目录名 `trade_date=20240101/`,`read_parquet` 读回来 `trade_date` 变 BIGINT, 本项目的 `VARCHAR 'YYYYMMDD'` 口径(§3)就断了;pandas 里 `df[df.trade_date == '20240101']` 会静默筛出 0 行。按年切文件能把列原样留在文件里。 * **`COPY ... TO` 的目标路径必须写字面量,不能用 `TO ?`。** 实测与 SELECT 里的 `?` 混用时, DuckDB 把**第一个参数**绑给 TO 路径,报 `Directory "20240101" is not empty`。 代价是路径不能走参数绑定,所以 `core/export.py::_quote_path` 先挡住引号和分号, 表名/日期列也只从 `EXPORT_TABLES` 白名单里取。 * **写文件走 `.parquet.tmp` + `os.replace`,不要用 `OVERWRITE=true`。** 目标目录非空时 DuckDB 直接报错,而 `OVERWRITE` 会清空整棵树 —— 范围外年份的文件会被一起删掉。 原子替换意味着消费方永远只看到完整文件,半截的 `.tmp` 也进不了 `*.parquet` 这个 glob。 长度不是 8 位的日期行不会被静默丢掉:单独导到 `bad.parquet` 并计数(`bad_date_rows`)。 `--tables` 可选的白名单见 `core/export.py::EXPORT_TABLES` (`realtime_snapshot`、`adj_factor`、`financial_item`、`dividend_event`、`ths_hot_list`、 `ths_limit_up` 默认不导,需要时显式点名)。 ## 9. 定时任务 ```bat scripts\schedule.bat install rem 注册 6 个 Windows 计划任务 scripts\schedule.bat status rem 每个任务的下次运行时间 / 重复设置 / 上次结果 scripts\schedule.bat remove ``` 八个任务(20261026 19:57 首次注册 6 个,20261028 20:50 重注册为 8 个,`ashare_prune` 手工 `/Run` 一次退出码 0): 工作日 09:30–11:30 与 13:00–15:00 各每 30 分钟 `realtime`、15:20 `hot`、16:00 `all_quotes` (17:30 再来一条兜底触发,客户端偶尔 17 点后才落盘)、19:00 `finance`、21:30 `fund_nav`、 周六 10:00 `prune`。16:00 是给通达信客户端留的落盘时间 (20261028 实测客户端 15:45 之后才写当日棒,15:35 跑会全市场 skipped);任务定义在 `scripts/install_tasks.py`(`.bat` 只是转调它), 每个任务都带 `-X utf8`、`StartWhenAvailable`(错过就补跑)、电池模式下不停不禁止启动、 以及各自的 `ExecutionTimeLimit`(`finance` 8 小时,其余 30 分钟~2 小时)。 `run.py` 用 `data/run.lock` 做单实例锁(`msvcrt.locking`,非 Windows 退回 `fcntl`), 计划任务与手工运行撞车时后来者直接以退出码 `2` 结束,不会把 DuckDB 写坏。 实测吞吐(20261026,用于判断哪些环节必须交给夜跑): | 环节 | 实测 | 全市场外推 | | --- | --- | --- | | `daily --full`(本地 `.day` 解析入库) | 20261026 复测:9,649 文件 / 24,114,652 行 / 402.1s(sh 12,779,462 + sz 11,055,921 + bj 279,269) | ≈6.7 分钟(约 6 万行/秒) | | `realtime`(腾讯+新浪全市场快照) | 25.5s,5594/5593 行 | 每次 30 分钟任务约 26 秒 | | `statement`(新浪三大报表,`scope=part`) | 15 票 45 请求 22.3s ≈ 2.0 请求/秒,581 行/票 | ≈2.25 小时、约 310 万行 | | `dividend` / `f10` | 各 14 请求 6.7s ≈ 2.0 请求/秒 | 各约 45 分钟 | | `adj`(前复权因子) | 50 票 1.1s ≈ 22ms/票 | ≈2 分钟 | | `basic` / `block`(纯本地) | 7864 行 0.2s / 594 行 0.0s | 秒级 | | `export`(DuckDB -> Parquet 投影) | stock_daily 24,114,652 行 / 27 文件 536.8MB / 7.7s、valuation_daily 5595 行 0.0s | 秒级,按需手工跑,不入夜跑链 | 新浪 `money.finance.sina.com.cn` 域名限速 0.5 秒/请求,是财务链的唯一瓶颈:全量报表 + 分红 + F10 约 3.7 小时,只能靠 19:00 的 `finance` 定时任务跑,不要塞进盘中链路。 ## 10. 离线索测 `python run.py selftest`(22 项,零网络)。`tests/fixtures/` 是 `data/raw` 的真实快照, 文件名即 `Http` 的 `key`,所以命中 fixture 的路径与线上路径完全一致。覆盖: `.day` 32 字节解码与 ÷100/÷1000、成交量退写成手的还原(含「均价只高 10 倍」的价格标度反例, 并断言不修改调用方的原表)、腾讯恒等式与成交额/量口径、估值亿元→元、 新浪万元→元与「每股类不缩放」、报表按期数、分红首行与 F10 上市日期、 热榜题材标签覆盖率、涨停池 HS 边界、概念目录去重、复权因子累乘、代码市场归属、 品种分类 kind、Parquet 投影导出(`VARCHAR` 保真、非 8 位日期落 `bad.parquet`、 失败不留 `.tmp` 且范围外旧文件字节不变)、盘中半截棒拦截(收盘前后的日期判定、 `fill` 盘中拒绝、`*_rt` 行被更晚快照覆盖、已有 `tdx_day` 行不被改写)、 尾棒逐字段对账(内容一致不改写、客户端改写当日棒必须修回、库里缺尾根要补、 盘中当日半根不参与、水位之后的新棒不越界)、逐日名称区间链(合成的 64 字节 `profile.dat` 记录锁住字节偏移与「`start_i = end_(i-1)`」的链方向、补齐空格必须删、上市日之前的假日期丢弃)、 股本尾锚门槛(同日不覆盖新浪行、GXRQ 更早不插、等值不插、重跑幂等)、 股本结构解析与前向填充(合成一张 `StockStructureNewTable`:万股×1e4、`--` 读成空、 `19000101` 公告日读成空、**倒算出来的负数流通A股清成 NULL**,再断言 `v_share_daily` 对 NULL 期取最近一期非空而不是当成 0)、同一测试还锁住「按票整体替换」:重抓的页面少了某期时, 库里那期必须被删掉(`dropped_events`),不能留成孤儿事件。 ## 11. 外部源交叉校验 `python -X utf8 scripts/validate_vs_xbx.py --seed 20261026`(只读主库,**不能与同步并行**)随机抽样, 把本库的股票/基金/指数日线与第三方 xbx 源逐字段对照,另外用 `.day` 的**原始字节**独立解码做仲裁, 每个字段给三档计数:`exact` / `noise`(两边存储精度之差,容差模型每次运行都打印)/ `bad`, `bad` 里再分 `bad_eq_file`(本库值 == 文件字节 → 差异在上游供应商之间,不是本项目换算问题)、 `unit_ratio`(x10/x100/x1000 指纹,用来暴露单位口径事故)与除权参考价比对照。 结果打印并落 `logs/validate_xbx_.json`,任一字段有 `bad` 时退出码 `1`。 20261026 两个 seed(20261026 / 20261027)修复前后的传导结论见 [同步手册.md](同步手册.md) §7: 成交量退写修复后 **基金 volume 的 86 条 bad 全部归零**(x100 指纹从 86 条变成 0 条), 其余 bad 字段 100% 满足 `本库 == .day 原始字节`。 逐日名称 / 逐日股本用同一条命令的三档开关校验,**只报告、不进退出码**(退市股旧名与 新浪历史流通盘滞后是源侧缺口,常年红灯只会盖掉真实故障,和 concept 不进取退码同理): | 开关 | 对照物 | 看什么 | | --- | --- | --- | | `--names 400` | xbx 逐日「股票名称」列 | 跑**两档**:`names`(只从 profile 里有旧名的票抽,考验链边界)与 `names_universe`(从 xbx 全集抽,给真实覆盖率,并把切换点 miss 按「代码不在 profile / 库里没名 / profile 漏了这次改名」分类)。行级 bad 再分「本库空 / xbx 用的是上市首日临时简称(`N南航` = N + 两个汉字)/ xbx 文件首行写了当前名 / 真差异」;归一化规则见 §5.2 | | `--shares 150` | xbx「流通市值 / 收盘价」反推的流通股本(已先验证该列是流通市值而非总市值,见 §5.2) | 与视图同口径(事件值按票前向填充,并记录值来源日)后的 `exact`(≤0.1%) / `within`(≤2%) / `bad`;`bad` 再分「距值来源日 ≤20 自然日的边界差 / 本库值在 xbx 历史里出现过的错位 / 持续性 level 差」,样例带 `source` 与值来源日 | | `--view` | —— | 两个视图的建视图耗时、全量展开耗时与填充率(决定「视图 vs 落地表」的那次实测) | ## 12. 没有做的 * 分钟线(本机无文件,见 §4)、Tick、Level-2。 * 配股除权建模(见 §7)。 * 同花顺概念全集(需 `hexin-v` JS 算法,属对抗性抓取,未做)。 * 财务指标的宽表透视 —— `financial_item` 是长表,`PIVOT` 一句 SQL 就能拉平,不预存冗余。 * 退市股的逐日名称 —— 本机 `profile.dat` 与 `.day` 一样只留在市证券(见 §5.2),要补得靠外部源。 * ETF/基金份额的**逐日历史** —— 新浪股本页对基金返回空页(20261026 实测 510300/159915/588000 各 0 期),`base.dbf` 只有**现值**一条,所以每只基金在 `share_event` 里只有一条 `source='tdx_base_dbf'` 的现值锚(基金合计 2,224 条,见 §5.2);真正的份额序列只能从每日 `valuation_daily`(腾讯)往后累积。