investment-analyst:每天都在跑的模拟 ETF 量化调仓线
local/investment-analyst
etf-rebalance v3.0.0 维护 RWHL——人形机器人龙头模拟 ETF(页面内置免责声明,非真实基金)。每日 SOP 顺序固定:qualitative overlay(先人后机器)→ 增量行情同步 → 重绘合成 K 线 → 5 策略回测 → 计算当日调仓 → 追加调仓历史 → POST /api/etf/portfolio。整套纯标准库实现,因为生产机只有 3GB 内存。
我们的判断第一原则是策略层与执行层严格分离:strategy.py 只回答目标权重,execution.py 管尾盘集合竞价撮合、涨跌停闸门与受阻再分配,绕过执行层就意味着回测数字不再可比。日跑小于 2 秒靠三层短路,输入指纹一致就直接 skip。两个真实踩坑都在成交量单位上——正确解法是用独立成交额反推因子,而不是背板块表。
它到底是一条什么线
etf-rebalance v3.0.0 维护 RWHL——一个人形机器人龙头模拟 ETF(页面内置免责声明,不得对外表述为真实基金)。它是本站唯一一条每天都在跑、且结果直接对读者可见的量化线,数字孪生里的执行者是 investment-analyst。每日 SOP 是一条固定顺序的流水线:qualitative overlay(先人后机器)→ 行情同步(增量)→ 重绘合成 K 线 → 5 策略回测 → 计算当日调仓 → 追加调仓历史 → POST /api/etf/portfolio 写库。
整套代码纯标准库实现,在仓库根目录跑,不依赖 pandas/numpy。这不是为了炫技:生产机只有 3GB 内存,回测要逐日滚动求解 195 日窗口,依赖越薄越可控。
架构的第一原则:策略层与执行层严格分离
| 模块 | 只回答什么问题 | 禁止做什么 |
|---|---|---|
scripts/etf/data_feed.py | 日 K 与快照从哪来、缓存到哪 | 不含任何权重逻辑 |
scripts/etf/strategy.py | 「目标权重是什么」 | 不关心能否成交、不关心涨跌停 |
scripts/etf/execution.py | 尾盘撮合、涨跌停闸门、受阻再分配、成本 | 不改目标权重的意图 |
scripts/etf/backtest.py | 滑窗协方差、逐日滚动求解、NAV 合成 | 不引入未来信息 |
scripts/etf/report.py | 调仓报告 markdown | 纯函数,禁用 now():同样输入必须产出同样字节 |
新增策略只要继承 strategy.BaseStrategy、实现 target_weights()、注册进 STRATEGIES,就自动进入回测对比与前端图表。执行规则(尾盘 / 涨跌停 / 成本)对全部策略统一,禁止在策略内绕过执行层——绕过就意味着回测数字不再可比。
量化方法(口径勿改)
风险模型用 120 交易日对数收益的样本协方差,按 Ledoit & Wolf (2004) 向缩放单位阵解析收缩,强度 δ* 数据驱动。权重解广义 ERC 方程 w_i·(Σw)_i ∝ b_i(Maillard-Roncalli-Teïletche 2010;Spinu 2013 的凸规划保证唯一解),其中 b 是 importance score 作为风险预算先验;b 全等时退化成经典风险平价。单票夹在 [2%, 12%]。
旗舰策略 size-tilt 在风险预算先验上乘市值因子 mult_i = clip(1 + λ·log2(cap_i / 300亿), 0.5, 2.0),λ=0.25,市值代理 = 前复权收盘 × 当前股本,每个调仓日只用截至当日信息,无前视。它还有两道硬约束:市值 ≤200 亿元的成分预算直接置 0(在可投子集上解方程,可投不足 3 只时退回完整篮子);200–300 亿元市值带单票 ≤3%(SIZE_BAND_CAP),上限经 diag["caps"] 逐成分下发,执行层的受阻再分配同样不得突破,各上限之和不足 1 时缺口留现金。
已注册 5 条策略:equal-weight(1/n 基准)、risk-budget、size-tilt(旗舰)、min-variance、momentum-tilt(3 个月截面动量 z-score 乘进预算,clip [0.25,1.75])。基准是沪深300(000300)同基期归一,逐策略算几何超额、年化跟踪误差、信息比率 IR 与跑赢胜率。
A 股微观结构进执行层
回测不假装能随时成交:全部调仓按调仓日收盘集合竞价折算,当日收益仍按旧权重,新权重次一交易日生效。涨跌停闸门按板块给——创业板/科创板 ±20%,主板 ±10%;收盘封死跌停(收盘=最低=跌停价)就卖不出、维持旧权重,封死涨停就买不进。锁仓造成的缺口沿目标方向分摊给可交易成分并尊重上下限,容量不足的残留记为现金敞口并在报告里披露。成本按单边 10bp × 换手率从净值计提。目标权重为 0 的成分(市值剔除)不参与再分配,不会被被动买回来。
定盘口径:15:05 之前跑属于正常
因为策略按收盘竞价成交,它只消费已定盘的交易日。data_feed.settled_cutoff() 按 Asia/Shanghai 15:05 判定:之前跑 SOP,当日 bar 只是盘中残 bar(收盘价=瞬时价、成交量只累计到当下),会被 drop_unsettled() 丢弃,调仓基准回落到上一交易日收盘。所以 fund.window_end 等于「最后一个已收盘交易日」而不是「今天」,盘中跑不是 bug。成分股表展示的 price/change_pct/mktcap_yi/pe_ttm 也一并对齐到定盘收盘,不会出现「盘中价 + 昨日权重」这种自相矛盾的页面。--allow-intraday 只作研究用,⛔ 不得用于调仓。
性能:三层短路,日跑 <2s
2026-09-07 重构前的慢源于「每只成分一个串行 HTTP + 每次全量重拉基准 + 无条件重跑回测与落盘」。现在的口径是:
python3 scripts/sync_etf_quotes.py # 日跑 <2s(幂等短路),有新数据 10-20s
python3 scripts/sync_etf_quotes.py --init --days 420 # 首次/修复数据
先用一次 qt.gtimg.cn 批量快照证明「有没有新数据」(实测 0.4s),再比输入指纹(K 线 + 基准 + 股本按 0.01 亿股量化 + 代码版本)与 state.json 里上次成品是否一致,一致就打印 [skip] 直接退出——不发 K 线请求、不重跑回测、不重写成品,周末重复跑实测 0.5s。有新数据才增量拉 only=stale,基准走锚点增量并做 OVERLAP_BARS 重叠校验,drift 越界自动全量自愈。另有 SourceBreaker:连续 2 次连接级失败即停用该源,⛔ 熔断后不要写循环反复探测同一源。
踩过的两个坑,都值得抄
坑一:量单位分板块。成交额统一是「元」,但日 K 的成交量单位随板块变化——主板/创业板以「手」报出(×100 → 股),科创板 688/689 直接以「股」报出。两次踩坑都在这一行:早期腾讯分支不换算,汇川整条序列量额少 100 倍;改成写死 ×100 后,5 只科创板整条序列虚高 100 倍(寒武纪单日成交额被写成 7227 亿元,比其流通市值还大)。正确做法是用独立成交额反推因子,而不是背板块表:东财主源用 amount / (量 × 典型价) 反推并吸附到 {1, 100};腾讯备源日 K 不给成交额,就先拉实时快照用 calibrate_volume_factors() 逐成分算因子。
两道自检哨兵缺一不可:amount_unit_ok() 要求隐含均价落在当日 [low, high] 带内(抓量额不对称);amount_drift() + OVERLAP_BARS=6 拿缓存与新鲜数据比同一天的成交额倍差,越界即全量重拉自愈——这是唯一能抓「整条序列同乘一个倍数」的手段,5 只科创板正是被它以 ~100x 判脏后重拉的。
坑二:发布路径。组合数据原先走 /static/etf/portfolio.json,被腾讯 CDN 边缘缓存且缓存键不含 query(?v= 桶号无效),每日调仓后线上最长 24h 吐旧数据。2026-08-31 起改为入 Postgres etf_snapshots 单行快照 + POST /api/etf/portfolio(X-API-Key 鉴权),前端读 GET /api/etf/portfolio,/api/* 全局 no-store,写入即生效、无需任何刷新动作。这条教训后来变成了整站的铁律:静态路径只放媒体,数据一律走 API。
人的位置:Qualitative Overlay
量化模型只消费 score(0–10 重要度先验)与量价数据。事件驱动的调分由人做,而且理由必须写进汇报,禁止无依据改分:行业进展(量产节点、特斯拉 Optimus/Figure/国内主机厂定点订单、减速器/丝杠/灵巧手突破、产业政策)→ 直接受益龙头 +1~+2;财报超预期 +1~+2,不及预期/商誉减值/大股东减持质押/立案调查 -1~-3,连续两个季度证伪产业逻辑则移出篮子;涨跌停封板一般不改分(执行层自动顺延),只有基本面恶化才 -1~-2。这层人工判断是整条线里唯一不允许自动化的环节。
行情源礼仪
公开免费接口、无鉴权:主源东方财富 push2his(前复权日 K),备源腾讯 ifzq.gtimg 自动切换,实时快照走 qt.gtimg.cn 批量接口。请求间隔 ≥0.4s,失败线性退避重试 3 次,禁止并发轰炸。东财会因高频探测封 IP(实测 RemoteDisconnected,冷却 10 分钟以上),所以排查数据问题时禁止写循环连续打多只标的——一次 SOP 正常约 20 个请求没问题,手动连打十几个就会被封。被封的后果不只是慢:全部成分退化到腾讯备源,成交额变成典型价估算值,K 线量能柱失真。