前视偏差防护(Point-in-Time)¶
适用版本:v0.5.0 之后。对应上游
TauricResearch/TradingAgentsv0.4.0~v0.4.2 的 同一类修复(#1251 / #1126 / #1220 / #1300 / #1167 / #1288 / #1170)。
为什么需要它¶
本项目支持按历史日期运行分析:
astock-trader analyze 600519 --date 2025-06-01
只要分析日不是今天,任何「在 2025-06-01 之后才可知」的信息都会构成 前视偏差(look-ahead bias)。它的危险之处在于不会报错:结果看起来完全正常, 只是模型提前知道了答案。改造前有三处真实泄漏:
| 泄漏点 | 改造前的行为 | 后果 |
|---|---|---|
| 交易记忆 | get_past_context(ticker) 不看运行日期 |
2025 年的分析读到了 2026 年才落地的反思教训 |
| 向量记忆 | MarketMemory.search(query, top_k) 无日期约束 |
TF-IDF 按语义相似度把「未来」的分析报告注入 prompt |
| 新闻流 | 日期解析失败时直接返回全部;窗口筛空时退回全量 | 历史窗口读到最新(未来)的稿件 |
| 反思闭环 | 用 timedelta(days=5)(自然日)判断持有窗口是否走完 |
长假期间 1 日收益被写成「5日收益」,污染记忆 |
| 基本面快照 | get_fundamentals 等四个工具取「最新」财报与市值,curr_date 标为 unused |
2025-06 的分析读到了 2025-08 才披露的半年报 |
反思闭环那条尤其隐蔽:它不泄漏未来,而是写错过去——错标签会被后续决策 当作「历史教训」反复引用。
基本面那条的难点在于「报告期结束 ≠ 可知」:一季报的报告期是 3-31,但最晚 4-30 才披露。直接拿报告期和分析日比大小,能把最多一个月后的信息放进 prompt。
五道门¶
判据收敛在 src/astock_trader/point_in_time.py 一个模块里,避免每个数据源各写
一份。各条的保守方向一致:证明不了它在分析日之前可知,就不放行。
1. 交易记忆时间点门控¶
每条 resolved 条目记录 resolved_date —— 结局落地的那一天,也就是计算收益
所用到最后一根 K 线的日期(不是「反思生成日」,那个总是更晚)。
memory_log.get_past_context("600519", as_of="2025-06-01")
- 只放行
resolved_date <= as_of的条目; - 没有
resolved_date的老条目在as_of查询下保守排除(回测里无法证明它 当时已知),实时运行不受影响; as_of=None(实时运行)完全不过滤,行为与改造前一致。
2. 向量记忆时间点门控¶
market_memory.search("白酒行业龙头估值", top_k=3, as_of="2025-06-01")
- TF-IDF 后端在排序前按
date <= as_of过滤候选,因此top_k仍由历史记录 填满,而不是先取未来再丢弃; - chroma 后端下发
where={"date": {"$lte": as_of}},并在返回后再兜底过滤一次: 后端忽略该条件时,宁可少返回也不能放行未来记录; search_by_ticker的那条「直接扫元数据」兜底路径同样受门控。
3. 持有窗口按交易日判定¶
反思闭环结算要求已出现 days + 1 根已收盘 K 线,不足则条目保持 pending、
下一轮再试:
- 自然日不够用:春节/国庆长假里 5 个自然日可能只含 1~2 个交易日;
- 平仓价一律取严格早于今天的 K 线。当日盘中(或收盘后数据未落定)的那根 仍可能变化,用它结算等于把浮动价格写成 T+N 结果。代价是结算最多延后一天, 换来的是「写进记忆的数字都是已收盘价」;
- 基准(沪深300)拉不到时,
alpha_return记为None,文案写「超额未获取」—— 不假装超额为 0。
4. 新闻窗口¶
from astock_trader.point_in_time import in_window
比较的是北京时间的日历天:先把发布时间换算到市场所在时区,再判是否落在
[start, end] 这几个日历天里。
这是一个容易写错的地方:用 UTC 半开区间 [start, end + 1 天) 判定会整体偏移
8 小时——「分析日次日 00:01 发布」的稿件会被算成 end 当天下午 4 点,正好漏进
窗口。国内数据源(东方财富 / akshare)的发布时间是北京时间且常常不带时区信息,
所以 naive 值按 CST(固定 UTC+8,不用 zoneinfo——Windows 上缺 IANA 数据库时
会直接抛异常)解释。
没有发布时间的条目只在实时窗口保留:回测里无法证明它不属于未来。
5. 基本面报告期门控¶
from astock_trader.point_in_time import report_is_known, statutory_disclosure_deadline
判定的是「这期报告在分析日是否已经公开」,而不是「报告期是否已经过去」:
- 有公告日期就用公告日期(精确)。东财报表帧带
NOTICE_DATE、Tushare 带ann_date/f_ann_date,早于法定截止日披露的也能正确放行; - 没有公告日期就退回法定披露截止日(保守):一季报/年报 4-30、半年报 8-31、 三季报 10-31,年报跨到次年。拿「最晚披露日」当可知日是保守方向——只有「到 截止日仍未披露」这种可能性被排除后才放行;
- 认不出报告期列 → 整帧剔除:无法证明任何一行当时可知。
实时运行不门控¶
这里有个必须区分开的情形。run_as_of(trade_date) 只在分析日严格早于今天时
返回该日期,否则返回 None(不门控):
run_as_of("2025-06-01", today=date(2026, 10, 2)) # -> "2025-06-01" 历史运行,门控
run_as_of("2026-10-02", today=date(2026, 10, 2)) # -> None 实时运行,不门控
实时运行必须不门控:数据源只会返回已经披露的报告,此时再拿法定截止日去卡, 反而会误伤「刚披露但还没到截止日」的报告(9-30 的三季报在 10-02 披露,截止日却是 10-31)。只有分析日已经过去,才需要靠公告日/截止日去证明「当时确实可知」。
日期由运行侧注入,不交给模型¶
四个工具(get_fundamentals / get_balance_sheet / get_cashflow /
get_income_statement)的 trade_date 参数用 InjectedState("trade_date") 标注:
@tool
def get_fundamentals(
symbol: Annotated[str, "A股股票代码"],
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
return route_to_vendor("get_fundamentals", symbol=symbol, curr_date=run_as_of(trade_date))
这个参数模型看不到也改不了,由 LangGraph 执行时从图状态注入。日期不能让模型 自己填:模型漏填一次,历史运行就退回「最新财报快照」的前视偏差——门控必须由运行侧 保证,而不是靠 prompt 提醒。
各数据源的处理¶
| 数据源 | 历史运行下的行为 |
|---|---|
| Tushare | 结构化财报按 ann_date 过滤;daily_basic 按 trade_date 过滤 |
| akshare | 按报告期列 + 公告日期列过滤;市值/股本等当期快照字段隐去(它们是运行当天的值),并留下说明 |
| 妙想 MX | 拒绝出数(抛 VendorError)——自然语言查询返回渲染好的表格,没有可判定的报告期字段,证明不了就不放行;路由随之换到能过滤的数据源 |
akshare 的 get_fundamentals 另外隐去「总市值/流通市值/总股本/流通股」:这些字段
取的是运行当天的值,历史运行下无法证明当时可知。需要价位时改用 get_stock_data /
get_indicators 取该日行情自行计算。
日线行情本身按 东方财富 → 新浪 → 腾讯 三源 fallback(东财会按出口 IP 做服务端
拦截)。换源不影响时点纪律:get_indicators 只取 curr_date 及之前的 K 线,
get_technical_indicators 的区间外历史仅用于均线预热、不出现在输出里,两者都不会
把分析日之后的行情喂进指标。唯一需要注意的是三家的前复权基准略有差异,所以
同一条序列始终由同一个源提供,不做跨源拼接(表头的 source: 会写明是谁出的数)。
数据源错误层级与换源契约¶
src/astock_trader/dataflows/errors.py:类型数量 = 路由层的不同反应数量,
不是「人能数出来的原因」数量。
VendorError
├── NoMarketDataError 没有可用数据(空结果或数据陈旧)
├── VendorRateLimitError 限流/配额用尽 → 换下一个数据源
└── VendorNotConfiguredError 缺 API Key 或依赖未安装 → 该数据源不可用
route_to_vendor 有两个换源触发条件:
- 抛异常 —— 按类型给日志级别:缺 key 是预期内的(debug),限流和故障才是 warning;
- 返回
"[ERROR] ..."串 —— 这也是失败。
第 2 条是这一版修掉的实际缺陷:改造前路由把任何返回值都当成成功直接返回, 第一个数据源一报错,整条 fallback 链就作废。妙想配额用尽时,链上的 Tushare / 东方财富 / akshare 根本不会被尝试,Agent 拿到一句错误文本并把它当成数据。
边界写得很窄,只认 [ERROR] 标记:「查到了但该标的确实没有数据」这类信息性返回
("... No data returned for ...")不算失败——它和数据源故障是两回事。真想让
路由层在这类情况下换源的数据源,应当抛 NoMarketDataError。
新增数据源时的检查清单¶
- [ ] 不可用时抛
VendorError子类,而不是返回错误字符串(字符串会丢分类信息) - [ ] 缺 Key / 缺依赖 →
VendorNotConfiguredError - [ ] 限流、配额、频次超限 →
VendorRateLimitError - [ ] 查到但无数据 →
NoMarketDataError(symbol, canonical=..., detail=...) - [ ] 网络超时 / 传输异常 → 裸
VendorError(可加_safe_call包装统一转换) - [ ] 凡是有时间维度的返回,先问一句:这次运行的分析日是什么? 历史日期下 它会不会返回分析日之后的内容?
- [ ] 财报/研报类返回另问一句:这期数据最晚什么时候公开? 报告期结束日
(如 3-31)不是可知日;拿不到公告日期就退回法定披露截止日
(
statutory_disclosure_deadline),认不出报告期就整帧剔除 - [ ] 拿到
curr_date后证明不了就别出数:像妙想那样抛VendorError让路由 换源,好过把运行当天的快照当成历史数据
尚未覆盖(已知缺口)¶
诚实起见列出这版没有修的部分:
| 缺口 | 说明 | 计划 |
|---|---|---|
| 「当期快照」类字段 | 市值、股本、PE/PB 分位等字段取的是运行当天的值,法定披露日推不出历史值;目前历史运行下由 akshare 隐去、妙想整体拒绝,但没有真正的历史估值源 | 需要带时点的估值源(如 Tushare daily_basic 按交易日)时逐字段接入 |
| 宏观数据 vintage | 我们尚未接入类似 FRED 带 vintage 的宏观源;若接入,必须把 vintage 钉在 as-of 日 | 接入时一并做 |
| 分析师的工具调用 | ReAct 工具循环里的日期窗口由 Agent 自己填,靠 SYSTEM_PREFIX 的「数据缺失时写数据不足」约束;未做工具层的强制日期夹取 | 观察实际调用参数后再定 |
复现与验收¶
# 全量测试(含 7 个专项测试文件,共 534 项)
pytest tests/test_point_in_time.py \
tests/test_memory_pointintime.py \
tests/test_market_memory_pointintime.py \
tests/test_reflection_holding_window.py \
tests/test_news_lookahead.py \
tests/test_fundamentals_pointintime.py \
tests/test_dataflows_vendor_errors.py -v
# 历史日期跑一遍,确认 prompt 里没有未来信息
astock-trader analyze 600519 --date 2025-06-01
借鉴对照¶
| 上游提交 | 本文对应章节 |
|---|---|
fix(memory): gate past-context lessons to point-in-time in backtests (#1251) |
1. 交易记忆时间点门控 |
fix(dataflows): trim social sentiment sources to the analysis window (#1126) |
4. 新闻窗口 |
fix(data): keep future/undated news out of historical windows (#1220) |
4. 新闻窗口 |
fix(data): filter fundamentals to the analysis date (#1300) |
5. 基本面报告期门控 |
fix(memory): don't settle a decision before its holding window trades |
3. 持有窗口按交易日判定 |
fix(agents): require absolute price levels from the Trader (#1288) |
trader.py:绝对价位约束 |
fix(agents): ground the Trader in the technical market report (#1167) |
trader.py:技术面报告锚定 |
fix(rating): surface an unparseable rating as REVIEW, not a silent Hold (#1170) |
rating.py:RATING_REVIEW「待复核」 |
fix(agents): stop the debate managers forcing a direction under ambiguity |
研究经理/组合经理 prompt 的「裁决纪律」 |
fix(data): unify vendor errors under a VendorError hierarchy |
数据源错误层级与换源契约 |