feat(scoring): add entry timing and AI opinion overlays

Add model versioning and forward-log validation, tighten scanner data flows, and cover the new contracts and decision paths with regression tests.
This commit is contained in:
Starriv
2026-07-10 11:30:35 +08:00
parent 919cba9559
commit aeacabcf3f
35 changed files with 2755 additions and 177 deletions
+3
View File
@@ -191,3 +191,6 @@ $RECYCLE.BIN/
# Local run artifacts
*.out
*.err
tasks/
data/
+8 -8
View File
@@ -3,19 +3,19 @@
> **理念:做趋势。** 只参与已被市场验证的方向,不猜底、不博反弹、不和趋势对抗。 抄底会错无数次,追高只会错一次
通过多因子量化分析判断股票是否值得买入,买之前问一问防止冲动金融消费。
脚本会在可用时只读 Alpaca 账户持仓,把当前敞口、成本和浮盈亏纳入建议,并输出参考入场价、保护性出场价和止盈价。Finnhub 可作为可选补充数据源,用于 quote/news/profile/earnings 核验和事件风险识别;其中新闻/财报只会生成降级候选,不会把标的加分或升级。
脚本会在可用时只读 Alpaca 账户持仓,把当前敞口、成本和浮盈亏纳入建议,并输出参考入场价、保护性出场价和止盈价。Finnhub 可作为可选补充数据源,用于 quote/news/profile/earnings 核验和事件风险识别;其中新闻/财报只会生成降级候选,不会把标的加分或升级。Claude AI 意见是评分完成后的独立对照,只写展示字段。
## 实现原理
```
ALPHA 加权层 (排名分) → 风险否决层 (封顶) → 确认 overlay (不扣分)
momentum 55 MA200 下行 → 55 技术转弱时 "是" → "观察"
rel_strength 35 MA60 跌破 → 65
efficiency 10
周线空头 → 50
ALPHA 加权层 → 风险否决层 → 确认 overlay → 入场时机层
排名分来源 封顶/否决 技术量价确认 过热降级/拐点触发
momentum 55 MA200/MA60 只挡买入 不改 composite
rel_strength 35 周线/大盘
efficiency 10
```
权重来自逐因子 IC 回测校准:风险调整动量、相对强度和趋势效率进入排名分。MACD/RSI/KDJ/量价等经典指标不进排名分,只做确认和风控解释。
权重来自逐因子 IC 回测校准:风险调整动量、相对强度和趋势效率进入排名分。MACD/RSI/KDJ/量价等经典指标不进排名分,只做确认和风控解释。第四层识别过热与强趋势内的有序回调反转;量能缺失时不允许拐点升级。
## 使用示例
@@ -29,7 +29,7 @@ ALPHA 加权层 (排名分) → 风险否决层 (封顶) → 确认 overlay
## 依赖
仅支持美股,主价量数据来源为 [Alpaca Markets](https://alpaca.markets/),需先开户并配置 [Alpaca Skill](https://alpaca.markets/blog/alpaca-launches-skills-library-for-ai-agents/)。可选在本地 `.env`(skill 根目录或 `~/.config/worth-buy-stocks/.env`)设置 `FINNHUB_API_KEY` 读取 Finnhub 补充上下文;Telegram 推送同样读取该 `.env`。
仅支持美股,主价量数据来源为 [Alpaca Markets](https://alpaca.markets/),需先开户并配置 [Alpaca Skill](https://alpaca.markets/blog/alpaca-launches-skills-library-for-ai-agents/)。可选在本地 `.env`(skill 根目录或 `~/.config/worth-buy-stocks/.env`)设置 `FINNHUB_API_KEY` 读取 Finnhub 补充上下文,设置 `ANTHROPIC_API_KEY` 并安装官方 `anthropic` SDK 生成 AI 对照意见;Telegram 推送同样读取该 `.env`。
## 广告
+31 -21
View File
@@ -1,6 +1,6 @@
---
name: worth-buy-stocks
description: "Evaluate whether a US stock is worth buying, holding, reducing, avoiding, or watchlisting under the user's Alpaca-based trend-following and relative-strength framework, with news/event-risk overlay, read-only account exposure overlay, whole-market scanning, and optional multi-agent evidence collection via stable JSON contracts. Use when the user asks 值得买吗, 股票评分, buy/hold/sell/avoid/watchlist, Alpaca 行情或持仓, account exposure, 全市场扫描/找今天能买的, multi-agent stock analysis, 并行采集, agent contracts, 入场价/出场价/止盈止损, 30 天行情, 当日行情, 新闻面/事件风险, 交易纪律, 趋势跟随, 相对强度, 主升趋势, 修正阶段, momentum leadership, 量价确认, 顶背离, or 超买超卖."
description: "Evaluate whether a US stock is worth buying, holding, reducing, avoiding, or watchlisting under the user's Alpaca-based trend-following and relative-strength framework, with news/event-risk overlay, read-only account exposure overlay, display-only AI second opinion, whole-market scanning, and optional multi-agent evidence collection via stable JSON contracts. Use when the user asks 值得买吗, 股票评分, buy/hold/sell/avoid/watchlist, Alpaca 行情或持仓, account exposure, 全市场扫描/找今天能买的, multi-agent stock analysis, 并行采集, agent contracts, 入场价/出场价/止盈止损, 30 天行情, 当日行情, 新闻面/事件风险, AI 意见/AI 分析对比, 交易纪律, 趋势跟随, 相对强度, 主升趋势, 修正阶段, momentum leadership, 量价确认, 顶背离, or 超买超卖."
---
# 值得买 - 股票版
@@ -15,20 +15,21 @@ description: "Evaluate whether a US stock is worth buying, holding, reducing, av
4. **数据诚实**:核心价量缺失输出 `无法评分`,其余缺失写 `无法确认`;不编造结论或持仓。
5. **不泄露凭证**:不打印、保存或输出 Alpaca/Finnhub/Telegram 密钥。
6. **feed 固定 `iex`**:只有用户明确说明有 SIP 权限并要求使用 `sip` 时才切换。
7. **最终回复是书面交易结论**:默认只输出结论,不输出思考过程、工具计划或逐步计算;不展示源码、命令参数、JSON 路径、字段名、布尔值、空数组、`null` 或内部状态,全部翻译成自然语言(如空风险项写"未触发大盘风控")。用户明确要求原始 JSON 或调试信息时例外。
7. **最终回复是书面交易决策**:默认只输出结论,不输出思考过程、工具计划或逐步计算;不展示源码、命令参数、JSON 路径、字段名、布尔值、空数组、`null` 或内部状态,全部翻译成自然语言。每一项都写成正式决策或事实陈述("通过""维持观察""按 X 价分批"),两类句式禁止出现:(a) 机制性否定——描述评分内部"没发生什么"的句子,如"未影响评分""评分未被新闻压低""不构成临近财报降级""未用于降级/升级/调整建议";(b) 免责声明与限定语,如"仅为分析参考""仅作重评参照""不构成下单指令"。未触发的环节直接给结论状态("通过"),不解释机制;触发了的只写事实原因与决策结果。用户明确要求原始 JSON 或调试信息时例外。
8. **仅支持美股**(Alpaca 覆盖标的);非美股 ticker 直接说明不支持,不要强行评分。
数据角色:Alpaca 价量是主证据;新闻/公告默认作为事件风险 overlay;Alpaca 持仓默认作为只读敞口 overlay;Finnhub 是可选补充(quote 交叉校验、公司新闻、财报日历、元数据),不得替代 Alpaca 主链路。
数据角色:Alpaca 价量是主证据;新闻/公告默认作为事件风险 overlay;Alpaca 持仓默认作为只读敞口 overlay;Finnhub 是可选补充(quote 交叉校验、公司新闻、财报日历、元数据),不得替代 Alpaca 主链路。AI 意见(脚本内置调用 Claude API 生成)默认作为对照展示 overlay:只用于让用户对比"量价纪律结论 vs AI 判断",不参与评分、不触发降级、不可用于升级或改写结论;不可用时如实写"无法确认",不得编造或自行补写 AI 意见。
框架取向:只参与趋势延续和相对强度领先,回避弱势修复、均值回归反弹和估值叙事。
框架取向:只参与趋势延续、相对强度领先,以及**强趋势内的回调拐点**(趋势与相对强度资格完好、回调后出现放量反转触发);回避弱势修复(趋势破坏后的均值回归)、过热追高末端和估值叙事。入场时机层由脚本判定:过热只降级、拐点触发可替代趋势热度确认,均不改排名分。
## 环境检查
若 `alpaca`、Python 或 Alpaca 凭证未配置,不要继续评分,把 `references/setup.md` 的清单给用户在本机执行。降级规则:
- 账户/持仓读取失败:敞口 overlay 记为不可用,价量评分照常执行。
- 新闻检索能力不可用:新闻面写"无法确认,未用于降级",价量评分照常执行。
- 新闻检索能力不可用:新闻面写"无法确认",价量评分照常执行。
- Finnhub 无 key 或限流:自动跳过,不影响评分。
- AI 意见无 `ANTHROPIC_API_KEY`、未装 `anthropic` SDK 或调用失败:自动跳过,结论段写"AI 意见: 无法确认"。
## 数据流程(单股评分)
@@ -40,7 +41,7 @@ FEED="iex"
1. 默认检索最近 30 天新闻/公告/监管披露:只取公司 IR、SEC、交易所公告和主流财经媒体中能影响交易纪律的 3-5 条(保留标题、日期、链接),不做新闻综述。社交媒体、无来源传闻、模型记忆、分析师目标价不能作为降级依据。整理成 `news_context.json`(形状见 `references/agent-contracts.md`)。
2. 一条命令完成价量指标 + 评分 + 账户 overlay + Finnhub 补充 + 当日 snapshot:
2. 一条命令完成价量指标 + 评分 + 账户 overlay + Finnhub 补充 + 当日 snapshot + AI 意见:
```bash
python3 "$SKILL_DIR/scripts/indicators.py" \
@@ -62,7 +63,8 @@ python3 "$SKILL_DIR/scripts/indicators.py" \
| `--account-context auto\|on\|off` | `auto` | 只读账户/持仓生成敞口 overlay |
| `--finnhub-context auto\|on\|off` | `auto` | 有 `FINNHUB_API_KEY` 时读 Finnhub 补充;无 key 不触网 |
| `--llm-context-file path` | 无 | 新闻面风控 JSON,只降级不加分 |
| `--account-context-file` / `--finnhub-context-file` / `--snapshot-context-file` | 无 | 离线复盘:喂对应 overlay JSON |
| `--ai-opinion auto\|on\|off` | `auto` | 有 `ANTHROPIC_API_KEY` 时生成 AI 意见对照;只展示不评分;无 key 不触网;离线 `--input` 下除非 `on` 不触网 |
| `--account-context-file` / `--finnhub-context-file` / `--snapshot-context-file` / `--ai-opinion-context-file` | 无 | 离线复盘:喂对应 overlay JSON |
| `--input -` | 无 | 离线模式:从 stdin 读 multi-bars JSON,不触网 |
3. 可选步骤:
@@ -78,13 +80,15 @@ python3 "$SKILL_DIR/scripts/indicators.py" \
python3 "$SKILL_DIR/scripts/scanner.py" --feed "$FEED" --adjustment split --top 20
```
流程:取股池(NYSE+NASDAQ 活跃普通股)→ 流动性初筛(IEX 日成交量 ≥5 万、价 ≥5)→ 精简批量评分 → 提取 `是` 候选 → Finnhub 新闻复核降级。输出含最终候选(按评分降序)、被新闻面降级的原"是"、市场 regime 和各阶段计数。全市场扫描耗时可达数分钟,建议后台运行并告知用户预期等待。
流程:取股池(NYSE+NASDAQ 活跃普通股)→ 流动性初筛(IEX 日成交量 ≥5 万、价 ≥5)→ 精简批量评分 → 提取 `是` 候选 → Finnhub 新闻复核降级 → 对最终候选生成 AI 意见(有 key 时)。输出含最终候选(按评分降序,每只候选带 AI 意见)、被新闻面降级的原"是"、市场 regime 和各阶段计数。全市场扫描耗时可达数分钟(开 AI 意见时每个候选再加数十秒),建议后台运行并告知用户预期等待。
关键 flag:
| Flag | 默认 | 作用 |
|------|------|------|
| `--entry all\|pullback\|trend` | `all` | 按入场时机过滤候选:pullback=只要回调拐点,trend=只要趋势延续 |
| `--verify-news auto\|on\|off` | `auto` | auto=有 `FINNHUB_API_KEY` 才对候选做新闻复核降级 |
| `--ai-opinion auto\|on\|off` | `auto` | 仅对 top-N 最终候选生成 AI 意见对照;只展示不评分、不影响排序 |
| `--exchange` | `NYSE,NASDAQ` | 股池来源交易所 |
| `--min-price` / `--min-volume` | `5` / `50000` | 流动性初筛阈值(IEX 量约为全市场 2-3%) |
| `--top` | `20` | 候选列表上限 |
@@ -102,7 +106,7 @@ python3 "$SKILL_DIR/scripts/scanner.py" --feed "$FEED" --adjustment split --top
- `severity=medium` 或 `data_trust=suspect`(增发摊薄、重大诉讼、监管调查、同业重大事故传染、坏数据等):封顶 74,`是` 降为 `观察`。
- `severity=low` 和利好 catalyst:只回显,不影响评分。
Finnhub 自动提炼只识别显式负面关键词(→ `medium/high` 候选)和 7 天内财报(→ `low` 提醒),利好忽略。若同时提供 `--llm-context-file`,手工上下文标量字段优先,来源和红旗追加合并。
Finnhub 自动提炼只识别显式负面关键词(→ `medium/high` 候选)和临近财报(≤7 天 → `medium`,把可成立的 `是` 降级为 `观察`,理由注明"财报临近,趋势开仓纪律降级";8–14 天 → `low` 仅提醒),利好忽略。若同时提供 `--llm-context-file`,手工上下文标量字段优先,来源和红旗追加合并。
## 多 agent 编排
@@ -119,7 +123,9 @@ Finnhub 自动提炼只识别显式负面关键词(→ `medium/high` 候选)
- Chart agent → 仅用户要求时运行 `chart.py`。
- QA agent → 只检查最终回复是否遵守本 skill,不得改写评分或生成替代结论。
每个 artifact 喂入前用 `python3 "$SKILL_DIR/scripts/validate_agent_contract.py" --kind <kind> <file>` 预检(kind:`news_context`/`account_context`/`finnhub_context`/`bars`/`snapshot_context`/`result`)。`indicators.py` 加载时会自动复检:可选 overlay 校验失败则丢弃并在 stderr 警告,核心 `bars`(`--input`)校验失败则输出 `无法评分` 退出。
AI 意见没有可委派角色:它由 `indicators.py`/`scanner.py` 内置生成,不得让 sub-agent(或主 agent 自己)代写 AI 意见;离线 `ai_opinion` artifact 仅用于复盘回放。
每个 artifact 喂入前用 `python3 "$SKILL_DIR/scripts/validate_agent_contract.py" --kind <kind> <file>` 预检(kind:`news_context`/`account_context`/`finnhub_context`/`bars`/`snapshot_context`/`ai_opinion`/`result`)。`indicators.py` 加载时会自动复检:可选 overlay 校验失败则丢弃并在 stderr 警告,核心 `bars`(`--input`)校验失败则输出 `无法评分` 退出。
## 评分合约
@@ -131,9 +137,10 @@ Finnhub 自动提炼只识别显式负面关键词(→ `medium/high` 候选)
- `score.trade_plan`:入场/止损/止盈(2R/3R)/移动止损/追价上限。风控参考,不是订单。
- `score.account_overlay`:持仓状态、当前/目标仓位、建议动作、保护性出场价。不可用时不得编造持仓。
- `score.llm_overlay`:新闻面封顶回显(cap、降级原因、catalyst、红旗)。
- `score.entry_timing`:入场时机层回显(分类与时机分)。分类:`pullback_reversal`=回调拐点(趋势资格完好+回调后放量反转触发,可替代趋势热度确认给出 `是`)、`trend_continuation`=趋势延续、`pullback_no_trigger`=回调中反转未确认、`overextended`=过热(评分达标的 `是` 降级为 `观察`,等回踩计划价)、`trend_broken`=中期趋势破坏。详情(过热度、回调深度、触发信号)在分析对象 `entry_timing` 字段。
- `score.data_flags`:历史不足、因子缺失、低流动性等提示。
辅助字段(按需引用,完整 schema 见 `scripts/README.md`):`raw_composite`、`factor_breakdown`(momentum 55 / rel_strength 35 / efficiency 10)、`risk_gates`、`confirmation`、`suggested_position_pct`、`supplemental.finnhub`。
辅助字段(按需引用,完整 schema 见 `scripts/README.md`):`raw_composite`、`factor_breakdown`(momentum 55 / rel_strength 35 / efficiency 10)、`risk_gates`、`confirmation`、`suggested_position_pct`、`supplemental.finnhub`、`supplemental.ai_opinion`(AI 意见:立场、是否与脚本一致、一句话理由、主要风险;只供结论段对照展示,绝不用于改评分或结论)。
`score` 缺失、关键字段缺失或脚本失败时输出 `无法评分`。字段名只供内部取数,最终答复按硬性纪律第 7 条翻译成自然语言。
@@ -143,7 +150,7 @@ Finnhub 自动提炼只识别显式负面关键词(→ `medium/high` 候选)
例外一:`无法评分` 时不硬套 7 段,简短说明缺失的数据和补救步骤(如安装 CLI、检查 ticker、补历史数据)。
例外二(轻量模式):用户明确要求简短(如"简单说""快速看""一句话")时,只输出 `结论` 段——内容仍全部来自脚本评分,仍注明不构成下单指令——并在末尾提示可展开完整分析;用户追问时再给完整 7 段。评分流程本身不简化。
例外二(轻量模式):用户明确要求简短(如"简单说""快速看""一句话")时,只输出 `结论` 段——内容仍全部来自脚本评分——并在末尾提示可展开完整分析;用户追问时再给完整 7 段。评分流程本身不简化。
**结论**
@@ -156,6 +163,7 @@ Finnhub 自动提炼只识别显式负面关键词(→ `medium/high` 候选)
- 建议出场价: 脚本止损价;已持仓优先写保护性出场价
- 止盈参考: 第一/第二止盈价,注明移动止损比例
- 强制排除条件: 自然语言原因;没有写"无"
- AI 意见: 同意,加一句话理由 / 分歧:AI 倾向买入/观察/回避,加一句话理由 / 无法确认
- 一句话: 最关键依据
**关键证据**
@@ -164,18 +172,18 @@ Finnhub 自动提炼只识别显式负面关键词(→ `medium/high` 候选)
**风控过滤条件**
表格列名固定:`过滤条件`, `状态`, `关键证据`, `处理建议`。固定 8 行,没有触发风险也要写"通过/未触发",不能省略:
表格列名固定:`过滤条件`, `状态`, `关键证据`, `处理建议`。固定 8 行,没有触发风险的行也要填写结论状态("通过"或"未发现")与事实证据,不能省略:
| 过滤条件 | 状态 | 关键证据 | 处理建议 |
|---|---|---|---|
| 大盘 regime | 通过 / 风险 | 大盘趋势与是否触发风险;无风险写"未触发大盘风控" | 大盘风险时不新增或降至观察 |
| 大盘 regime | 通过 / 风险 | 大盘趋势事实(如 SPY 相对 200 日线的位置与方向) | 大盘风险时不新增仓位或降至观察 |
| 个股趋势闸门 | 通过 / 风险 | MA60、MA200、周线排列和关键趋势位 | 跌破关键均线不新开;已持仓按保护价管理 |
| 相对强度 | 通过 / 偏弱 / 无法确认 | 相对 SPY/QQQ 的 3m/6m 强弱 | 跑输基准则等待重评 |
| 30 日结构与追价 | 通过 / 过热 / 破位 | 30 日位置、回踩价、突破价、追价上限 | 高于追价上限不追;等回踩或突破确认 |
| 技术与量价确认 | 通过 / 未确认 | 技术确认、MACD/RSI/KDJ 和成交量是否达标 | 确认不足时只观察不买 |
| 流动性/数据质量 | 通过 / 风险 / 无法确认 | 历史数据、snapshot 新鲜度、价差和成交量;无风险写"未发现数据质量问题" | 数据异常时补数据后重评 |
| 新闻/事件红旗 | 未触发 / 低 / 中 / 高 / 无法确认 | 红旗等级、可验证来源、是否触发封顶;无红旗写"未发现中高等级红旗" | 中高风险只降级;不可用时不用于升级 |
| 账户敞口 | 无持仓 / 已持仓 / 无法确认 / 风险 | 当前仓位、目标仓位、现金比例、是否超配 | 超目标减仓;无持仓按入场计划,不下单 |
| 技术与量价确认 | 通过 / 未确认 | 技术确认、MACD/RSI/KDJ 和成交量是否达标 | 确认不足时维持观察 |
| 流动性/数据质量 | 通过 / 风险 / 无法确认 | 历史数据、snapshot 新鲜度、价差和成交量的核对结果 | 数据异常时补数据后重评 |
| 新闻/事件红旗 | 未发现 / 低 / 中 / 高 / 无法确认 | 红旗等级与可验证来源;无红旗写"近 30 天未发现需要处置的事件风险" | 存在中高红旗时按降级后的结论执行;无法确认时以价量结论为准 |
| 账户敞口 | 无持仓 / 已持仓 / 无法确认 / 风险 | 当前仓位、目标仓位、现金比例、是否超配 | 超目标减仓至目标;无持仓按入场计划执行 |
**评分拆解**
@@ -183,17 +191,19 @@ Finnhub 自动提炼只识别显式负面关键词(→ `medium/high` 候选)
**账户敞口与交易计划**
读取到持仓时:当前持仓占账户权益比例、目标仓位、差额、成本价、浮盈亏、保护性出场价、移动止损、2R/3R 止盈、追价上限和处理建议。无持仓时写"当前无持仓,不构成下单指令",仍给出入场/止损/止盈/追价上限。账户读取失败写"账户持仓无法确认,未用于调整建议"。
读取到持仓时:当前持仓占账户权益比例、目标仓位、差额、成本价、浮盈亏、保护性出场价、移动止损、2R/3R 止盈、追价上限和处理建议。无持仓时写"当前无持仓",并给出完整的入场/止损/止盈/追价上限计划价。账户读取失败写"账户持仓无法确认",处理建议只基于价量与新闻面给出。
**新闻面风控**
写新闻检索状态、Finnhub 补充状态、主要 catalyst、红旗、来源日期/链接、是否触发封顶。无红旗写"未发现可验证的重大新闻红旗,未影响评分";不可用写"新闻面无法确认,未用于降级"。
写新闻检索状态、Finnhub 补充状态、主要 catalyst、红旗、来源日期/链接;存在降级时写明事件与降级后的结论。无红旗写"近 30 天未发现需要处置的事件风险";不可用写"新闻面无法确认"。
**建议**
给出一个明确动作并结合账户处理建议:无持仓且计划允许入场可写"按计划价分批新开";`持仓需减风险` 明确写"减仓至目标敞口 / 跌破保护性出场价退出";账户 overlay 不可用则动作只基于价量和新闻面。仅在用户要求详细数据时追加 Alpaca 明细、完整 JSON 或 K 线图。
输出前自检(不写进回复):7 个标题齐全且顺序正确(轻量模式只查 `结论` 段完整);`结论` 段含入场/出场/止盈/持仓/强制排除条件;风控表 8 行齐全且每行有处理建议;交易计划段明确"不下单";新闻段明确红旗或不可用状态;全文无字段路径、snake_case、空数组/对象、`true/false/null`、命令参数等源码式文本。
结论为 `否` 或 `观察` 时,必须附上重评触发条件,写成正式条件句("满足以下任一条件时重新评分:……"),不附加"仅作重评参照"之类的限定语;用较快的信号组合而非慢速确认(如"等 3 个月相对强度转正"太滞后,不要用作默认触发)。默认触发组合:1 个月相对 SPY/QQQ 强度转正,且放量收复 60 日均线(或脚本给出的回踩/突破参考价确认);`观察` 标的若因新闻封顶降级,还需红旗解除或影响明朗。内部约束(不写进回复):触发条件只决定"什么时候重跑脚本重评",不是评分输入,升级与否仍完全由脚本评分决定——1 个月强度存在短期反转噪声,禁止据此直接给出买入结论。
输出前自检(不写进回复):7 个标题齐全且顺序正确(轻量模式只查 `结论` 段完整);`结论` 段含入场/出场/止盈/持仓/强制排除条件/AI 意见(同意/分歧写明理由,不可用写"无法确认",且未据此改动任何评分或结论);风控表 8 行齐全且每行有处理建议;结论为 `否`/`观察` 时建议段含正式条件句的重评触发条件;新闻段明确红旗或不可用状态;全文无字段路径、snake_case、空数组/对象、`true/false/null`、命令参数等源码式文本;全文无机制性否定句与免责声明——逐句检查不得出现"未影响评分""评分未被压低""不构成……降级""未用于降级/升级/调整""仅为分析参考""仅作重评参照""不构成下单指令"及同义变体(硬性纪律第 7 条)。
## Telegram
+158
View File
@@ -0,0 +1,158 @@
# AI 意见 Overlay 设计方案
状态:已实施,2026-07-10 review 修正完成
日期:2026-07-08(2026-07-10 更新)
任务系统:`tasks/projects/ai-opinion-overlay.md`
## 1. 背景与目标
worth-buy-stocks 当前是"脚本 + 量价算法"的确定性评分体系:`indicators.py` 是唯一评分权威,外部信息(新闻、账户、Finnhub、snapshot)全部以 overlay JSON + 契约校验 + 独立降级的方式接入。
目标:引入一个 AI 模型对同一份证据做独立分析,在最终回复的结论段新增一行"AI 意见",让用户对比"量价纪律结论 vs AI 判断",**同时不破坏现有模式**——不动评分逻辑、不动硬性纪律实质、不给分析链路增加硬依赖。
## 2. 设计原则
1. **零评分权力**:AI 意见不改评分、不改结论、不触发降级,只作对照展示。权力级别低于新闻 overlay(可封顶)和账户 overlay(可调仓位建议)。硬性纪律第 2 条"脚本是唯一评分来源"不变。
2. **防锚定**:AI 模型只拿到证据层,不拿到脚本的 verdict/composite,独立形成判断;"同意/分歧"由脚本比较得出,不由模型自述。
3. **优雅降级**:无 API key 不触网,`auto` 模式 API 失败/超时/限流不阻断评分,输出行写"AI 意见:无法确认"。显式 `on` 模式要求每个目标标的成功,任一条目不可用时命令明确失败。
4. **契约化**:ai_opinion 是第五个 overlay,有 JSON 契约、`validate_agent_contract.py` 校验、离线复盘 flag、pytest 覆盖。
## 3. 架构
```
indicators.py 流程(现有):
bars/account/finnhub/snapshot 并行采集 → 指标计算 → 评分 → 组装输出
新增(评分完成后、组装输出前):
评分结果 ──提取证据(隐去 verdict/composite)──▶ ai_opinion.py
│ Claude API (structured outputs)
▼
supplemental.ai_opinion ◀──脚本比较 stance vs verdict 得出 agreement──┘
```
注意:AI 意见依赖指标计算结果,不能与 bars 采集并行;它是评分后的一个后置步骤。
## 4. `scripts/ai_opinion.py` 规格
### 4.1 输入(证据载荷)
从单 symbol 的评分结果中提取,**必须隐去** `score.verdict`、`score.composite`、`raw_composite`、`suggested_position_pct`、`trade_plan`(含结论倾向的字段一律不进):
- 日线/周线趋势状态、MA 排列、关键趋势位
- 30 日结构(位置、回踩价、突破价)
- 相对 SPY/QQQ 强度(3m/6m)
- MACD/RSI/KDJ、量价确认状态
- market regime
- `news_context` 的红旗与 catalyst(如有)
- data_flags(历史不足、低流动性等)
### 4.2 API 调用
- SDK:官方 `anthropic` Python SDK(需加入依赖说明,不用 requests 裸调)
- 模型:`claude-opus-4-8`
- 结构化输出:`output_config: {format: {type: "json_schema", schema: ...}}`,保证返回合法 JSON,无需解析容错
- thinking:`{"type": "adaptive"}`;effort:`medium`
- 超时:显式设置(建议 30-60s),失败即降级,不重试超过 SDK 默认
- key 查找:`ANTHROPIC_API_KEY`,复用现有 `.env` 查找链(`WORTH_BUY_STOCKS_ENV_FILE` → skill 根目录 `.env` → `~/.config/worth-buy-stocks/.env` → 环境变量),实现在 `local_env.py` 基础上扩展
- 提示词:系统提示声明角色(独立技术分析视角)、纪律框架(趋势跟随/相对强度取向)、只基于给定证据不引入外部知识、不输出投资建议措辞
### 4.3 模型返回 schema
```json
{
"stance": "buy | watch | avoid",
"confidence": "low | medium | high",
"view": "一句话核心理由(中文,≤60字)",
"key_risks": ["...", "..."]
}
```
### 4.4 脚本输出(ai_opinion 契约)
顶层为 `symbols` 映射包装(与 finnhub_context/snapshot_context 同构),以支持多 symbol 离线注入;每个 symbol 的条目即写入该 symbol `supplemental.ai_opinion` 的内容:
```json
{
"contract_version": "worth-buy-stocks.agent.v1",
"kind": "ai_opinion",
"status": "ok | unavailable",
"reason": "非 ok 时必填",
"symbols": {
"AAPL": {
"status": "ok | unavailable",
"reason": "非 ok 时必填",
"stance": "buy | watch | avoid",
"confidence": "low | medium | high",
"view": "一句话核心理由(中文,≤60字)",
"key_risks": ["..."],
"agreement": "agree | diverge | not_applicable",
"model": "claude-opus-4-8",
"generated_at": "ISO 8601"
}
}
}
```
`status=ok` 的 symbol 条目必填 `stance/confidence/view/agreement`;`status=unavailable` 的条目只需 `reason`。顶层 `status=unavailable` 表示整体不可用(如无 key)。
> 2026-07-08 调整:原设计为单 symbol 扁平结构,实施 task-001 时改为 symbols 包装,原因:多 symbol 离线复盘(`--ai-opinion-context-file`)需要按 symbol 键控,且与现有 overlay 契约保持同构。
### 4.5 agreement 映射(脚本计算,不由模型自述)
| 脚本 verdict | AI stance | agreement |
|---|---|---|
| 是 | buy | agree |
| 观察 | watch | agree |
| 否 / 持仓需减风险 | avoid | agree |
| 其余组合 | — | diverge |
| 无法评分 | 任意 | not_applicable(此时不调用 API,直接 unavailable) |
## 5. `indicators.py` 接入
- 新 flag:`--ai-opinion auto|on|off`,默认 `auto`(有 key 才调用,无 key 不触网,行为对齐 `--finnhub-context`)
- 离线复盘:`--ai-opinion-context-file path`,加载时经契约校验,失败则丢弃并 stderr 警告(对齐现有 overlay)
- 结果落点:`supplemental.ai_opinion`,**不进 `score` 对象**
- 多 symbol:每个评分 symbol 各一次调用(SPY/QQQ 基准不调用);注意成本与延迟提示
## 6. `scanner.py` 接入
- 只对**最终 top-N 候选**跑 AI 意见(位置在新闻复核降级之后),不对全股池跑
- flag:`--ai-opinion auto|on|off`,默认 `auto`
- 输出中每个候选带 AI 意见行;降级不影响扫描主流程
## 7. 契约与校验
- `validate_agent_contract.py` 新增 kind `ai_opinion`
- `references/agent-contracts.md` 增补 `ai_opinion` 契约形状与示例
- `indicators.py` 加载 `--ai-opinion-context-file` 时自动复检
## 8. 输出格式变更(SKILL.md)
1. **数据角色**段落加一句:"AI 意见默认作为对照展示 overlay,不参与评分、不触发降级、不可用于升级结论。"
2. **结论段**固定字段,在"一句话"之前新增:
- `AI 意见: 同意,<view> / 分歧:AI 倾向<stance 中文>,<view> / 无法确认`
3. **输出前自检清单**同步该行。
4. `references/setup.md` 增加 `ANTHROPIC_API_KEY` 可选配置说明(未配置时功能自动关闭)。
5. 轻量模式:结论段本就完整保留,AI 意见行随之保留,无需特殊处理。
## 9. 测试计划
- `tests/test_ai_opinion.py`:mock API —— 正常返回、超时/异常降级、无 key 跳过、agreement 映射全组合、证据载荷不含 verdict/composite(防锚定回归测试)
- `tests/test_agent_contracts.py`:新增 `ai_opinion` kind 的合法/非法样例
- `tests/test_indicators.py`:`--ai-opinion off` 默认路径不变;`--ai-opinion-context-file` 离线注入;契约校验失败丢弃
- `tests/test_scanner.py`:top-N 候选才调用;降级不阻断
- 端到端:真实 key 单股跑通;全量 pytest 通过
2026-07-10 review 增补回归:
- 顶层 batch 成功但单票 unavailable 时,`on` 模式必须失败。
- 离线 artifact 附加时忽略其 `agreement`,按当前脚本 verdict 重算。
- `docs/` 属于版本化权威文档,不得被 `.gitignore` 整体排除。
## 10. 明确不做(第一版)
- 不给 AI 意见任何评分权(封顶/降级/升级都不给)
- 不做多模型投票、不做 AI 意见缓存
- 不在 scanner 初筛阶段调用 AI
升级路径:若后续用 `backtest_*` 体系回测证明 AI 意见有信息量,再考虑给予类似新闻面的 `min(cap)` 封顶权——届时才需要动纪律条款。
@@ -0,0 +1,48 @@
# Fix: 入场时机与 AI overlay 深度 review 缺陷
> **日期**: 2026-07-10
## 问题描述
- 回调拐点没有约束高点、低点和反转的时间顺序,量能缺失仍可能放行“是”。
- AI 强制模式只看 batch 状态,单票失败会被吞掉;离线 agreement 可以与当前评分不一致。
- forward log 没有第四层字段,v1.0/v1.1 语义无法隔离,股票与 SPY 起始日可能错位。
- scanner 测试夹具被新过热规则击穿,入场类型过滤发生在 Finnhub 调用之后。
- `docs/` 被整体忽略,model card 与设计文档无法版本化。
## 根因分析
- 回撤深度直接用无时间顺序的窗口极值计算,历史低点又与当前 MA20 比较。
- 确认层把缺失值视为通过;AI `on` 只校验顶层 artifact。
- 样本外日志只保存最终 verdict/composite,没有信号日、基础结论或时机分类。
- 文档、测试和任务验收没有随第四层语义同步。
## 修复方案
- 回调改为严格“结构高点早于近期低点”,近五日触碰使用同时点 MA20;五种时机分类均有直接测试。
- technical、volume、trend quality 必须可计算且达标;拐点仅替代 technical/trend quality,不能替代量能。
- AI `on` 对任一目标条目失败报错;离线 agreement 在附加时按当前 verdict 重算。
- 评分输出第四层介入前结论;model card 升为 v1.1。新 forward log 从信号日后下一交易日统一使用股票和 SPY 收盘,并按时机分类统计。
- scanner 在新闻复核前应用入场类型过滤;重建不过热的健康趋势夹具。
- 恢复 `docs/` 版本跟踪并同步 README、契约与设计文档。
## 修改文件
| 文件 | 变更 |
|---|---|
| `scripts/metrics.py` | 有序回调、同时点 MA20、完整时机元数据 |
| `scripts/scoring/engine.py`、`scripts/scoring/gates.py` | 缺失确认不放行、保留第四层介入前结论 |
| `scripts/ai_opinion.py`、`scripts/indicators.py`、`scripts/scanner.py` | 单票失败检测、agreement 重算、过滤前移 |
| `scripts/forward_log.py`、`docs/model-card.md` | v1.1 日志与统一起始日对账 |
| `tests/` | 入场五分类、升级/降级、AI 强制失败、日志日期与 scanner 顺序回归 |
| `.gitignore`、`README.md`、`scripts/README.md`、`references/agent-contracts.md` | 权威文档恢复跟踪并与行为同步 |
## 验证方式
- `python3 -m unittest discover -s tests -q`:295 tests passed。
- `pytest -q`:295 passed。
- `python3 -m compileall -q scripts tests`、`git diff --check`:通过。
- 评分反例:量能缺失维持观察;过热前后 composite 相同且“是”降为“观察”。
- AI 契约:顶层成功但单票 unavailable 时 `on` 报错;离线假 agreement 被重算。
*创建于: 2026-07-10*
+78
View File
@@ -0,0 +1,78 @@
# Model Card — worth-buy-stocks 评分引擎
| | |
|---|---|
| 版本 | **v1.1** |
| 冻结日期 | 2026-07-10 |
| 代码锚点 | `scripts/scoring/`(constants/factors/gates/engine/trade_plan) |
| 变更规则 | 任何下表参数变更 → bump 版本、在本文件追加变更记录(动机+证据),`forward_log.py` 记录会带上版本号,跨版本样本不混算 |
## 用途与定位
把日线价量数据映射为 0-100 排名分与「是/观察/否」结论的**交易纪律工具**。不是收益预言机:统计显著性不足(见下),从未在完整熊市周期做样本外验证。输出用于纪律对照与复盘,不构成投资建议,系统不下单。
## 冻结参数
### ALPHA 加权(排名分,缺失按可用权重重归一)
| 因子 | 权重 | 定义 |
|---|---|---|
| momentum | 55 | risk_adj_6m(6 月年化收益/年化波动,logistic x0=0.5, k=3.0)×0.6 + m12_1(12-1 月动量,logistic x0=5.0, k=0.08)×0.4 |
| rel_strength | 35 | 3m/6m 两视界各取 vs SPY/QQQ 较弱者,均值过 logistic(x0=0, k=0.12) |
| efficiency | 10 | Kaufman 效率比(30 日),裸值截断到 [0,1] |
### 风险否决层(只封顶不加分)
| 条件 | 封顶 |
|---|---|
| 价格 < 200 日线且 200 日线下行 | 55 |
| 价格 < 200 日线 | 70 |
| 周线均线空头排列 | 50 |
| 价格 < MA60 | 65 |
| 大盘 risk-off(SPY < 自身 MA200) | 65 |
### LLM 事件风控层(非对称,只降级)
致命红旗 → 封顶 50;软红旗/数据存疑 → 封顶 74;利好只回显不加分。
财报临近(Finnhub 日历,确定性):≤7 天 → medium(封顶 74,「是」降级);8–14 天 → low 仅提示。无 Finnhub key 时本层整体不生效。
### 确认层(不进加权,仅挡「是」)
technical ≥ 0.50、volume ≥ 0.40、trend_quality ≥ 0.40 全部满足才放行「是」;否则降为「观察(技术未确认)」。
### 入场时机层(不改 composite,只改新开仓结论)
- 过热:距 MA20 ≥3.0 ATR,或 RSI14≥80,或距 MA20≥2.2 ATR 且 RSI14≥75 → 原三层「是」降为「观察」。
- 回调拐点:近 30 日结构高点必须早于近 10 日低点,回撤 ≥4% 或近 5 日按同时点 MA20 确认触碰;当前完成日线为反转日且 ≥2 个触发信号。
- 拐点替代:仅在原三层因 technical/trend_quality 未确认而为「观察」时生效;volume 必须可计算且 ≥0.40,缺失不视为通过。
- 分类阈值均为手调,需由 v1.1 forward log 按 `entry_timing` 分桶积累样本外证据。
### 结论阈值与仓位
composite ≥75 且无否决且确认通过 → 是;≥75 → 观察;≥60 → 观察;<60 → 否。仓位 = min(20%/年化波动, 1) × composite/100。低流动性(20 日均成交额 < $5M)只 flag 不封顶。
## 校准证据摘要(全部为样本内,2020-07→2026-06;口径:破产票终值 -100%、五分位扣单边 10bp)
**最重要的结论(2026-07-09 扩篮子对照):排名分的 edge 是篮子条件的。**
同一套代码、唯一变量是篮子,63 日非重叠:
| 篮子 | 全样本 IC | risk-on IC | 行业中性 IC | 五分位毛价差 |
|---|---|---|---|---|
| 旧:45 手挑 + 8 退市 | +0.076(t≈1.57) | **+0.103(t≈2.01, CI[+0.007,+0.200])** | +0.085(t≈2.36, CI[+0.016,+0.155]) | +1.85% |
| 新:132 规则化 + 8 退市 | +0.013(t≈0.35, CI 跨 0) | +0.030(t≈0.70, CI 跨 0) | +0.015(CI 跨 0) | **−0.64%** |
诚实解读:横截面 edge 只在手挑大盘票池内可测得(该篮子按"今天著名"入选,带后见之明),无法推广到宽篮子。**composite 应作纪律工具与风险过滤器使用,不是经过验证的选股 alpha。**
- Regime 条件性(旧篮子内):risk-on 有效、risk-off 反转——否决层存在的依据;宽篮子上两个 regime 都无 edge。
- 确认层 A/B(旧篮子):21 日放行组 +1.01% 优于封顶组;63 日洗平(-0.05%)。保留,证据不足待前瞻验证。
- 已知不单调:60-75 桶(63 日 +6.19%)高于 ≥75 桶(+4.68%),高分≠高确定性。
- 21 日五分位净价差为负:本评分只服务 ≥3 个月持有周期。
## 已知局限
1. **排名 IC 在宽篮子上不可测得(见上表)**;否决层/确认层/事件层的风险过滤价值不依赖排名 IC,但"高分票更可能跑赢"这一主张目前没有可推广的证据。
2. 全部校准是样本内的;本 model card + `scripts/forward_log.py` 是样本外验证的起点。
3. 样本只含 2022 一个熊市,regime 结论本质 n=1。
4. 隐藏自由度:logistic 参数、确认阈值、封顶值、75/60 分界均无独立校准依据。
5. 全市场 scanner 的排名尤其只应作纪律过滤解读,不是 alpha 排序。
6. 入场时机阈值尚无独立样本外校准;v1.0 的 46 条历史记录不含时机字段,不得与 v1.1 时机 A/B 混算。
## 变更记录
- v1.1(2026-07-10):新增并冻结第四层入场时机语义;修正回调高低点时序、同时点 MA20 触碰与量能缺失放行。forward log 新增时机分类/介入前结论,股票与 SPY 统一从信号日后下一交易日收盘开始对账。v1.0 历史记录保留 legacy 口径并按版本隔离。
- v1.0(2026-07-09):首次冻结。含同日两项口径修正(破产终值、交易成本)、确认层 A/B 决策(保留,证据不足)、财报临近确定性软封顶(≤7 天 medium)。财报闸门与冻结同日落地、尚无跨版本样本,并入 v1.0;注意 `forward_log.py record` 走纯价量管道(无 Finnhub overlay),其 verdict 不受财报闸门影响。
- v1.0 证据更新(2026-07-09 同日,参数未变不 bump):扩篮子对照(132 规则化 vs 45 手挑)确认排名 edge 是篮子条件的,校准证据摘要与已知局限已重写;评分参数刻意不动(在噪声样本里重新拟合只会更糟),以 forward log 样本外证据为后续决策准绳。
+47
View File
@@ -222,6 +222,51 @@ Rules:
- Snapshot writes to `result.supplemental.snapshots` (summary) and each `symbol.supplemental.snapshot` (detail).
- `status=unavailable` is a valid non-blocking state; scoring continues without it.
## Artifact: ai_opinion
Purpose: optional display-only AI second opinion. Produced internally by `scripts/ai_opinion.py` (invoked from `indicators.py`/`scanner.py`); an offline artifact is only needed for replay/tests via `--ai-opinion-context-file`. It is never produced by a sub-agent and never feeds `score` — it writes to each symbol's `supplemental.ai_opinion` only.
```json
{
"contract_version": "worth-buy-stocks.agent.v1",
"kind": "ai_opinion",
"status": "ok",
"symbols": {
"AAPL": {
"status": "ok",
"stance": "buy",
"confidence": "medium",
"view": "趋势与相对强度均领先,量价确认充分",
"key_risks": ["财报临近"],
"agreement": "agree",
"model": "claude-opus-4-8",
"generated_at": "2026-07-08T00:00:00Z"
}
}
}
```
Rules:
- `stance` must be `buy`, `watch`, or `avoid`; `confidence` must be `low`, `medium`, or `high`; `agreement` must be `agree`, `diverge`, or `not_applicable`.
- A `status=ok` symbol entry requires `stance`, `confidence`, `view`, and `agreement`; a non-ok entry only requires `reason`.
- `agreement` is computed by the script (stance vs current `score.verdict` mapping), never self-reported by the model. Offline replay files are re-evaluated when attached; their stored `agreement` value is not trusted.
- The AI opinion has zero scoring power: it must never change `score.verdict`/`score.composite`, never cap, never downgrade, never upgrade.
- The evidence payload sent to the model must not contain `verdict`, `composite`, or `trade_plan` (anti-anchoring).
- `status=unavailable` (e.g. no `ANTHROPIC_API_KEY`, API timeout) is a valid non-blocking state; scoring continues without it.
- In explicit `--ai-opinion on` mode, every requested symbol must have `status=ok`; any missing/unavailable entry makes the command fail clearly. `auto` mode keeps per-symbol graceful degradation.
Unavailable shape:
```json
{
"contract_version": "worth-buy-stocks.agent.v1",
"kind": "ai_opinion",
"status": "unavailable",
"reason": "ANTHROPIC_API_KEY not configured"
}
```
## Artifact: bars
Purpose: provide reproducible offline market data for `--input`.
@@ -278,6 +323,7 @@ Required shape:
Rules:
- Main agent must not rewrite `score.verdict` or `score.composite`.
- `score.account_overlay` and `score.llm_overlay` are `null` when the corresponding overlay is disabled or offline; `null` is a valid state, not a contract violation.
- If `score` is missing for the target, return `无法评分`.
- Final prose may summarize, but must preserve `blocking_reasons`, account overlay availability, and news overlay availability.
@@ -291,6 +337,7 @@ python3 "$SKILL_DIR/scripts/validate_agent_contract.py" --kind account_context a
python3 "$SKILL_DIR/scripts/validate_agent_contract.py" --kind finnhub_context finnhub_context.json
python3 "$SKILL_DIR/scripts/validate_agent_contract.py" --kind bars bars.json
python3 "$SKILL_DIR/scripts/validate_agent_contract.py" --kind snapshot_context snapshot_context.json
python3 "$SKILL_DIR/scripts/validate_agent_contract.py" --kind ai_opinion ai_opinion.json
python3 "$SKILL_DIR/scripts/validate_agent_contract.py" --kind result result.json
```
+3
View File
@@ -48,6 +48,9 @@ alpaca position list --quiet
## 可选依赖
- Finnhub API key:补充 quote/news/profile/earnings。从本地 `.env` 的 `FINNHUB_API_KEY` 读取;无 key 自动跳过。
- AI 意见(Claude API):对评分结论生成对照展示用的 AI 第二意见,不参与评分。需要两项都满足才启用,缺任一项自动关闭、其余功能不受影响:
1. `pip install anthropic`(官方 SDK);
2. 本地 `.env` 配置 `ANTHROPIC_API_KEY`。
- Telegram 推送:从本地 `.env` 读取 `TELEGRAM_BOT_TOKEN` 和 `TELEGRAM_CHAT_ID`;仅在用户要求通知时需要。
## `.env` 位置
+40 -22
View File
@@ -1,6 +1,6 @@
# scripts/ 工程说明
`worth-buy-stocks` skill 的脚本层。纯 Python 标准库 + 本机 `alpaca` CLI,无第三方依赖。Finnhub 为可选 HTTP 补充数据源。
`worth-buy-stocks` skill 的脚本层。纯 Python 标准库 + 本机 `alpaca` CLI。Finnhub 为可选 HTTP 补充数据源;AI 意见 overlay 依赖可选的 `anthropic` SDK(`pip install anthropic`,未安装时自动降级)。
确定性计算(同输入同输出)、不下单、不打印凭证。面向接手者,非用户文档(用户文档见 `../SKILL.md`)。
---
@@ -13,7 +13,7 @@
2. **相对强度比绝对收益重要。** 一只股票涨了 20% 不说明任何问题——如果同期 SPY 涨了 25%,它是弱势股。我们的框架对标 SPY/QQQ,只选跑赢基准的标的。跑输大盘的股票,哪怕绝对收益为正,也不具备趋势领导力。
3. **数据诚实比模型复杂重要。** 回测暴露了一个残酷事实:在 2023–26 的单边牛市中,经过逐因子 IC 分析,只有**风险调整动量(IC≈0.10)**和**相对强度(IC≈0.06)**在 3 个月前瞻维度稳定为正。MACD、RSI、KDJ、量价关系——这些经典指标的 IC 趋近于零。我们**如实记录这个结论**,并据此刻意限制了它们在评分中的角色。
3. **数据诚实比模型复杂重要。** 回测暴露了一个残酷事实:在 2023–26 的单边牛市中,经过逐因子 IC 分析,只有**风险调整动量(IC≈0.10)**和**相对强度(IC≈0.06)**在 3 个月前瞻维度稳定为正。MACD、RSI、KDJ、量价关系——这些经典指标的 IC 趋近于零。我们**如实记录这个结论**,并据此刻意限制了它们在评分中的角色。2026-07-09 的扩篮子对照进一步暴露:这些正 IC **只在 45 只手挑大盘票池内可测得**,按规则扩到 132 只(11 行业)后全部衰减到统计无意义(95%CI 横跨 0,行业中性化亦然)——排名分是纪律工具,不是经过验证的选股 alpha(证据链见 `docs/model-card.md` 与 `scoring/__init__.py` 校准叙事)。
4. **纪律工具,不是盈利预言机。** 本评分的统计显著性不足(t≈1.2),从未在熊市验证。它不是"买入就能赚钱"的保证,而是把一套可复现的交易纪律编码为确定性规则——让每个决策都有据可查、事后可复盘。
@@ -47,6 +47,7 @@
fetching.py Alpaca 日线拉取(分页/去重/feed 回退),无内部依赖
local_env.py 本地 .env 解析(override → skill 根目录 → XDG 配置目录;凭证只读,不输出)
finnhub.py Finnhub quote/news/profile/earnings 可选补充上下文 + 保守新闻红旗提炼
ai_opinion.py ◄── agent_contracts, local_env AI 第二意见 overlay(Claude API,只展示不评分,无 key/SDK 自动降级)
portfolio.py Alpaca 账户/持仓只读读取与归一化(account/positions)
旁路工具(不在评分链路上)
@@ -55,9 +56,10 @@
研究/回测(离线,不属于 skill 运行时)
backtest_common.py ◄── fetching, analysis, metrics 篮子常量 + 取数面板 + 切片打分 + 统计原语
backtest_score.py ◄── backtest_common 分数分桶 / 截面 IC
backtest_score.py ◄── backtest_common 分数分桶 / 截面 IC / 确认层 A/B
backtest_factor_ic.py ◄── backtest_common 逐因子单因子 IC
backtest_robustness.py ◄── backtest_common 非重叠窗口 + regime 拆分 + 幸存者探针
backtest_robustness.py ◄── backtest_common 非重叠窗口 + regime/行业中性拆分 + bootstrap CI + 幸存者探针
forward_log.py ◄── backtest_common, fetching, pipeline 前瞻验证日志(record/evaluate,见 docs/model-card.md)
```
依赖方向单向向上,无环。`indicators.py` 仅做 CLI + 再导出(旧的 `import indicators as I` 仍可用)。
@@ -90,7 +92,7 @@
Artifact 契约见 `../references/agent-contracts.md`。新增或修改 sub-agent 输出形状时,先更新该 reference,再补 `agent_contracts.py` 和测试。合并前可运行:
```bash
# kind: news_context / account_context / finnhub_context / bars / snapshot_context / result
# kind: news_context / account_context / finnhub_context / bars / snapshot_context / ai_opinion / result
python3 scripts/validate_agent_contract.py --kind <kind> <artifact>.json
```
@@ -102,38 +104,30 @@ python3 scripts/validate_agent_contract.py --kind <kind> <artifact>.json
`scoring.py` 是本项目的核心——一个确定性的多因子打分引擎,设计目标贴近真实量化基金的规则化决策流程。
### 三层架构
### 四层架构
```
输入: analyze_symbol 的分析字典
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
ALPHA 加权层 风险否决层 确认 overlay
(排名分来源) (封顶/否决) (不做加分)
│ │ │
▼ ▼ ▼
momentum 55 MA200 下行 → 55 技术转弱时
rel_strength 35 MA60 跌破 → 65 "是"→"观察"
efficiency 10 周线空头 → 50
│ │ │
└────────────────────┼─────────────────────┘
▼
composite (0–100)
最终评分 + 结论
ALPHA 加权层 ──▶ 风险否决层 ──▶ 确认 overlay ──▶ 入场时机层
排名分来源 封顶/否决 技术量价确认 过热降级/拐点替代
momentum 55 MA200/MA60 只挡买入 不改 composite
rel_strength 35 周线/大盘
efficiency 10
```
### 因子分工的设计哲学
这个架构来自血泪教训。早期版本把 trend、technical、volume 和 momentum、rel_strength 等权混入排名分——结果六因子 composite 的 IC (0.082) **低于** momentum 单因子 (0.102)。弱因子不是在"分散风险",而是在**稀释信号**。
修正方案——把因子按 IC 分到三个功能层——是本项目最重要的架构决策:
修正方案——把因子按 IC 分工,并把入场质量独立成第四层——是本项目最重要的架构决策:
| 层 | 包含因子 | IC 水平 | 角色 |
|---|---------|--------|------|
| ALPHA 加权 | momentum 55, rel_strength 35, efficiency 10 | 0.05–0.09 | 决定排名分,进 composite |
| 风险否决 | trend (MA200/MA60/周线) | ~0.05 | 封顶分数,不进排名 |
| 确认 overlay | technical, volume, trend_quality 混合 | ≈0 | 二元确认,只降不升 |
| 入场时机 | 过热度、有序回调、完成日线反转信号 | 待 v1.1 前瞻验证 | 过热只降级;拐点在量能明确达标时可替代 technical/trend_quality |
**efficiency 为什么进了排名层?** momentum 与 rel_strength 截面相关 ≈0.84,实为 ~1.2 个因子;而**裸 Kaufman 效率比**(趋势干净度)与 momentum 近正交(相关 ≈0.08)。在早期 24 期牛市样本里它 IC≈0.063、加进 composite 把 63 日 IC 从 0.064 抬到 0.069;在扩样本(57 期、含 2022 熊市、降幸存者偏差)里 IC 回落到 ≈0.027 但仍为正,故维持小权重 10。注意:效率比单拎出来有效,但混进 trend_quality(含 ADX/回归/回撤)反而被稀释到 IC≈0,故只取 `efficiency_30` 单原语入排名。
@@ -145,6 +139,16 @@ python3 scripts/validate_agent_contract.py --kind <kind> <artifact>.json
> 这层 gate **改判结论但其实战价值需持续验证**:`backtest_score.py` 现含 confirmation gate A/B——在「composite≥75 且无否决」的买入候选里按 `confirmation.ok` 拆分前瞻收益。若被封顶(`ok=False`)组收益并不更差,说明 gate 在砍真买点而非过滤差买点,应据此调整。买入候选稀少、样本小,需长窗口累积看方向。
### 入场时机层(entry timing)
纯动量+相对强度的排名分天然偏好"已经涨了很久"的标的——按它直接开仓就是系统性追高。`metrics.entry_timing()` 在 ALPHA/否决/确认三层之外加了第四层**时机层**,只作用于 verdict/action/交易计划,**不改 composite**(保持 IC 校准的排名分可回测可比):
- **过热降级(非对称,只降不升)**:价格较 MA20 超出 ≥3.0 倍 ATR、或 RSI14≥80、或两者软组合(≥2.2 倍且 RSI≥75)→ 分类 `overextended`,评分达标的「是」降级为「观察」,交易计划给回踩计划价(不是回避)。
- **回调拐点替代确认**:趋势资格完好(价格在 MA60/MA200 上方附近)+ 30 日结构高点早于近 10 日低点 + 回撤 ≥4%(或近 5 日按同时点 MA20 确认触及)+ 最后一根已完成日线出现反转日(收阳且收在区间上半部/收复前一根高点)+ ≥2 个独立触发信号(放量 ≥1.2×、收复 MA10/20、KDJ 三日内金叉、MACD 柱上拐、RSI6 自弱位回升)→ 分类 `pullback_reversal`。此时 technical/trend_quality 确认由反转触发替代,但 volume 必须可计算且达标;缺失或偏弱均维持「观察」。
- 分类为 `pullback_no_trigger`(回调中未确认)/`trend_broken`(趋势破坏)时不升级不降级,走原有分支。
时机层的触发阈值(3.0/2.2 ATR、RSI 80/75、回撤 -4%、信号数 ≥2)为手调值,属于"已知校准缺口"清单的一部分;v1.1 forward log 记录时机分类与第四层介入前结论,按版本和分类做 A/B。注意:盘中运行时 `fetching.py` 会剔除今日未收盘的日线,当日盘中信息只进 snapshot 展示。
### 因子打分函数的设计
每个因子打分函数(`_f_momentum`, `_f_rel_strength` 等)采用 logistic squashing:
@@ -236,10 +240,19 @@ Finnhub 原始补充输出写入 `supplemental.finnhub`,不参与 `score.compo
- `earnings`:用于提示近期财报事件风险。
- `data_flags`:记录 rate limit、unauthorized、空数据等补充数据可信度问题。
自动提炼规则只做降级候选,不做加分:明确负面关键词(如增发摊薄、退市/停牌、going concern、调查、诉讼、财报重述、重大运营风险)会生成 `medium/high` 红旗;7 天内财报只生成 `severity=low` 事件提醒;正面新闻、分析师观点和目标价被忽略。`indicators.py` 会把 Finnhub 生成的上下文和 `--llm-context-file` 合并,手工上下文的标量字段优先,来源与红旗追加。
自动提炼规则只做降级候选,不做加分:明确负面关键词(如增发摊薄、退市/停牌、going concern、调查、诉讼、财报重述、重大运营风险)会生成 `medium/high` 红旗;临近财报是确定性事件风险——≤7 天生成 `severity=medium`(经 LLM overlay 封顶 74,`是` 降级为 `观察`;财报跳空是趋势前提最常见的确定性破坏源),8–14 天生成 `severity=low` 仅提醒;正面新闻、分析师观点和目标价被忽略。`indicators.py` 会把 Finnhub 生成的上下文和 `--llm-context-file` 合并,手工上下文的标量字段优先,来源与红旗追加。
利好新闻、分析师观点、目标价和基本面不得加分;只有可验证红旗能通过 `llm_context` 触发现有降级/封顶。
### AI 意见补充上下文
`ai_opinion.py` 在评分完成后调用 Claude API(`claude-opus-4-8` + structured outputs),对每个非基准标的给出独立 stance(buy/watch/avoid)、一句话理由与主要风险,写入 `supplemental.ai_opinion`。设计文档:`../docs/ai-opinion-overlay-design.md`。
- **零评分权力**:不改 `score.verdict`/`composite`,不封顶、不降级、不升级;权力级别低于新闻和账户 overlay。
- **防锚定**:证据载荷按白名单(`EVIDENCE_KEYS`)从分析结果拷贝,verdict/composite/trade_plan 结构上进不去;`agree/diverge` 由脚本比较得出,不由模型自述。
- **降级**:API key 从本地 `.env` 的 `ANTHROPIC_API_KEY` 读取(查找链与 Finnhub 相同);无 key、未安装 `anthropic` SDK、API 失败/超时均返回 `status=unavailable`,评分照常。
- **verdict=无法评分 的标的不触网**,直接标记不可用。
### 设计承诺
- 改打分逻辑:只动 `scoring.py` 的 `ALPHA_WEIGHTS` / 各 `_f_*` 断点 / `_gates()` 封顶规则。`score()` 与三个回测脚本自动跟随。
@@ -320,6 +333,7 @@ Spearman 秩相关不对收益的分布形态做假设。单只股票 3 个月
| `notify_telegram.py` | 把文本推到 Telegram,读环境变量,未配置则提示 | `python3 notify_telegram.py` |
| `backtest_common.py` | 回测公共件:`UNIVERSE`/`Panel`/`spearman`/`quintile_spread` 等 | 库 |
| `backtest_*.py` | 三类因子有效性回测 | `python3 backtest_xxx.py` |
| `forward_log.py` | 前瞻验证日志:`record` 把当日评分固化到 `data/forward_log.jsonl`(同日重跑幂等、历史只追加),`evaluate` 对 ≥63 交易日的到期记录按 verdict 分桶对账(SPY 超额)。参数冻结见 `docs/model-card.md`,跨 `model_version` 不混算 | `python3 forward_log.py record` |
---
@@ -373,6 +387,10 @@ python3 scripts/backtest_score.py # 分桶 + 截面 IC
python3 scripts/backtest_factor_ic.py # 逐因子 IC
python3 scripts/backtest_robustness.py # 非重叠 + regime + 幸存者探针
# 样本外日志(v1.1 从信号日后下一交易日统一取股票/SPY 收盘)
python3 scripts/forward_log.py record
python3 scripts/forward_log.py evaluate # 按 verdict 与 entry timing 分桶
# 单元测试(纯计算 + 离线路径,不触网)
python3 -m pytest tests/
```
+48 -2
View File
@@ -17,8 +17,9 @@ KIND_FINNHUB = "finnhub_context"
KIND_BARS = "bars"
KIND_SNAPSHOT = "snapshot_context"
KIND_RESULT = "result"
KIND_AI_OPINION = "ai_opinion"
KINDS = (KIND_NEWS, KIND_ACCOUNT, KIND_FINNHUB, KIND_BARS, KIND_SNAPSHOT, KIND_RESULT)
KINDS = (KIND_NEWS, KIND_ACCOUNT, KIND_FINNHUB, KIND_BARS, KIND_SNAPSHOT, KIND_RESULT, KIND_AI_OPINION)
UNAVAILABLE_STATUSES = {"unavailable", "unauthorized", "rate_limited"}
META_KEYS = {
"contract_version", "kind", "status", "reason", "as_of", "provider",
@@ -199,7 +200,9 @@ def validate_result(payload: Any) -> dict[str, Any]:
_expect("composite" in score, f"{sp}.score.composite", "composite is required")
_expect(isinstance(score.get("blocking_reasons"), list), f"{sp}.score.blocking_reasons", "blocking_reasons must be a list")
_expect(_is_obj(score.get("trade_plan")), f"{sp}.score.trade_plan", "trade_plan must be an object")
_expect(_is_obj(score.get("account_overlay")), f"{sp}.score.account_overlay", "account_overlay must be an object")
# account overlay 关闭/离线时 scoring 合法地输出 null(与 llm_overlay 惯例一致)
account_overlay = score.get("account_overlay")
_expect(account_overlay is None or _is_obj(account_overlay), f"{sp}.score.account_overlay", "account_overlay must be an object or null")
scored += 1
return {"symbols_count": len(symbols), "scored_symbols_count": scored}
@@ -226,6 +229,48 @@ def validate_snapshot(payload: Any) -> dict[str, Any]:
return {"symbols_count": len(symbols)}
AI_OPINION_STANCES = {"buy", "watch", "avoid"}
AI_OPINION_CONFIDENCES = {"low", "medium", "high"}
AI_OPINION_AGREEMENTS = {"agree", "diverge", "not_applicable"}
def validate_ai_opinion(payload: Any) -> dict[str, Any]:
_expect(_is_obj(payload), "$", "ai_opinion must be an object")
status = _status(payload, "$")
if status != "ok":
return {"symbols_count": 0}
symbols = payload.get("symbols")
_expect(isinstance(symbols, dict), "$.symbols", "symbols must be an object")
opined = 0
for symbol, ctx in symbols.items():
sp = f"$.symbols.{symbol}"
_validate_symbol_key(str(symbol), sp)
_expect(_is_obj(ctx), sp, "symbol opinion must be an object")
ctx_status = _status(ctx, sp)
if ctx_status != "ok":
continue
_expect(
str(ctx.get("stance") or "").lower() in AI_OPINION_STANCES,
f"{sp}.stance",
"stance must be buy, watch, or avoid",
)
_expect(
str(ctx.get("confidence") or "").lower() in AI_OPINION_CONFIDENCES,
f"{sp}.confidence",
"confidence must be low, medium, or high",
)
_expect(bool(str(ctx.get("view") or "").strip()), f"{sp}.view", "view is required")
_expect(
str(ctx.get("agreement") or "").lower() in AI_OPINION_AGREEMENTS,
f"{sp}.agreement",
"agreement must be agree, diverge, or not_applicable",
)
if ctx.get("key_risks") is not None:
_expect(isinstance(ctx["key_risks"], list), f"{sp}.key_risks", "key_risks must be a list")
opined += 1
return {"symbols_count": len(symbols), "opined_symbols_count": opined}
VALIDATORS = {
KIND_NEWS: validate_news_context,
KIND_ACCOUNT: validate_account_context,
@@ -233,6 +278,7 @@ VALIDATORS = {
KIND_BARS: validate_bars,
KIND_SNAPSHOT: validate_snapshot,
KIND_RESULT: validate_result,
KIND_AI_OPINION: validate_ai_opinion,
}
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env python3
"""Display-only AI second opinion overlay (the fifth overlay).
Sends the already-computed evidence (indicators, relative strength, structure,
news red flags) to Claude and asks for an independent stance. The script — not
the model — compares that stance against the script verdict to produce an
``agree``/``diverge`` marker.
Hard boundaries (see docs/ai-opinion-overlay-design.md):
- Zero scoring power: output goes to ``supplemental.ai_opinion`` only and must
never change ``score.verdict``/``score.composite``, cap, or downgrade.
- Anti-anchoring: the evidence payload is built from an explicit whitelist so
the model never sees verdict, composite, or trade plan.
- Graceful degradation: no API key, missing SDK, or any API failure returns a
``status=unavailable`` artifact and never blocks scoring.
"""
from __future__ import annotations
import datetime
import json
import sys
from typing import Any
from agent_contracts import CONTRACT_VERSION, KIND_AI_OPINION, ContractError, validate_payload
from local_env import get_env
try: # anthropic SDK 是可选依赖:未安装时整体降级为 unavailable,不影响评分
import anthropic as _anthropic
except ImportError: # pragma: no cover - 取决于运行环境
_anthropic = None
API_KEY_ENV = "ANTHROPIC_API_KEY"
MODEL = "claude-opus-4-8"
REQUEST_TIMEOUT_SECONDS = 60.0
MAX_TOKENS = 8192
BENCHMARK_SYMBOLS = {"SPY", "QQQ"}
# 证据白名单:只从单票分析结果拷贝这些键。verdict/composite/trade_plan 等含结论
# 倾向的字段不在名单内,从结构上保证防锚定(另有测试断言保护)。
EVIDENCE_KEYS = (
"last_close", "last_date", "bars_count",
"ma", "weekly", "structure_30d",
"returns_pct", "relative_strength_pct",
"momentum", "volatility", "risk", "trend_quality",
"macd", "rsi", "kdj", "volume",
)
# 脚本 verdict → 等价 AI stance;用于 agreement 计算(由脚本算,不由模型自述)
_VERDICT_TO_STANCE = {
"是": "buy",
"观察": "watch",
"否": "avoid",
"持仓需减风险": "avoid",
}
_STANCE_ZH = {"buy": "买入", "watch": "观察", "avoid": "回避"}
_RESPONSE_SCHEMA = {
"type": "object",
"properties": {
"stance": {"type": "string", "enum": ["buy", "watch", "avoid"]},
"confidence": {"type": "string", "enum": ["low", "medium", "high"]},
"view": {"type": "string", "description": "中文一句话核心理由,不超过 60 字"},
"key_risks": {
"type": "array",
"items": {"type": "string"},
"description": "0-3 条主要风险,中文短语",
},
},
"required": ["stance", "confidence", "view", "key_risks"],
"additionalProperties": False,
}
_SYSTEM_PROMPT = """你是一名独立的美股技术分析顾问,基于趋势跟随与相对强度纪律框架给出第二意见。
规则:
- 只依据用户提供的结构化证据判断,不引入证据之外的公司知识、新闻记忆或行情记忆。
- 框架取向:只认可趋势延续与相对强度领先;弱势修复、均值回归反弹和估值叙事不构成买入理由。
- stance 含义:buy=值得新开仓;watch=观察等待更好的入场确认;avoid=回避或不参与。
- view 用中文一句话(不超过 60 字)给出最关键依据;key_risks 列出 0-3 条主要风险。
- 这是纪律对照参考,不是投资建议;不要输出免责声明或客套话。"""
def has_api_key() -> bool:
return bool(get_env(API_KEY_ENV).strip())
def sdk_available() -> bool:
return _anthropic is not None
def build_evidence(
symbol_analysis: dict[str, Any],
market_risk_off: Any = None,
news_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Whitelist-copy evidence for the model; conclusion-bearing fields never enter."""
evidence = {k: symbol_analysis[k] for k in EVIDENCE_KEYS if k in symbol_analysis}
evidence["market_risk_off"] = bool(market_risk_off) if market_risk_off is not None else None
if isinstance(news_context, dict):
news: dict[str, Any] = {}
if news_context.get("red_flags"):
news["red_flags"] = news_context["red_flags"]
if news_context.get("catalyst"):
news["catalyst"] = news_context["catalyst"]
if news:
evidence["news"] = news
# data_flags(历史不足/低流动性等)是事实性提示,不含结论,允许进入证据
data_flags = (symbol_analysis.get("score") or {}).get("data_flags")
if data_flags:
evidence["data_flags"] = data_flags
return evidence
def compute_agreement(verdict: Any, stance: str) -> str:
expected = _VERDICT_TO_STANCE.get(str(verdict or "").strip())
if expected is None:
return "not_applicable"
return "agree" if expected == stance else "diverge"
def stance_zh(stance: str) -> str:
return _STANCE_ZH.get(str(stance or "").strip(), str(stance or ""))
def unavailable_opinions(
artifact: dict[str, Any] | None,
symbols: list[str] | None = None,
) -> dict[str, str]:
"""Return requested symbols without a usable opinion.
Top-level ``status=ok`` means the batch contract itself is usable; individual
entries may still have failed. Explicit ``--ai-opinion on`` callers use this
helper to enforce all-requested-symbol success instead of checking only the
top-level status.
"""
if not isinstance(artifact, dict):
return {"*": "AI 意见 artifact 缺失"}
if artifact.get("status") != "ok":
return {"*": str(artifact.get("reason") or "AI 意见整体不可用")}
entries = artifact.get("symbols") if isinstance(artifact.get("symbols"), dict) else {}
wanted = [str(s).strip().upper() for s in symbols] if symbols is not None else list(entries)
failures: dict[str, str] = {}
for sym in wanted:
entry = entries.get(sym)
if not isinstance(entry, dict) or entry.get("status") != "ok":
reason = entry.get("reason") if isinstance(entry, dict) else "AI 意见条目缺失"
failures[sym] = str(reason or "AI 意见不可用")
return failures
def _unavailable(reason: str) -> dict[str, Any]:
return {
"contract_version": CONTRACT_VERSION,
"kind": KIND_AI_OPINION,
"status": "unavailable",
"reason": reason,
}
def _call_model(symbol: str, evidence: dict[str, Any]) -> dict[str, Any]:
"""One structured-output API call; raises on any failure (caller degrades)."""
client = _anthropic.Anthropic(
api_key=get_env(API_KEY_ENV),
timeout=REQUEST_TIMEOUT_SECONDS,
max_retries=1,
)
response = client.messages.create(
model=MODEL,
max_tokens=MAX_TOKENS,
thinking={"type": "adaptive"},
output_config={
"effort": "medium",
"format": {"type": "json_schema", "schema": _RESPONSE_SCHEMA},
},
system=_SYSTEM_PROMPT,
messages=[{
"role": "user",
"content": (
f"请根据以下结构化证据,对 {symbol} 给出独立判断:\n"
+ json.dumps(evidence, ensure_ascii=False)
),
}],
)
if getattr(response, "stop_reason", None) == "refusal":
raise RuntimeError("model refused the request")
text = next(
(block.text for block in response.content if getattr(block, "type", "") == "text"),
None,
)
if not text:
raise RuntimeError("no text block in model response")
return json.loads(text)
def _opinion_entry(symbol: str, analysis: dict[str, Any], market_risk_off: Any,
news_context: dict[str, Any] | None) -> dict[str, Any]:
verdict = (analysis.get("score") or {}).get("verdict")
parsed = _call_model(symbol, build_evidence(analysis, market_risk_off, news_context))
stance = str(parsed.get("stance") or "").lower()
confidence = str(parsed.get("confidence") or "").lower()
if stance not in _VERDICT_TO_STANCE.values() or confidence not in {"low", "medium", "high"}:
raise RuntimeError(f"model returned invalid stance/confidence: {stance}/{confidence}")
return {
"status": "ok",
"stance": stance,
"confidence": confidence,
"view": str(parsed.get("view") or "").strip(),
"key_risks": [str(r) for r in (parsed.get("key_risks") or [])][:3],
"agreement": compute_agreement(verdict, stance),
"model": MODEL,
"generated_at": datetime.datetime.now(datetime.timezone.utc)
.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
}
def opinions_for_result(
result: dict[str, Any],
llm_context: dict[str, dict[str, Any]] | None = None,
symbols: list[str] | None = None,
) -> dict[str, Any]:
"""Build an ai_opinion contract for the scored, non-benchmark symbols of a result.
Never raises: every failure mode degrades to top-level or per-symbol
``status=unavailable``. The returned artifact is self-checked against the
contract before being handed back.
"""
if not sdk_available():
return _unavailable("anthropic SDK 未安装(pip install anthropic)")
if not has_api_key():
return _unavailable(f"{API_KEY_ENV} 未配置")
llm_context = llm_context or {}
wanted = {s.strip().upper() for s in symbols} if symbols else None
entries: dict[str, Any] = {}
for sym, analysis in (result.get("symbols") or {}).items():
sym = str(sym).strip().upper()
if sym in BENCHMARK_SYMBOLS or (wanted is not None and sym not in wanted):
continue
if not isinstance(analysis, dict) or "error" in analysis:
entries[sym] = {"status": "unavailable", "reason": "该标的无分析结果"}
continue
verdict = str((analysis.get("score") or {}).get("verdict") or "").strip()
if verdict not in _VERDICT_TO_STANCE:
# 无法评分(或缺 score):约定不触网,直接标记不可用(设计文档 4.5)
entries[sym] = {"status": "unavailable", "reason": "脚本无法评分,未调用 AI"}
continue
try:
entries[sym] = _opinion_entry(sym, analysis, result.get("market_risk_off"), llm_context.get(sym))
except Exception as e: # 单票失败只降级该票,不影响其他票与评分
entries[sym] = {"status": "unavailable", "reason": f"AI 调用失败:{type(e).__name__}"}
artifact = {
"contract_version": CONTRACT_VERSION,
"kind": KIND_AI_OPINION,
"status": "ok",
"symbols": entries,
}
try:
validate_payload(KIND_AI_OPINION, artifact)
except ContractError as e: # 自检兜底:不合契约宁可整体不可用,也不外泄坏数据
return _unavailable(f"ai_opinion 自检失败:{e}")
return artifact
def main(argv: list[str] | None = None) -> int:
"""CLI: read a result JSON from stdin, print the ai_opinion artifact."""
import argparse
parser = argparse.ArgumentParser(description="Generate display-only AI opinions for a result JSON")
parser.add_argument("--llm-context-file", help="optional news_context JSON for evidence enrichment")
parser.add_argument("--symbols", help="comma-separated symbol filter")
args = parser.parse_args(argv)
result = json.load(sys.stdin)
llm_context = None
if args.llm_context_file:
with open(args.llm_context_file, encoding="utf-8") as f:
raw = json.load(f)
llm_context = raw.get("symbols") if isinstance(raw.get("symbols"), dict) else raw
symbols = [s for s in (args.symbols or "").split(",") if s.strip()] or None
artifact = opinions_for_result(result, llm_context=llm_context, symbols=symbols)
json.dump(artifact, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4 -3
View File
@@ -15,9 +15,9 @@ import sys
from typing import Any
from metrics import (
adx, annualized_return, annualized_vol, atr, efficiency_ratio, kdj, ma, macd,
max_drawdown, momentum_12_1, obv, pct_return, rsi, to_weekly, trend_regression,
up_day_volume_ratio, volume_ratio_ma, volume_trend_direction,
adx, annualized_return, annualized_vol, atr, efficiency_ratio, entry_timing, kdj,
ma, macd, max_drawdown, momentum_12_1, obv, pct_return, rsi, to_weekly,
trend_regression, up_day_volume_ratio, volume_ratio_ma, volume_trend_direction,
)
# 交易日窗口(约数):1/3/6 个月、52 周
@@ -126,6 +126,7 @@ def analyze_symbol(bars: list[dict[str, Any]]) -> dict[str, Any]:
# 高点回看实际根数;<252 说明历史不足一年,52w 口径名不副实,需谨慎解读
"high_lookback_bars": high_52w_bars,
},
"entry_timing": entry_timing(closes, highs, lows, vols),
"macd": macd(closes),
"rsi": {"RSI14": rsi(closes, 14), "RSI6": rsi(closes, 6)},
"kdj": kdj(highs, lows, closes),
+109 -12
View File
@@ -22,20 +22,70 @@ from pipeline import build_result # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
# 今日仍在交易的大盘流动性票
ALIVE = [
"AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "AVGO", "AMD", "NFLX",
"ADBE", "CRM", "ORCL", "CSCO", "QCOM", "TXN", "INTC", "AMAT", "MU",
"JPM", "BAC", "WFC", "GS", "V", "MA",
"UNH", "JNJ", "LLY", "ABBV", "MRK", "PFE",
"WMT", "COST", "HD", "MCD", "NKE", "SBUX",
"XOM", "CVX", "CAT", "BA", "GE", "PG", "KO", "PEP", "DIS",
]
# 规则化篮子(2026-07-09 由 45 只手挑名单扩至 132 只)。选票规则(可复述,非手挑品味):
# 2021 年前上市、今日仍在交易的美股大盘流动性票,按 GICS 11 个行业分组、每行业 ≥7 只、
# 行业内按大致市值/流动性取头部。目的:降低「今天著名的名字」的选择偏差、给行业中性
# 检验提供标签。仍非时点成分库(缺 CRSP 类数据),幸存者缓解依赖 DELISTED/BANKRUPT。
SECTOR = {
# Information Technology
"AAPL": "tech", "MSFT": "tech", "NVDA": "tech", "AVGO": "tech", "AMD": "tech",
"ADBE": "tech", "CRM": "tech", "ORCL": "tech", "CSCO": "tech", "QCOM": "tech",
"TXN": "tech", "INTC": "tech", "AMAT": "tech", "MU": "tech", "NOW": "tech",
"INTU": "tech", "IBM": "tech", "ACN": "tech", "LRCX": "tech", "KLAC": "tech",
# Communication Services
"GOOGL": "comm", "META": "comm", "NFLX": "comm", "DIS": "comm", "CMCSA": "comm",
"TMUS": "comm", "VZ": "comm", "T": "comm", "EA": "comm", "TTWO": "comm",
# Consumer Discretionary
"AMZN": "cons_disc", "TSLA": "cons_disc", "HD": "cons_disc", "MCD": "cons_disc",
"NKE": "cons_disc", "SBUX": "cons_disc", "LOW": "cons_disc", "TJX": "cons_disc",
"BKNG": "cons_disc", "MAR": "cons_disc", "GM": "cons_disc", "F": "cons_disc",
"ORLY": "cons_disc", "CMG": "cons_disc",
# Consumer Staples
"WMT": "cons_staples", "COST": "cons_staples", "PG": "cons_staples", "KO": "cons_staples",
"PEP": "cons_staples", "PM": "cons_staples", "MO": "cons_staples", "MDLZ": "cons_staples",
"CL": "cons_staples", "KMB": "cons_staples",
# Health Care
"UNH": "health", "JNJ": "health", "LLY": "health", "ABBV": "health", "MRK": "health",
"PFE": "health", "TMO": "health", "ABT": "health", "DHR": "health", "BMY": "health",
"AMGN": "health", "GILD": "health", "ISRG": "health", "VRTX": "health",
"REGN": "health", "CVS": "health",
# Financials(V/MA 按 2023 GICS 重分类归金融)
"JPM": "fin", "BAC": "fin", "WFC": "fin", "GS": "fin", "MS": "fin", "C": "fin",
"BLK": "fin", "SCHW": "fin", "AXP": "fin", "V": "fin", "MA": "fin", "SPGI": "fin",
"CME": "fin", "ICE": "fin", "COF": "fin", "PGR": "fin",
# Industrials
"CAT": "industrials", "BA": "industrials", "GE": "industrials", "HON": "industrials",
"UPS": "industrials", "RTX": "industrials", "LMT": "industrials", "DE": "industrials",
"UNP": "industrials", "CSX": "industrials", "FDX": "industrials", "ETN": "industrials",
"EMR": "industrials", "ITW": "industrials",
# Energy
"XOM": "energy", "CVX": "energy", "COP": "energy", "SLB": "energy", "EOG": "energy",
"MPC": "energy", "PSX": "energy", "VLO": "energy", "OXY": "energy", "KMI": "energy",
# Materials
"LIN": "materials", "APD": "materials", "SHW": "materials", "FCX": "materials",
"NEM": "materials", "ECL": "materials", "NUE": "materials", "DOW": "materials",
# Utilities
"NEE": "utilities", "DUK": "utilities", "SO": "utilities", "D": "utilities",
"AEP": "utilities", "EXC": "utilities", "SRE": "utilities",
# Real Estate
"PLD": "real_estate", "AMT": "real_estate", "EQIX": "real_estate", "CCI": "real_estate",
"SPG": "real_estate", "O": "real_estate", "PSA": "real_estate",
}
ALIVE = list(SECTOR)
# 2021–24 间离开市场、但有死前历史的大盘票(降幸存者偏差)。
# 真暴雷(跑高后归零,最关键):SIVB(SVB,2023-03)、FRC(First Republic,2023-04)。
# 被并购(溢价退出):ATVI(MSFT)、TWTR(Musk)、PXD(XOM)、SPLK(Cisco)、VMW(Broadcom)、ABMD(JNJ)。
# 排除 CREE(更名 WOLF,仅 87 根)与 SBNY(末根 2025-03 与其 2023 倒闭不符,数据存疑)。
DELISTED = ["SIVB", "FRC", "ATVI", "TWTR", "PXD", "SPLK", "VMW", "ABMD"]
# 终值口径分两类:破产票停牌后按 -100% 计(最后成交价远高于清零后的真实终值,
# 用最后价会把 SIVB 的 -100% 记成 -60% 量级、低估模型崩盘前给高分的代价);
# 被并购票维持「最后可得收盘价」(溢价退出,最后价即真实终值)。
BANKRUPT = {"SIVB", "FRC"}
# 退市票的行业标签(供行业中性检验;ATVI/TWTR 按 GICS 归 Communication Services)
SECTOR.update({
"SIVB": "fin", "FRC": "fin", "ATVI": "comm", "TWTR": "comm",
"PXD": "energy", "SPLK": "tech", "VMW": "tech", "ABMD": "health",
})
UNIVERSE = ALIVE + DELISTED
BENCH = ["SPY", "QQQ"]
WARMUP = 230 # 起评所需的最少历史根数(>200 让 MA200 可算)
@@ -83,6 +133,10 @@ class Panel:
c0 = self.close(sym, entry_date)
c1 = self.close(sym, target_date)
if c1 is None:
dates = self.dates_by_symbol.get(sym, [])
if sym in BANKRUPT and dates and dates[-1] < target_date:
# 破产退市:股权清零,按 -100% 计(见 BANKRUPT 注释)
return -100.0 if c0 else None
# Delisted/merged names often stop before the benchmark target date.
# Use the last available post-entry close so the observation remains
# in the panel instead of silently reintroducing survivorship bias.
@@ -149,18 +203,61 @@ def mean_t(values):
return m, t, n
def bootstrap_ci(values, n_boot=2000, seed=42, alpha=0.05):
"""对均值做百分位 bootstrap,返回 (lo, hi) 置信区间;n<3 返回 None。
固定 seed 保证确定性(同输入同输出)。非重叠 IC 序列近独立,普通
bootstrap 足够;不做 block bootstrap。
"""
import random
n = len(values)
if n < 3:
return None
rng = random.Random(seed)
means = sorted(
sum(values[rng.randrange(n)] for _ in range(n)) / n
for _ in range(n_boot)
)
lo_idx = int(alpha / 2 * n_boot)
hi_idx = int((1 - alpha / 2) * n_boot) - 1
return means[lo_idx], means[hi_idx]
def sector_demean(triples):
"""[(score, fwd, sector)] → [(score, fwd − 同期同行业均值)]。
用于行业中性 IC:把「买对行业」的收益成分去掉,只留行业内的个股区分度。
行业在当期只有 1 只票时剔除该票(无行业内对照,去均值后恒为 0 会稀释信号)。
"""
by_sector = {}
for _, f, sec in triples:
by_sector.setdefault(sec, []).append(f)
means = {sec: sum(fs) / len(fs) for sec, fs in by_sector.items()}
counts = {sec: len(fs) for sec, fs in by_sector.items()}
return [(s, f - means[sec]) for s, f, sec in triples if counts[sec] > 1]
def bucket_mean(pairs, lo, hi):
"""pairs:[(score,fwd)],取 score∈[lo,hi) 的前瞻收益均值与个数。"""
sel = [f for s, f in pairs if lo <= s < hi]
return (sum(sel) / len(sel), len(sel)) if sel else (None, 0)
def quintile_spread(pairs):
"""最高五分位均值 − 最低五分位均值;返回 (spread, top, bot),样本<10 返回 None。"""
COST_BPS_ONEWAY = 10.0 # 大盘股保守单边成本(佣金+滑点),用于净价差口径
def quintile_spread(pairs, cost_bps_oneway=COST_BPS_ONEWAY):
"""最高五分位均值 − 最低五分位均值;返回 (spread_gross, spread_net, top, bot)。
净价差 = 毛价差 − 4×单边成本:多空两腿、每腿每期一买一卖共 4 次单边。
样本 <10 返回 None。
"""
if len(pairs) < 10:
return None
ss = sorted(pairs, key=lambda x: x[0])
q = len(ss) // 5
bot = sum(f for _, f in ss[:q]) / q
top = sum(f for _, f in ss[-q:]) / q
return top - bot, top, bot
gross = top - bot
net = gross - 4 * cost_bps_oneway / 100.0
return gross, net, top, bot
+22 -12
View File
@@ -18,7 +18,8 @@ sys.path.insert(0, os.path.dirname(__file__))
from fetching import _fetch_feed, FeedLimitError # noqa: E402
from metrics import ma # noqa: E402
from backtest_common import ( # noqa: E402
UNIVERSE, DELISTED, WARMUP, HISTORY_DAYS, load_panel, spearman, mean_t, quintile_spread,
UNIVERSE, DELISTED, SECTOR, WARMUP, HISTORY_DAYS,
load_panel, spearman, mean_t, quintile_spread, bootstrap_ci, sector_demean,
)
# 仍未纳入篮子的死亡名字(残余幸存者缺口):SBNY 数据存疑、CREE 更名 WOLF,
@@ -39,9 +40,11 @@ def _ic_line(label, pairs, ic_list):
print(f" {label:16} 无样本")
return
qs = quintile_spread(pairs)
spread = f"{qs[0]:+.2f}%" if qs else "n/a"
spread = f"毛{qs[0]:+.2f}%/净{qs[1]:+.2f}%" if qs else "n/a"
st = mean_t(ic_list)
icstr = f"IC {st[0]:+.3f} t≈{st[1]:+.2f} ({st[2]}期独立)" if st else "IC n/a"
ci = bootstrap_ci(ic_list)
cistr = f" 95%CI[{ci[0]:+.3f},{ci[1]:+.3f}]" if ci else ""
icstr = f"IC {st[0]:+.3f} t≈{st[1]:+.2f}{cistr} ({st[2]}期独立)" if st else "IC n/a"
print(f" {label:16} N={len(pairs):<5} 五分位多空 {spread:<9} {icstr}")
@@ -55,29 +58,36 @@ def run(feed, adjustment, timeout):
for H in (21, 63):
rebal_idx = list(range(WARMUP, len(panel.calendar) - H, H)) # 非重叠:步长=前瞻期
groups = {"all": ([], []), "on": ([], []), "off": ([], [])} # (pairs, ic_list)
# 行业中性平行组:前瞻收益按同期同行业去均值(检验 alpha 是否只是行业赌注)
sn_groups = {"all": ([], []), "on": ([], []), "off": ([], [])}
for ti in rebal_idx:
tdate = panel.calendar[ti]
symbols = panel.scores_at(tdate)
ron = regime.get(tdate)
gkey = "on" if ron else "off"
ds, df = [], []
triples = []
for s in UNIVERSE:
comp = (symbols.get(s, {}).get("score") or {}).get("composite")
fwd = panel.fwd_return_pct(s, ti, H)
if comp is None or fwd is None:
continue
groups["all"][0].append((comp, fwd))
groups[gkey][0].append((comp, fwd))
ds.append(comp)
df.append(fwd)
ic = spearman(ds, df)
if ic is not None:
groups["all"][1].append(ic)
groups[gkey][1].append(ic)
triples.append((comp, fwd, SECTOR.get(s, "unknown")))
pairs = [(c, f) for c, f, _ in triples]
sn_pairs = sector_demean(triples)
for gs, ps in ((groups, pairs), (sn_groups, sn_pairs)):
gs["all"][0].extend(ps)
gs[gkey][0].extend(ps)
ic = spearman([p[0] for p in ps], [p[1] for p in ps])
if ic is not None:
gs["all"][1].append(ic)
gs[gkey][1].append(ic)
print(f"\n── 前瞻 {H} 交易日(独立 rebalance {len(rebal_idx)} 期)──")
_ic_line("全样本", *groups["all"])
_ic_line("全样本·行业中性", *sn_groups["all"])
_ic_line("risk-on", *groups["on"])
_ic_line("risk-on·行业中性", *sn_groups["on"])
_ic_line("risk-off", *groups["off"])
_ic_line("risk-off·行业中性", *sn_groups["off"])
_survivorship_probe(feed, adjustment, timeout)
print("\n注:非重叠后 63 日独立样本很少(~7 期),显著性必然弱;regime 看 edge 是否在 "
+3 -2
View File
@@ -76,8 +76,9 @@ def _report(obs, ic_by_date):
else f" {label:14} 无样本")
qs = quintile_spread(pairs)
if qs:
spread, top, bot = qs
print(f" 五分位: 最高分组 {top:+.2f}% 最低分组 {bot:+.2f}% 多空价差 {spread:+.2f}%")
gross, net, top, bot = qs
print(f" 五分位: 最高分组 {top:+.2f}% 最低分组 {bot:+.2f}% "
f"多空价差 毛 {gross:+.2f}% / 净 {net:+.2f}%(扣单边10bp×4)")
st = mean_t(ic_by_date[h])
if st:
mic, t, ndays = st
+40 -5
View File
@@ -59,19 +59,54 @@ def _fetch_feed(symbols, start, end, feed, adjustment, limit, timeout):
return {sym: [bymt[t] for t in sorted(bymt)] for sym, bymt in out.items()}
def fetch_bars(symbols, start, end, feed, adjustment, limit, timeout):
"""拉取日线;sip 等 feed 受限时自动回退 iex 一次。
def _drop_unclosed_today(bars_map):
"""盘中运行时剔除今天的未完成日线;收盘后原样保留。
返回 (bars_dict, used_feed, note)。note 为回退说明或 None。
盘中的当日 bar 只含开盘以来的部分量价,会污染 RSI/量能比/反转日检测等
末根敏感指标(SKILL 口径是"已完成日线")。当日盘中信息由 snapshot
overlay 补充展示,不进评分。返回 (bars_map, note)。
"""
try:
return _fetch_feed(symbols, start, end, feed, adjustment, limit, timeout), feed, None
from zoneinfo import ZoneInfo
now_et = datetime.datetime.now(ZoneInfo("America/New_York"))
market_closed = (now_et.hour, now_et.minute) >= (16, 5)
today = now_et.date().isoformat()
except Exception: # 无 tzdata 时保守回退:UTC 21:05 前视为未收盘
now = datetime.datetime.now(datetime.timezone.utc)
market_closed = (now.hour, now.minute) >= (21, 5)
today = now.date().isoformat()
if market_closed:
return bars_map, None
dropped = False
out = {}
for sym, blist in bars_map.items():
if blist and str(blist[-1].get("t", ""))[:10] == today:
out[sym] = blist[:-1]
dropped = True
else:
out[sym] = blist
note = "盘中运行:今日日线未收盘,已剔除(评分基于最近一根已完成日线)" if dropped else None
return out, note
def fetch_bars(symbols, start, end, feed, adjustment, limit, timeout):
"""拉取日线;sip 等 feed 受限时自动回退 iex 一次;盘中剔除今日未完成 bar。
返回 (bars_dict, used_feed, note)。note 为回退/剔除说明或 None。
"""
try:
bars = _fetch_feed(symbols, start, end, feed, adjustment, limit, timeout)
used_feed, note = feed, None
except FeedLimitError as e:
if feed.lower() == "iex":
raise RuntimeError(f"iex feed 受限: {e}")
note = f"{feed} feed 受限({e}),已回退 iex"
bars = _fetch_feed(symbols, start, end, "iex", adjustment, limit, timeout)
return bars, "iex", note
used_feed = "iex"
bars, drop_note = _drop_unclosed_today(bars)
if drop_note:
note = f"{note};{drop_note}" if note else drop_note
return bars, used_feed, note
def _run_json(cmd, timeout):
+12 -4
View File
@@ -18,7 +18,8 @@ from utils import to_num as _num
BASE_URL = "https://finnhub.io/api/v1"
TOKEN_ENV = "FINNHUB_API_KEY"
BENCHMARK_SYMBOLS = {"SPY", "QQQ"}
EARNINGS_EVENT_WINDOW_DAYS = 7
EARNINGS_EVENT_WINDOW_DAYS = 14 # 财报事件提示窗口:8–14 天仅 low 提示,不封顶
EARNINGS_SOFT_CAP_DAYS = 7 # ≤7 天 → medium 红旗,经 LLM overlay 封顶 74(「是」降级为「观察」)
_FINNHUB_MAX_WORKERS = 8
NEWS_RED_FLAG_RULES = [
@@ -404,8 +405,10 @@ def llm_context_from_finnhub(finnhub_context, today=None, max_news_flags=5):
"""Convert Finnhub supplemental context into the existing one-way llm_context.
The mapping is intentionally conservative: explicit negative event keywords can
produce medium/high red flags; earnings-calendar events are low severity and do
not cap the score. Positive news is only ignored here, never used to upgrade.
produce medium/high red flags. Earnings-calendar events are deterministic event
risk: within EARNINGS_SOFT_CAP_DAYS they become a medium flag (caps 是→观察 via
the LLM overlay); 8-14 days out they stay low severity (informational only).
Positive news is only ignored here, never used to upgrade.
"""
if not isinstance(finnhub_context, dict) or finnhub_context.get("status") != "ok":
return {}
@@ -457,9 +460,14 @@ def llm_context_from_finnhub(finnhub_context, today=None, max_news_flags=5):
if item.get("hour"):
note += f"({item.get('hour')})"
catalyst_parts.append(note)
if days <= EARNINGS_SOFT_CAP_DAYS:
severity = "medium" # 经 _llm_overlay 封顶 74:财报跳空是趋势前提的确定性破坏源
note += "——财报临近,趋势开仓纪律降级,待财报后趋势重新确认"
else:
severity = "low"
red_flags.append({
"type": "earnings_event",
"severity": "low",
"severity": severity,
"note": note,
"source_id": source_id,
})
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""前瞻验证日志:把每次评分固化为样本外证据(docs/model-card.md 的执行侧)。
``record`` 对篮子打分,把模型版本、信号日期、时机分类、介入前后结论与分数
逐票追加到 JSONL;同日同票重跑覆盖旧行
(幂等),跨日只追加、绝不改写历史——这是"样本外"成立的前提。
``evaluate`` 把已满足 horizon(默认 63 交易日,与回测口径一致)的记录与真实
后续收益对账,按 verdict 分桶报均值收益与同窗口 SPY 超额。
纯函数(merge_records / evaluate_records / bucket_stats)不触网,可离线测试;
只有 CLI 的取数环节碰 Alpaca。跨 model_version 的记录不混算。
"""
from __future__ import annotations
import argparse
import datetime
import json
import logging
import os
import sys
from typing import Any
sys.path.insert(0, os.path.dirname(__file__))
from backtest_common import UNIVERSE # noqa: E402
from fetching import fetch_bars # noqa: E402
from pipeline import build_result # noqa: E402
MODEL_VERSION = "v1.1" # 与 docs/model-card.md 同步;第四层语义从 v1.1 起记录
DEFAULT_LOG = os.path.join(os.path.dirname(__file__), "..", "data", "forward_log.jsonl")
DEFAULT_HORIZON = 63 # 交易日;与回测/确认层 A/B 的 63 日口径一致
RECORD_LOOKBACK_DAYS = 750 # 打分所需历史(MA200 + 12-1 动量预热)
BENCH = ["SPY", "QQQ"]
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
# ---- 纯函数(离线可测) ----
def merge_records(existing: list[dict[str, Any]], new: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""同 (run_date, symbol, model_version) 的旧行被新行覆盖;其余保序保留。"""
key = lambda r: (r.get("run_date"), r.get("symbol"), r.get("model_version")) # noqa: E731
fresh = {key(r) for r in new}
return [r for r in existing if key(r) not in fresh] + list(new)
def evaluate_records(
records: list[dict[str, Any]],
closes_by_date: dict[str, dict[str, float]],
calendar: list[str],
horizon: int,
) -> dict[str, Any]:
"""对账 v1.1 信号与 legacy v1.0 记录。
v1.1:信号在已完成日线收盘后生成,股票与 SPY 都用 signal_date 后下一交易日
的收盘作为统一、可复现的 entry;legacy 无 signal_date 记录保留原 last_close
口径并标记 method,绝不与 v1.1 混算。
"""
date_idx = {d: i for i, d in enumerate(calendar)}
def _close_on_or_before(sym: str, date: str, after: str) -> float | None:
by_date = closes_by_date.get(sym, {})
best = None
for d, c in by_date.items():
if after < d <= date and (best is None or d > best[0]):
best = (d, c)
return best[1] if best else None
matured, immature, invalid = [], 0, 0
for r in records:
run_date = str(r.get("run_date") or "")
signal_date = str(r.get("signal_date") or "")
symbol = str(r.get("symbol") or "")
if signal_date:
# 信号使用 signal_date 收盘数据,执行从下一交易日收盘开始;股票和基准同日。
ti = next((date_idx[d] for d in calendar if d > signal_date), None)
entry_date = calendar[ti] if ti is not None else None
entry_price = closes_by_date.get(symbol, {}).get(entry_date) if entry_date else None
method = "next_session_close"
else:
# v1.0 legacy:缺少真实 signal_date,只能保留旧口径并显式标记。
ti = next((date_idx[d] for d in calendar if d >= run_date), None)
entry_date = calendar[ti] if ti is not None else None
entry_price = r.get("last_close")
method = "legacy_recorded_close"
if ti is None:
immature += 1
continue
if not isinstance(entry_price, (int, float)) or entry_price <= 0:
invalid += 1
continue
if ti + horizon >= len(calendar):
immature += 1
continue
target = calendar[ti + horizon]
c1 = closes_by_date.get(symbol, {}).get(target)
if c1 is None:
c1 = _close_on_or_before(symbol, target, entry_date)
spy0 = closes_by_date.get("SPY", {}).get(entry_date)
spy1 = closes_by_date.get("SPY", {}).get(target)
if not c1 or not spy0 or not spy1:
invalid += 1
continue
ret = (c1 / entry_price - 1) * 100
spy_ret = (spy1 / spy0 - 1) * 100
matured.append({**r, "entry_date": entry_date, "entry_price": entry_price,
"evaluation_method": method, "realized_pct": round(ret, 2),
"spy_pct": round(spy_ret, 2),
"excess_pct": round(ret - spy_ret, 2)})
return {"matured": matured, "immature_count": immature, "invalid_count": invalid}
def bucket_stats(matured: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""按 (model_version, verdict) 分桶的均值统计;跨版本不混算。"""
buckets: dict[str, list[dict[str, Any]]] = {}
for m in matured:
k = f"{m.get('model_version')}/{m.get('verdict')}"
buckets.setdefault(k, []).append(m)
out = {}
for k, rows in sorted(buckets.items()):
n = len(rows)
out[k] = {
"n": n,
"mean_pct": round(sum(r["realized_pct"] for r in rows) / n, 2),
"mean_excess_pct": round(sum(r["excess_pct"] for r in rows) / n, 2),
}
return out
def timing_bucket_stats(matured: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""按 (model_version, entry_timing) 分桶,供第四层样本外 A/B。"""
buckets: dict[str, list[dict[str, Any]]] = {}
for row in matured:
timing = row.get("entry_timing") or "unknown"
key = f"{row.get('model_version')}/{timing}"
buckets.setdefault(key, []).append(row)
out: dict[str, dict[str, Any]] = {}
for key, rows in sorted(buckets.items()):
n = len(rows)
out[key] = {
"n": n,
"mean_pct": round(sum(r["realized_pct"] for r in rows) / n, 2),
"mean_excess_pct": round(sum(r["excess_pct"] for r in rows) / n, 2),
}
return out
# ---- IO ----
def load_jsonl(path: str) -> list[dict[str, Any]]:
if not os.path.exists(path):
return []
with open(path, encoding="utf-8") as f:
return [json.loads(line) for line in f if line.strip()]
def write_jsonl(path: str, records: list[dict[str, Any]]) -> None:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
# ---- CLI 子命令(触网) ----
def _fetch(symbols: list[str], lookback_days: int, feed: str, adjustment: str, timeout: int):
end = datetime.date.today().isoformat()
start = (datetime.date.today() - datetime.timedelta(days=lookback_days)).isoformat()
return fetch_bars(symbols, start, end, feed, adjustment, 10000, timeout)
def cmd_record(args) -> int:
symbols = ([s.strip().upper() for s in args.symbols.split(",") if s.strip()]
if args.symbols else list(UNIVERSE))
syms = list(dict.fromkeys(symbols + BENCH))
bars, used_feed, note = _fetch(syms, RECORD_LOOKBACK_DAYS, args.feed, args.adjustment, args.timeout)
if note:
logging.info(note)
result = build_result(syms, bars, used_feed, args.adjustment)
now_utc = datetime.datetime.now(datetime.timezone.utc)
run_date = now_utc.date().isoformat()
rows = []
for sym, a in result["symbols"].items():
if sym in BENCH or not isinstance(a, dict) or "error" in a:
continue
sc = a.get("score") or {}
if sc.get("composite") is None:
continue
rows.append({
"run_date": run_date,
"recorded_at": now_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"model_version": MODEL_VERSION,
"symbol": sym,
"composite": sc.get("composite"),
"base_verdict": sc.get("verdict_before_entry_timing"),
"verdict": sc.get("verdict"),
"entry_timing": (a.get("entry_timing") or {}).get("classification"),
"timing_score_pct": (a.get("entry_timing") or {}).get("timing_score_pct"),
"confirmation_ok": (sc.get("confirmation") or {}).get("ok"),
"market_risk_off": bool(result.get("market_risk_off")),
"signal_date": a.get("last_date"),
"signal_close": a.get("last_close"),
# legacy readers still recognize last_close; v1.1 evaluate uses next-session price.
"last_close": a.get("last_close"),
})
merged = merge_records(load_jsonl(args.log), rows)
write_jsonl(args.log, merged)
print(f"记录 {len(rows)} 票({run_date}, {MODEL_VERSION})→ {args.log},累计 {len(merged)} 行")
return 0
def cmd_evaluate(args) -> int:
records = load_jsonl(args.log)
if not records:
print("日志为空:先运行 record 积累记录")
return 0
syms = sorted({r["symbol"] for r in records} | {"SPY"})
earliest = min(r["run_date"] for r in records)
lookback = (datetime.date.today() - datetime.date.fromisoformat(earliest)).days + 30
bars, _, note = _fetch(syms, lookback, args.feed, args.adjustment, args.timeout)
if note:
logging.info(note)
closes = {s: {b["t"][:10]: b["c"] for b in bars.get(s, [])} for s in syms}
calendar = sorted(closes.get("SPY", {}))
res = evaluate_records(records, closes, calendar, args.horizon_days)
print(f"记录 {len(records)} 行:到期 {len(res['matured'])},未到期 {res['immature_count']},无效/缺价 {res['invalid_count']}")
if not res["matured"]:
print(f"暂无可评估样本(horizon={args.horizon_days} 交易日)。继续积累。")
return 0
print(f"\n按 verdict 分桶({args.horizon_days} 交易日,超额基准 SPY):")
for k, st in bucket_stats(res["matured"]).items():
print(f" {k:12} n={st['n']:<4} 均值 {st['mean_pct']:+.2f}% SPY 超额 {st['mean_excess_pct']:+.2f}%")
print("\n按入场时机分桶:")
for k, st in timing_bucket_stats(res["matured"]).items():
print(f" {k:28} n={st['n']:<4} 均值 {st['mean_pct']:+.2f}% SPY 超额 {st['mean_excess_pct']:+.2f}%")
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="评分前瞻验证日志(record/evaluate)")
p.add_argument("--log", default=os.path.normpath(DEFAULT_LOG))
p.add_argument("--feed", default="iex")
p.add_argument("--adjustment", default="split")
p.add_argument("--timeout", type=int, default=120)
sub = p.add_subparsers(dest="cmd", required=True)
pr = sub.add_parser("record", help="对篮子打分并追加当日记录(同日重跑覆盖)")
pr.add_argument("--symbols", help="逗号分隔;默认回测篮子 UNIVERSE")
pe = sub.add_parser("evaluate", help="对到期记录对账,按 verdict 分桶")
pe.add_argument("--horizon-days", type=int, default=DEFAULT_HORIZON)
args = p.parse_args(argv)
return cmd_record(args) if args.cmd == "record" else cmd_evaluate(args)
if __name__ == "__main__":
raise SystemExit(main())
+92 -3
View File
@@ -10,6 +10,7 @@
metrics.py 纯指标原语(EMA/MACD/RSI/KDJ/均线/收益率/周线聚合)
fetching.py Alpaca CLI 日线拉取(分页/去重/feed 回退)
finnhub.py Finnhub quote/news/profile/earnings 补充上下文(可选)
ai_opinion.py AI 第二意见 overlay(可选;只展示不评分)
portfolio.py Alpaca CLI 账户/持仓只读读取与归一化
analysis.py 单 symbol 分析与跨 symbol 聚合(相对强度)
本文件保留 CLI 与向后兼容的再导出(旧的 `import indicators as I` 仍可用)。
@@ -30,7 +31,10 @@
# 可选:Finnhub 补充 quote/news/profile/earnings;无 key 时 auto 不触网
indicators.py --symbols AAPL,SPY,QQQ --finnhub-context auto
依赖:仅 Python 标准库 + 本机 `alpaca` CLI。
# 可选:AI 第二意见(只展示不评分);无 ANTHROPIC_API_KEY 时 auto 不触网
indicators.py --symbols AAPL,SPY,QQQ --ai-opinion auto
依赖:仅 Python 标准库 + 本机 `alpaca` CLI(AI 意见需可选的 `anthropic` SDK)。
"""
import argparse
import datetime
@@ -84,6 +88,7 @@ from portfolio import ( # noqa: F401
from scoring import score # noqa: F401
from agent_contracts import ( # noqa: E402
KIND_ACCOUNT,
KIND_AI_OPINION,
KIND_BARS,
KIND_FINNHUB,
KIND_NEWS,
@@ -92,6 +97,13 @@ from agent_contracts import ( # noqa: E402
ContractError,
validate_payload,
)
from ai_opinion import ( # noqa: E402
compute_agreement as ai_opinion_compute_agreement,
has_api_key as ai_opinion_has_api_key,
opinions_for_result,
sdk_available as ai_opinion_sdk_available,
unavailable_opinions,
)
BENCHMARK_SYMBOLS = ("SPY", "QQQ")
@@ -232,6 +244,73 @@ def _load_snapshot_context(path):
return data
def _load_ai_opinion_context(path):
"""Load optional AI opinion overlay JSON for offline tests/replay."""
if not path:
return None
with open(path, encoding="utf-8") as f:
data = json.load(f)
if not _validate_or_drop(KIND_AI_OPINION, data, "--ai-opinion-context-file"):
return {"status": "unavailable", "reason": "ai_opinion 契约校验失败"}
return data
def _ai_opinion_from_args(args, result, llm_context):
"""Generate or load the display-only AI opinion artifact after scoring.
评分后置步骤:依赖指标与 verdict(算 agreement),不能并入前段并行采集。
auto 模式在无 key/SDK 时不触网也不写任何块;on 模式失败报错。
"""
if args.ai_opinion_context_file:
return _load_ai_opinion_context(args.ai_opinion_context_file)
if args.ai_opinion == "off":
return None
if args.input and args.ai_opinion != "on":
return None
if args.ai_opinion == "auto" and not (ai_opinion_sdk_available() and ai_opinion_has_api_key()):
return None
targets = [
s for s in (result.get("symbols") or {})
if s not in BENCHMARK_SYMBOLS
]
if len(targets) > 1:
sys.stderr.write(f"提示: AI 意见将逐票调用({len(targets)} 票),预计每票数十秒\n")
artifact = opinions_for_result(result, llm_context=llm_context)
if args.ai_opinion == "on":
failures = unavailable_opinions(artifact, targets)
if failures:
detail = ";".join(f"{sym}: {reason}" for sym, reason in failures.items())
raise RuntimeError(f"AI 意见生成失败:{detail}")
return artifact
def _apply_ai_opinion(result, artifact):
"""Attach AI opinions to supplemental blocks only; ``score`` is never touched."""
if not isinstance(artifact, dict):
return
supplemental = result.setdefault("supplemental", {})
if artifact.get("status") != "ok":
supplemental["ai_opinion"] = {
"status": artifact.get("status") or "unavailable",
"reason": artifact.get("reason"),
}
return
entries = artifact.get("symbols") or {}
supplemental["ai_opinion"] = {"status": "ok", "symbols_count": len(entries)}
for sym, entry in entries.items():
a = (result.get("symbols") or {}).get(sym)
if isinstance(a, dict) and "error" not in a:
# 离线 artifact 里的 agreement 不可信:它必须与当前脚本 verdict 比较后
# 重新生成,避免旧结果或手改文件伪造“同意/分歧”。
normalized_entry = dict(entry) if isinstance(entry, dict) else entry
if isinstance(normalized_entry, dict) and normalized_entry.get("status") == "ok":
verdict = (a.get("score") or {}).get("verdict")
normalized_entry["agreement"] = ai_opinion_compute_agreement(
verdict, str(normalized_entry.get("stance") or "")
)
a.setdefault("supplemental", {})["ai_opinion"] = normalized_entry
def _snapshot_context_from_args(args, symbols):
if args.snapshot_context_file:
return _load_snapshot_context(args.snapshot_context_file)
@@ -284,6 +363,13 @@ def main():
help="当日快照补充上下文:auto=非离线模式拉取当日最新价/报价/成交,on=失败时报错,off=关闭。只写入 supplemental,不参与 score",
)
p.add_argument("--snapshot-context-file", help="离线快照补充上下文 JSON")
p.add_argument(
"--ai-opinion",
choices=("auto", "on", "off"),
default="auto",
help="AI 第二意见 overlay:auto=有 ANTHROPIC_API_KEY 时调用,on=失败时报错,off=关闭。只写入 supplemental,不参与 score",
)
p.add_argument("--ai-opinion-context-file", help="离线 AI 意见 overlay JSON")
args = p.parse_args()
llm_context = _load_llm_context(args.llm_context_file)
@@ -311,9 +397,9 @@ def main():
else:
if not args.symbols:
p.error("非离线模式需要 --symbols")
end = args.end or datetime.date.today().isoformat()
end = args.end or datetime.datetime.now(datetime.timezone.utc).date().isoformat()
# 约两年日线:MA60 与周线 MACD(需 ~78 周) 都能充分预热
start = args.start or (datetime.date.today() - datetime.timedelta(days=728)).isoformat()
start = args.start or (datetime.datetime.now(datetime.timezone.utc).date() - datetime.timedelta(days=728)).isoformat()
symbols = _symbols_with_benchmarks(
[s.strip().upper() for s in args.symbols.split(",") if s.strip()]
)
@@ -341,6 +427,9 @@ def main():
finnhub_context=finnhub_context, snapshot_context=snapshot_context,
)
# AI 意见是评分完成后的展示 overlay:任何失败只降级自身,绝不改 score。
_apply_ai_opinion(result, _ai_opinion_from_args(args, result, effective_llm_context))
try:
validate_payload(KIND_RESULT, result)
except ContractError as e:
+196
View File
@@ -369,6 +369,202 @@ def up_day_volume_ratio(closes, volumes, window=10):
return round(avg_up / avg_dn, 2) if avg_dn else None
# ---- 入场时机(过热度 / 趋势内回调拐点)----
# 过热判定:距 MA20 的 ATR 倍数与 RSI14 超买(硬条件二选一,软条件需同时满足)
ENTRY_EXT_ATR_HARD = 3.0
ENTRY_EXT_RSI_HARD = 80.0
ENTRY_EXT_ATR_SOFT = 2.2
ENTRY_EXT_RSI_SOFT = 75.0
# 回调拐点:近 10 日低点较 30 日高点的最小回撤深度(%),或 5 日内触及 MA20
ENTRY_PULLBACK_MIN_DEPTH_PCT = -4.0
# 反弹确认:反转日之外还需的独立触发信号数
ENTRY_BOUNCE_MIN_SIGNALS = 2
def _ma_reclaim(closes, n, lookback=2):
"""近 lookback 根内收盘价由 n 日均线下方收复到上方,且当前仍在上方。"""
total = len(closes)
if total < n + lookback + 1:
return None
m_now = ma(closes, n)
if m_now is None or closes[-1] <= m_now:
return False
for back in range(lookback):
c_prev = closes[-2 - back]
m_prev = ma(closes[:total - 1 - back], n)
if m_prev is not None and c_prev <= m_prev:
return True
return False
def entry_timing(closes, highs, lows, volumes):
"""入场时机画像:过热度 + 趋势内回调拐点识别。历史 <60 根返回 None。
只做时机分类,不判断趋势资格(趋势/相对强度由 ALPHA 因子与闸门负责):
- overextended:价格显著超出短期均值/RSI 超买 → 追高风险
- pullback_reversal:趋势完好 + 近期回调 + 最后一根出现放量反转触发
- pullback_no_trigger:回调中但反转未确认
- trend_continuation:趋势正常推进、未过热
- trend_broken:跌破中期均线(资格由闸门另行否决)
"""
n = len(closes)
if n < 60:
return None
last = closes[-1]
ma20 = ma(closes, 20)
ma60 = ma(closes, 60)
ma200 = ma(closes, 200)
atr14 = atr(highs, lows, closes, 14)
rsi14 = rsi(closes, 14)
# 过热度:距 MA20 的 ATR 倍数(均值回归引力的量纲化读数)
ext_atr = round((last - ma20) / atr14, 2) if (ma20 and atr14) else None
ext_pct = round((last / ma20 - 1) * 100, 2) if ma20 else None
overextended = bool(
(ext_atr is not None and ext_atr >= ENTRY_EXT_ATR_HARD)
or (rsi14 is not None and rsi14 >= ENTRY_EXT_RSI_HARD)
or (ext_atr is not None and rsi14 is not None
and ext_atr >= ENTRY_EXT_ATR_SOFT and rsi14 >= ENTRY_EXT_RSI_SOFT)
)
# 回调结构必须满足「先高点、后低点、再到当前反转」的时间顺序。旧实现直接拿
# 近 10 日低点除以近 30 日最高点;当最高点就是今天时,会把从旧低点上涨到
# 新高的幅度误当成回撤。这里先定位近 10 日最低点,再只在它之前找高点。
win = min(30, n)
window_start = n - win
recent_start = n - min(10, n)
recent_low = min(lows[recent_start:])
# 并列低点取最后一个,优先解释离当前反转最近的回调低点。
recent_low_idx = recent_start + max(
i for i, value in enumerate(lows[recent_start:]) if value == recent_low
)
prior_highs = highs[window_start:recent_low_idx]
if prior_highs:
hi = max(prior_highs)
# 并列高点取最后一个,得到距离当前最近且仍早于低点的结构高点。
hi_idx = window_start + max(i for i, v in enumerate(prior_highs) if v == hi)
days_since_high = n - 1 - hi_idx
depth = round((recent_low / hi - 1) * 100, 2) if hi else None
else:
hi, hi_idx, days_since_high, depth = None, None, None, None
# 过去五日的低点必须与「当时」的 MA20 比较,不能用今天的 MA20 回看历史。
touched_ma20_days = []
for idx in range(max(19, n - 5), n):
ma20_at_idx = ma(closes[:idx + 1], 20)
if ma20_at_idx is not None and lows[idx] <= ma20_at_idx * 1.01:
touched_ma20_days.append(idx)
touched_ma20 = bool(touched_ma20_days)
had_pullback = bool(
(depth is not None and depth <= ENTRY_PULLBACK_MIN_DEPTH_PCT) or touched_ma20
)
# 反转日:收阳且收在日内区间上半部,或直接收在前一根最高价上方
prev_close = closes[-2]
up_day = last > prev_close
day_range = highs[-1] - lows[-1]
close_strength = round((last - lows[-1]) / day_range, 2) if day_range else None
bounce_day = bool(up_day and (
(close_strength is not None and close_strength >= 0.55) or last > highs[-2]
))
# 独立触发信号
vol_ma20 = ma(volumes, 20)
vol_ratio = round(volumes[-1] / vol_ma20, 2) if vol_ma20 else None
vol_expand = bool(vol_ratio is not None and vol_ratio >= 1.2)
reclaim10 = _ma_reclaim(closes, 10)
reclaim20 = _ma_reclaim(closes, 20)
reclaim_ma = bool(reclaim10 or reclaim20)
kdj_now = kdj(highs, lows, closes)
kdj_p1 = kdj(highs[:-1], lows[:-1], closes[:-1])
kdj_p2 = kdj(highs[:-2], lows[:-2], closes[:-2]) if n > 2 else None
kdj_golden = bool(
kdj_now and kdj_now.get("bull") and (
(kdj_p1 and not kdj_p1.get("bull"))
or (kdj_p1 and kdj_p1.get("bull") and kdj_p2 and not kdj_p2.get("bull"))
)
)
macd_now = macd(closes)
macd_p1 = macd(closes[:-1])
macd_rising = bool(
macd_now and macd_p1
and macd_now.get("hist") is not None and macd_p1.get("hist") is not None
and macd_now["hist"] > macd_p1["hist"]
)
rsi6_now = rsi(closes, 6)
rsi6_p1 = rsi(closes[:-1], 6)
rsi6_rebound = bool(
rsi6_now is not None and rsi6_p1 is not None
and rsi6_now > rsi6_p1 and rsi6_p1 < 50
)
signal_count = sum((vol_expand, reclaim_ma, kdj_golden, macd_rising, rsi6_rebound))
# 趋势是否完好(时机分类的前提;不足以单独构成买入资格)
trend_intact = bool(
ma60 is not None and last >= ma60 * 0.97
and (ma200 is None or last > ma200 * 0.99)
)
if not trend_intact:
cls = "trend_broken"
timing_score = 10
elif overextended:
cls = "overextended"
timing_score = 20
elif had_pullback and bounce_day and signal_count >= ENTRY_BOUNCE_MIN_SIGNALS:
cls = "pullback_reversal"
timing_score = min(75 + 5 * signal_count, 95)
elif had_pullback and (ext_atr is None or ext_atr < 1.0):
# 回调中但反转未确认(含反弹日信号不足);已收复到 MA20 上方 1 ATR 以上的
# 视为回调结束、回到趋势推进
cls = "pullback_no_trigger"
timing_score = 45
else:
cls = "trend_continuation"
timing_score = 65
if ext_atr is not None and ext_atr >= 2.0:
timing_score = 45
elif ext_atr is not None and ext_atr >= 1.5:
timing_score = 55
return {
"ext_atr_above_ma20": ext_atr,
"ext_pct_above_ma20": ext_pct,
"rsi14": rsi14,
"overextended": overextended,
"trend_intact": trend_intact,
"pullback": {
"high_30d": round(hi, 4) if hi is not None else None,
"days_since_30d_high": days_since_high,
"recent_low_10d": round(recent_low, 4),
"bars_since_recent_low": n - 1 - recent_low_idx,
"depth_from_30d_high_pct": depth,
"touched_ma20_5d": touched_ma20,
"had_pullback": had_pullback,
},
"bounce": {
"bounce_day": bounce_day,
"up_day": up_day,
"close_strength": close_strength,
"vol_ratio_vs_ma20": vol_ratio,
"vol_expand": vol_expand,
"reclaim_ma10": reclaim10,
"reclaim_ma20": reclaim20,
"kdj_golden_3d": kdj_golden,
"macd_hist_rising": macd_rising,
"rsi6_rebound": rsi6_rebound,
"signal_count": signal_count,
},
"classification": cls,
"timing_score_pct": timing_score,
}
def adx(highs, lows, closes, n=14):
"""Average Directional Index(Wilder 平滑)。
+1 -14
View File
@@ -10,6 +10,7 @@ import subprocess
from concurrent.futures import ThreadPoolExecutor
from utils import to_num as _num
from fetching import _run_json
def _round_num(value, ndigits=4):
@@ -22,20 +23,6 @@ def _pct_from_factor(value):
return round(n * 100, 2) if n is not None else None
def _run_json(cmd, timeout):
res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if res.returncode != 0:
detail = (res.stderr.strip() or res.stdout.strip() or "unknown error").replace("\n", " ")
raise RuntimeError(detail[:300])
text = res.stdout.strip()
if not text:
return None
try:
return json.loads(text)
except json.JSONDecodeError as e:
raise RuntimeError(f"无法解析 alpaca 输出为 JSON: {e}; 输出前 200 字符: {text[:200]!r}")
def _normalize_account(account):
account = account if isinstance(account, dict) else {}
equity = _round_num(account.get("equity") or account.get("portfolio_value"), 2)
+135 -35
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""全市场扫描:取股池 → 流动性初筛 → 精简批量评分 → 提取候选 → 新闻复核降级。
"""全市场扫描:取股池 → 流动性初筛 → 精简批量评分 → 提取候选 → 新闻复核降级 → AI 第二意见。
把"扫全市场找今天能买的"固化为一条命令。复用 ``pipeline.build_result`` 做两轮评分:
@@ -9,6 +9,7 @@
把被软红旗降级(是→观察/否)的剔除到 ``downgraded``。
精简轮关 overlay 先粗筛、再只对候选开 Finnhub,省 API 调用。日线在两轮间复用,不重复拉。
AI 第二意见只对 top-N 截断后的最终候选逐票生成(只展示不评分),无 ANTHROPIC_API_KEY 时自动跳过。
设计原则同 indicators.py:只读、不下单、不打印凭证;网络分段各自失败降级,不阻断核心评分;
纯函数(过滤/提取/合并)与网络函数分离,纯函数可离线单测。
@@ -25,13 +26,19 @@ from concurrent.futures import ThreadPoolExecutor
from typing import Any
# 本模块位于 scripts/,平级 import(与 indicators.py 一致)
from fetching import fetch_bars, fetch_snapshots # noqa: E402
from fetching import _run_json, fetch_bars, fetch_snapshots # noqa: E402
from finnhub import ( # noqa: E402
fetch_finnhub_context,
has_api_key as finnhub_has_api_key,
llm_context_from_finnhub,
)
from pipeline import build_result # noqa: E402
from ai_opinion import ( # noqa: E402
has_api_key as ai_opinion_has_api_key,
opinions_for_result,
sdk_available as ai_opinion_sdk_available,
unavailable_opinions,
)
BENCHMARK_SYMBOLS = ("SPY", "QQQ")
# IEX 日成交量约为全市场 2-3%;5 万 IEX 股 ≈ 数百万股全市场,作活跃粗筛门槛。
@@ -107,10 +114,19 @@ def _candidate_row(sym: str, a: dict) -> dict:
sc = a.get("score") or {}
bd = sc.get("factor_breakdown") or {}
tp = sc.get("trade_plan") or {}
et = a.get("entry_timing") or {}
return {
"symbol": sym,
"composite": sc.get("composite"),
"verdict": sc.get("verdict"),
"suggested_action": sc.get("suggested_action"),
"entry_timing": {
"classification": et.get("classification"),
"timing_score_pct": et.get("timing_score_pct"),
"ext_atr_above_ma20": et.get("ext_atr_above_ma20"),
"pullback_depth_pct": (et.get("pullback") or {}).get("depth_from_30d_high_pct"),
"bounce_signal_count": (et.get("bounce") or {}).get("signal_count"),
},
"factor_breakdown": {
k: (v.get("score_pct") if isinstance(v, dict) else None)
for k, v in bd.items()
@@ -151,6 +167,14 @@ def extract_candidates(result: dict, top_n: int | None = None) -> list[dict]:
return cands
def _filter_entry(cands: list[dict], mode: str) -> list[dict]:
"""按入场时机分类过滤候选(--entry)。all 原样返回。"""
if mode == "all":
return cands
want = "pullback_reversal" if mode == "pullback" else "trend_continuation"
return [c for c in cands if (c.get("entry_timing") or {}).get("classification") == want]
def merge_news_downgrades(lean_candidates: list[dict], verified_result: dict) -> tuple[list[dict], list[dict]]:
"""对比精简轮候选与复核轮(带 llm_context)结果,分流 final / downgraded。
@@ -181,6 +205,43 @@ def merge_news_downgrades(lean_candidates: list[dict], verified_result: dict) ->
return final, downgraded
def _attach_ai_opinions(final_candidates: list[dict], scored_result: dict,
mode: str, llm_context: dict | None = None,
offline: bool = False) -> str | None:
"""对最终候选(top-N 截断后)逐票生成 AI 第二意见并附加到候选行。
只展示不评分:候选的 composite/verdict/排序在调用前已定型,本函数只往
每行追加 ``ai_opinion`` 字段。整体不可用返回 data_flag 文本(调用方决定
是否记录);``mode=="on"`` 时不可用直接报错;单票失败由 ai_opinion 内部
降级为该票 ``status=unavailable``,不影响其他候选。
"""
if not final_candidates or mode == "off":
return None
# 离线复盘保持不触网可复现;除非显式 on
if offline and mode != "on":
return None
if mode == "auto" and not (ai_opinion_sdk_available() and ai_opinion_has_api_key()):
return "未配置 ANTHROPIC_API_KEY,跳过 AI 意见(auto 模式)"
syms = [c["symbol"] for c in final_candidates]
sys.stderr.write(f"AI 意见: 对 {len(syms)} 个候选逐票调用,预计每票数十秒\n")
artifact = opinions_for_result(scored_result, llm_context=llm_context, symbols=syms)
if artifact.get("status") != "ok":
if mode == "on":
raise RuntimeError(artifact.get("reason") or "AI 意见生成失败")
return f"AI 意见不可用:{artifact.get('reason')}"
if mode == "on":
failures = unavailable_opinions(artifact, syms)
if failures:
detail = ";".join(f"{sym}: {reason}" for sym, reason in failures.items())
raise RuntimeError(f"AI 意见生成失败:{detail}")
entries = artifact.get("symbols") or {}
for cand in final_candidates:
entry = entries.get(cand["symbol"])
if entry:
cand["ai_opinion"] = entry
return None
def _verdict_dist(result: dict) -> dict[str, int]:
syms = (result or {}).get("symbols") or {}
dist: dict[str, int] = {}
@@ -210,21 +271,6 @@ def _market_regime(result: dict) -> dict:
# 网络函数
# ----------------------------------------------------------------------------
def _run_json(cmd: list[str], timeout: int) -> Any:
"""跑 alpaca CLI 子进程并解析 JSON stdout;非零退出抛 RuntimeError。"""
res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if res.returncode != 0:
detail = (res.stderr.strip() or res.stdout.strip() or "unknown error").replace("\n", " ")
raise RuntimeError(detail[:300])
text = res.stdout.strip()
if not text:
return None
try:
return json.loads(text)
except json.JSONDecodeError as e:
raise RuntimeError(f"无法解析 alpaca 输出为 JSON: {e}; 输出前 200 字符: {text[:200]!r}")
def fetch_active_assets(exchanges=("NYSE", "NASDAQ"), asset_class="us_equity", timeout=30) -> list[dict]:
"""从 ``alpaca asset list`` 取活跃美股原始字典列表(每个交易所一次调用)。"""
out: list[dict] = []
@@ -249,15 +295,29 @@ def fetch_active_assets(exchanges=("NYSE", "NASDAQ"), asset_class="us_equity", t
def fetch_liquidity(symbols: list[str], feed: str, min_price: float, min_volume: float,
chunk: int = DEFAULT_SNAPSHOT_CHUNK, timeout: int = 30) -> tuple[list[dict], list[str]]:
"""分块拉 snapshot 并按流动性过滤。返回 (pool, data_flags)。单块失败跳过不阻断。"""
"""分块拉 snapshot 并按流动性过滤。返回 (pool, data_flags)。单块失败跳过不阻断。
分块之间并行(ThreadPoolExecutor),耗时从「各 chunk 之和」降到「最慢 chunk」。
"""
syms = [s for s in (symbols or []) if s]
if not syms:
return [], []
batches = [syms[i:i + chunk] for i in range(0, len(syms), chunk)]
data_flags: list[str] = []
merged: dict[str, dict] = {}
for i in range(0, len(syms), chunk):
batch = syms[i:i + chunk]
def _one(i_batch):
i, batch = i_batch
ctx = fetch_snapshots(batch, feed=feed, timeout=timeout)
return i, ctx
workers = max(1, min(len(batches), 8))
with ThreadPoolExecutor(max_workers=workers) as ex:
futures = [ex.submit(_one, (idx, b)) for idx, b in enumerate(batches)]
results: list[tuple[int, dict]] = [f.result() for f in futures]
for _idx, ctx in sorted(results, key=lambda x: x[0]):
if ctx.get("status") != "ok":
data_flags.append(f"snapshot 分块 {i}-{i+len(batch)} 拉取失败已跳过: {ctx.get('reason')}")
data_flags.append(f"snapshot 分块拉取失败已跳过: {ctx.get('reason')}")
continue
for sym, snap in (ctx.get("symbols") or {}).items():
merged[sym] = snap
@@ -268,16 +328,31 @@ def fetch_liquidity(symbols: list[str], feed: str, min_price: float, min_volume:
def fetch_pool_bars(symbols: list[str], start: str, end: str, feed: str, adjustment: str,
chunk: int = DEFAULT_BARS_CHUNK, timeout: int = 30) -> tuple[dict, list[str]]:
"""分块拉两年日线,合并 {symbol: [bar]}。SPY/QQQ 若缺失单独补拉(regime 依赖)。"""
"""分块拉两年日线,合并 {symbol: [bar]}。SPY/QQQ 若缺失单独补拉(regime 依赖)。
分块之间并行(ThreadPoolExecutor),耗时从「各 chunk 之和」降到「最慢 chunk」。
"""
syms = [s for s in (symbols or []) if s]
if not syms:
return {}, []
bars: dict[str, list] = {}
data_flags: list[str] = []
for i in range(0, len(syms), chunk):
batch = syms[i:i + chunk]
batches = [syms[i:i + chunk] for i in range(0, len(syms), chunk)]
def _one(batch):
try:
chunk_bars, _used, _note = fetch_bars(batch, start, end, feed, adjustment, 10000, timeout)
b_bars, _used_feed, _note = fetch_bars(batch, start, end, feed, adjustment, 10000, timeout)
return b_bars, None
except Exception as e: # noqa: BLE001
data_flags.append(f"bars 分块 {i}-{i+len(batch)} 拉取失败已跳过: {e}")
return None, f"bars 分块拉取失败已跳过: {e}"
workers = max(1, min(len(batches), 8))
with ThreadPoolExecutor(max_workers=workers) as ex:
futures = [ex.submit(_one, b) for b in batches]
results = [f.result() for f in futures]
for chunk_bars, err in results:
if err:
data_flags.append(err)
continue
bars.update(chunk_bars)
# 确保基准在列(build_result 的 market_risk_off 依赖 SPY.above_MA200)
@@ -320,7 +395,7 @@ def _ensure_benchmarks(symbols: list[str]) -> list[str]:
def _today_iso() -> str:
return datetime.date.today().isoformat()
return datetime.datetime.now(datetime.timezone.utc).date().isoformat()
def scan(args) -> dict:
@@ -329,7 +404,7 @@ def scan(args) -> dict:
adjustment = args.adjustment
as_of = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
data_flags: list[str] = []
today_d = datetime.date.today()
today_d = datetime.datetime.now(datetime.timezone.utc).date()
today = today_d.isoformat()
# ---- 1. 股池 ----
@@ -368,12 +443,16 @@ def scan(args) -> dict:
verdict_dist = _verdict_dist(lean_result)
# ---- 4. 提取候选 ----
candidates = extract_candidates(lean_result, top_n=None)
lean_candidates = extract_candidates(lean_result, top_n=None)
# --entry 是候选资格过滤,必须在 Finnhub 复核前执行,避免对不会进入结果的
# 另一类时机候选消耗新闻 API 配额。top-N 仍在新闻后截断,以便降级后回补。
candidates = _filter_entry(lean_candidates, args.entry)
# ---- 5. 新闻复核降级 ----
downgraded: list[dict] = []
verify = args.verify_news
do_verify = verify == "on" or (verify == "auto" and finnhub_has_api_key())
scored_result, cand_llm_ctx = lean_result, None
if do_verify and candidates:
cand_syms = _ensure_benchmarks([c["symbol"] for c in candidates])
finnhub_ctx = fetch_finnhub_context(
@@ -387,6 +466,7 @@ def scan(args) -> dict:
llm_context=llm_ctx, finnhub_context=finnhub_ctx,
)
final_candidates, downgraded = merge_news_downgrades(candidates, verified)
scored_result, cand_llm_ctx = verified, llm_ctx
else:
final_candidates = candidates
if do_verify and not candidates:
@@ -397,6 +477,11 @@ def scan(args) -> dict:
if args.top is not None:
final_candidates = final_candidates[:args.top]
# ---- 6. AI 第二意见(仅最终候选;只展示不评分)----
ai_flag = _attach_ai_opinions(final_candidates, scored_result, args.ai_opinion, cand_llm_ctx)
if ai_flag:
data_flags.append(ai_flag)
return {
"as_of": as_of,
"feed": feed,
@@ -406,7 +491,8 @@ def scan(args) -> dict:
"universe": universe_count,
"liquidity_pool": pool_count,
"scored": len([s for s in score_syms if s not in BENCHMARK_SYMBOLS]),
"candidates_lean": len(candidates),
"candidates_lean": len(lean_candidates),
"candidates_entry_filtered": len(candidates),
"verdict_dist": verdict_dist,
},
"market_regime": _market_regime(lean_result),
@@ -425,7 +511,7 @@ def _scan_offline(args) -> dict:
feed = args.feed
adjustment = args.adjustment
as_of = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
today_d = datetime.date.today()
today_d = datetime.datetime.now(datetime.timezone.utc).date()
if args.symbols:
pool_syms = [s.strip().upper() for s in args.symbols.split(",") if s.strip()]
@@ -440,10 +526,12 @@ def _scan_offline(args) -> dict:
score_syms = _ensure_benchmarks(pool_syms)
lean_result = build_result(score_syms, bars, feed, adjustment)
verdict_dist = _verdict_dist(lean_result)
candidates = extract_candidates(lean_result, top_n=None)
lean_candidates = extract_candidates(lean_result, top_n=None)
candidates = _filter_entry(lean_candidates, args.entry)
downgraded: list[dict] = []
finnhub_ctx = data.get("finnhub")
scored_result, cand_llm_ctx = lean_result, None
if args.verify_news != "off" and candidates and isinstance(finnhub_ctx, dict):
llm_ctx = llm_context_from_finnhub(finnhub_ctx, today=today_d)
cand_syms = _ensure_benchmarks([c["symbol"] for c in candidates])
@@ -452,12 +540,16 @@ def _scan_offline(args) -> dict:
llm_context=llm_ctx, finnhub_context=finnhub_ctx,
)
final_candidates, downgraded = merge_news_downgrades(candidates, verified)
scored_result, cand_llm_ctx = verified, llm_ctx
else:
final_candidates = candidates
if args.top is not None:
final_candidates = final_candidates[:args.top]
# 离线复盘默认不触网;--ai-opinion on 时才对最终候选生成 AI 意见
_attach_ai_opinions(final_candidates, scored_result, args.ai_opinion, cand_llm_ctx, offline=True)
return {
"as_of": as_of,
"feed": feed,
@@ -468,7 +560,8 @@ def _scan_offline(args) -> dict:
"universe": len(pool_syms) if args.symbols else len(filter_common_tickers(data.get("assets") or [])),
"liquidity_pool": len(pool_syms),
"scored": len([s for s in score_syms if s not in BENCHMARK_SYMBOLS]),
"candidates_lean": len(candidates),
"candidates_lean": len(lean_candidates),
"candidates_entry_filtered": len(candidates),
"verdict_dist": verdict_dist,
},
"market_regime": _market_regime(lean_result),
@@ -488,8 +581,12 @@ def _build_args(argv=None) -> argparse.Namespace:
p.add_argument("--snapshot-chunk", type=int, default=DEFAULT_SNAPSHOT_CHUNK)
p.add_argument("--bars-chunk", type=int, default=DEFAULT_BARS_CHUNK)
p.add_argument("--top", type=int, default=DEFAULT_TOP)
p.add_argument("--entry", choices=("all", "pullback", "trend"), default="all",
help="按入场时机过滤候选:pullback=只要回调拐点,trend=只要趋势延续,all=不过滤")
p.add_argument("--verify-news", choices=("auto", "on", "off"), default="auto",
help="auto=有 FINNHUB_API_KEY 才复核,on=强制复核,off=关闭")
p.add_argument("--ai-opinion", choices=("auto", "on", "off"), default="auto",
help="仅对最终候选生成 AI 第二意见(只展示不评分):auto=有 ANTHROPIC_API_KEY 才调用,on=失败时报错,off=关闭")
p.add_argument("--finnhub-news-days", type=int, default=30)
p.add_argument("--finnhub-earnings-days", type=int, default=14)
p.add_argument("--finnhub-timeout", type=int, default=15)
@@ -517,10 +614,13 @@ def _summary_text(result: dict) -> str:
lines.append("大盘: SPY %s risk_off=%s" % (mr.get("spy_verdict"), mr.get("market_risk_off")))
if cands:
lines.append("可买 %d:" % len(cands))
entry_label = {"pullback_reversal": "拐点", "trend_continuation": "延续"}
for c in cands:
tp = c.get("trade_plan") or {}
lines.append(" %s %s/100 入%s 止%s 止盈%s" % (
c["symbol"], c.get("composite"), tp.get("suggested_entry_price"),
tag = entry_label.get((c.get("entry_timing") or {}).get("classification"), "")
lines.append(" %s%s %s/100 入%s 止%s 止盈%s" % (
c["symbol"], f"[{tag}]" if tag else "", c.get("composite"),
tp.get("suggested_entry_price"),
tp.get("stop_loss_price"), tp.get("take_profit_price")))
else:
lines.append("无可买候选")
+21 -6
View File
@@ -26,12 +26,27 @@ momentum≈0.087,是过拟合);因高度共线,55/35 的 momentum/rel
账户 overlay(account_context)只读 Alpaca 持仓,把当前敞口与建议仓位比较,用于给出
更具体的「新开 / 持有 / 加仓 / 减仓 / 退出」动作;不改变 composite 本身。
**最关键的实证局限——edge 是 regime 条件的**(backtest_robustness regime 拆分,
63 日非重叠):risk-on(SPY>MA200)IC ≈+0.099、t≈2.05、五分位多空 +2.35%,真实
且接近显著;risk-off(SPY<MA200)IC ≈−0.097、五分位多空 −0.49%,**edge 反转为负**
(动量在熊市崩溃)。全样本 IC +0.046 是两者混合。这正是 risk_gates 否决层(SPY/
个股跌破 MA200 时封顶)存在的理由——alpha 恰在 risk-off 失效。含 2022 后「≥75 是」
桶不再单调领先(60–75 桶反而更高),说明高分≠高确定性,须配合 regime 与否决层解读。
**最关键的实证局限(2026-07-09 扩篮子对照后修订)——edge 首先是篮子条件的,其次
才是 regime 条件的**:把 45 只手挑大盘票按规则扩到 132 只(11 行业每行业 ≥7,另含
8 只退市票)后,63 日非重叠全样本 IC 从 +0.076(t≈1.57)跌到 +0.013(t≈0.35,
95%CI[-0.064,+0.085] 横跨 0),五分位多空毛价差 +1.85% → −0.64%;行业中性化后同样
无意义(IC +0.015)。同一套修正后代码在旧篮子上完整复现旧结论(risk-on IC +0.103、
t≈2.01、CI[+0.007,+0.200];行业中性全样本 +0.085、t≈2.36、CI[+0.016,+0.155])——
唯一变量是篮子。诚实解读:**排名分的横截面 edge 只在手挑的大盘票池内可测得,无法
推广到更宽的大盘股全集**;旧篮子按「今天著名的名字」入选,本身带后见之明。regime
结论(risk-on 有效、risk-off 反转——否决层的依据)在旧篮子内仍成立,在宽篮子上两个
regime 都无 edge。含 2022 后「≥75 是」桶不再单调领先(60–75 桶反而更高)。定位结论:
composite 是纪律工具与风险过滤器(否决层/确认层/事件层的价值不依赖排名 IC),
**不是经过验证的选股 alpha**;全市场 scanner 的排名只应作纪律过滤解读。权重不因此
重调(在噪声样本里重新拟合只会更糟),后续以 forward_log 的样本外证据为准绳。
确认 overlay 的 A/B 实证(2026-07-09,修正口径:破产票终值按 -100%、五分位扣
单边 10bp 成本报净价差):买入候选(composite≥75 无否决)按 confirmation.ok 拆分,
21 日前瞻 ok 组 +1.78% vs 封顶组 +0.77%(差 +1.01%,n=180/284);63 日 +4.65% vs
+4.70%(差 -0.05%)。按预注册判定规则(63 日差值 ±1% 内)→ **保留但证据不足**:
gate 对 1 个月内入场时点有帮助、3 个月洗平;维持"只封顶不加分",待 forward log
积累样本外证据后复评。同轮 21 日五分位净价差扣成本后转负(毛 +0.23% → 净 -0.17%),
63 日净 +1.41% 存活——月度换仓会吃掉短周期 edge,评分只应服务 ≥3 个月持有周期。
历史教训:早期等掺入 technical/volume 的六因子 composite,IC 反而低于强因子单打
——弱因子稀释强信号。本版按数据只保留 IC 为正且彼此不过度共线的因子进排名分。
+53 -16
View File
@@ -61,7 +61,8 @@ def score(
effective_cap = min(cap, llm_cap) # 价格否决层与 LLM overlay 取更严者;只压不抬
composite = round(min(raw, effective_cap), 1) if raw is not None else None
core_data_reasons = _core_data_reasons(alpha_subs)
blocking_reasons = gate_reasons + llm_reasons + core_data_reasons
# plan_blocking 决定交易计划分支;timing 降级原因只进输出(时机过热仍给回踩计划价)
plan_blocking = gate_reasons + llm_reasons + core_data_reasons
# 确认 overlay(technical / volume / trend_quality,IC≈0:不加分,只在转弱时封顶买入)
tech_s, vol_s = _f_technical(a), _f_volume_exec(a)
@@ -75,23 +76,54 @@ def score(
"minimums_pct": {k: int(v * 100) for k, v in CONFIRMATION_MIN.items()},
}
if composite is None:
verdict, action = "无法评分", "补充数据后重评"
elif core_data_reasons:
verdict, action = "无法评分", "补齐 SPY/QQQ 相对强度数据后重评"
elif composite >= 75 and not gate_reasons and confirmation_ok:
# gate_reasons redundant here: any gate caps to <=70, so composite>=75 implies
# no gates fired. Kept as defensive guard.
verdict, action = "是", "可关注 / 小仓位试探"
elif composite >= 75 and not gate_reasons:
verdict, action = "观察", "观察等待(技术未确认,买入封顶)"
elif composite >= 60:
verdict, action = "观察", "观察等待"
else:
verdict, action = "否", "回避"
# 入场时机层(analyze_symbol 计算):过热只降级、拐点可替代趋势热度确认。
# 不改 composite(保持 IC 校准的排名分),只作用于 verdict/action/交易计划。
timing = a.get("entry_timing") or {}
timing_class = timing.get("classification")
timing_reasons: list[str] = []
# 拐点入场的替代确认:波动确认仍须达标(反弹要有参与度),趋势热度类确认
# (technical/trend_quality)在回调转折处定义性偏低,由反转触发信号替代。
# 数据诚实:量能缺失不是通过。拐点替代 technical/trend_quality,但 volume
# 必须明确可算且达标,否则维持观察。
vol_min_ok = vol_s is not None and vol_s >= CONFIRMATION_MIN["volume"]
pullback_entry_ok = timing_class == "pullback_reversal" and vol_min_ok
# 先计算原三层结论,再让第四层做单向过热降级或受约束的拐点替代。
# 显式保留 base_verdict,供 forward log 对第四层做 A/B,避免只看到最终结果。
if composite is None:
base_verdict, base_action = "无法评分", "补充数据后重评"
elif core_data_reasons:
base_verdict, base_action = "无法评分", "补齐 SPY/QQQ 相对强度数据后重评"
elif composite >= 75 and not gate_reasons and confirmation_ok:
base_verdict, base_action = "是", "可关注 / 小仓位试探"
elif composite >= 75 and not gate_reasons:
base_verdict, base_action = "观察", "观察等待(技术未确认,买入封顶)"
elif composite >= 60:
base_verdict, base_action = "观察", "观察等待"
else:
base_verdict, base_action = "否", "回避"
verdict, action = base_verdict, base_action
if base_verdict == "是" and timing_class == "overextended":
verdict, action = "观察", "观察等待(入场时机过热,等回踩计划价)"
ext = timing.get("ext_atr_above_ma20")
r14 = timing.get("rsi14")
timing_reasons.append(
"入场时机过热:价格较 MA20 超出约 %s 倍 ATR、RSI14 %s——追高风险大,"
"新开降级为观察,等回踩计划价或横盘消化后重评"
% (ext if ext is not None else "无法确认", r14 if r14 is not None else "无法确认")
)
elif base_verdict == "是" and timing_class == "pullback_reversal":
action = "回调拐点入场(分批,跌破反弹起点低点退出)"
elif (
base_verdict == "观察" and composite is not None and composite >= 75
and not gate_reasons and pullback_entry_ok
):
verdict, action = "是", "回调拐点入场(分批,反转触发替代趋势热度确认)"
blocking_reasons = plan_blocking + timing_reasons
suggested_position = _position_pct(a, composite)
trade_plan = _trade_plan(a, verdict, blocking_reasons, confirmation_ok)
trade_plan = _trade_plan(a, verdict, plan_blocking, confirmation_ok, timing_class)
account_overlay, account_verdict, account_action = _account_overlay(
a, verdict, suggested_position, trade_plan, account_context
)
@@ -112,9 +144,14 @@ def score(
"composite": composite,
"raw_composite": raw, # 未经否决封顶的原始分
"verdict": verdict,
"verdict_before_entry_timing": base_verdict,
"suggested_action": action,
"factor_breakdown": breakdown,
"confirmation": confirmation, # technical/volume:确认 overlay,不进排名分
"entry_timing": ({ # 入场时机层回显:分类与时机分(详情在分析对象)
"classification": timing_class,
"timing_score_pct": timing.get("timing_score_pct"),
} if timing else None),
"risk_gates": gate_reasons,
"blocking_reasons": blocking_reasons, # 面向输出:价格/趋势闸门 + 新闻面降级原因
"cap_applied": effective_cap if effective_cap < 100 else None,
+2 -1
View File
@@ -117,9 +117,10 @@ def _core_data_reasons(alpha_subs: dict[str, float | None]) -> list[str]:
def _confirmation_ok(tech_s: float | None, vol_s: float | None, tq_s: float | None) -> bool:
"""All confirmation dimensions must be available and meet their minimums."""
checks = (
(tech_s, CONFIRMATION_MIN["technical"]),
(vol_s, CONFIRMATION_MIN["volume"]),
(tq_s, CONFIRMATION_MIN["trend_quality"]),
)
return all(value is None or value >= threshold for value, threshold in checks)
return all(value is not None and value >= threshold for value, threshold in checks)
+14 -1
View File
@@ -50,6 +50,7 @@ def _trade_plan(
verdict: str,
blocking_reasons: list[str],
confirmation_ok: bool,
timing_class: str | None = None,
) -> dict[str, Any]:
"""Deterministic entry/exit plan from ATR + MA60 + 30-day structure.
@@ -73,6 +74,11 @@ def _trade_plan(
stop_candidates = [last - 2 * unit]
if ma60 and 0 < ma60 < last:
stop_candidates.append(ma60 * 0.98)
# 回调拐点入场:反弹起点低点是更贴近的结构性止损位
if timing_class == "pullback_reversal":
recent_low = _num(((a.get("entry_timing") or {}).get("pullback") or {}).get("recent_low_10d"))
if recent_low and 0 < recent_low < last:
stop_candidates.append(recent_low * 0.99)
stop_candidates = [s for s in stop_candidates if s and 0 < s < last]
stop_loss = max(stop_candidates) if stop_candidates else last * 0.92
# Ensure stop has >=3% buffer below last close to absorb daily noise.
@@ -92,7 +98,14 @@ def _trade_plan(
if verdict == "是":
status = "entry_allowed"
entry = last
note = "趋势与确认通过,可按当前价附近分批;超过追价上限则等待回踩或突破确认"
if timing_class == "pullback_reversal":
note = "回调拐点确认:按当前价附近分批,跌破反弹起点低点或止损价即退出,不追离场后的反抽"
else:
note = "趋势与确认通过,可按当前价附近分批;超过追价上限则等待回踩或突破确认"
elif verdict == "观察" and not blocking_reasons and timing_class == "overextended":
status = "wait_pullback"
entry = pullback_entry
note = "评分达标但入场时机过热:不追当前价,只在回踩计划价附近或横盘消化后接回"
elif verdict == "观察" and not blocking_reasons and confirmation_ok is False:
status = "wait_confirmation"
entry = pullback_entry
+106
View File
@@ -106,6 +106,112 @@ class TestOtherContracts(unittest.TestCase):
result = C.validate_payload(C.KIND_RESULT, payload)
self.assertEqual(result["scored_symbols_count"], 1)
def test_result_allows_null_account_overlay_when_disabled(self):
payload = {
"symbols": {
"AAPL": {
"score": {
"verdict": "观察",
"composite": 68.5,
"blocking_reasons": [],
"trade_plan": {},
"account_overlay": None,
"llm_overlay": None,
}
}
}
}
result = C.validate_payload(C.KIND_RESULT, payload)
self.assertEqual(result["scored_symbols_count"], 1)
def test_result_rejects_non_object_account_overlay(self):
payload = {
"symbols": {
"AAPL": {
"score": {
"verdict": "观察",
"composite": 68.5,
"blocking_reasons": [],
"trade_plan": {},
"account_overlay": "unavailable",
}
}
}
}
with self.assertRaises(C.ContractError):
C.validate_payload(C.KIND_RESULT, payload)
class TestAiOpinionContract(unittest.TestCase):
def _ok_symbol(self, **overrides):
base = {
"status": "ok",
"stance": "buy",
"confidence": "medium",
"view": "趋势与相对强度均领先,量价确认充分",
"key_risks": ["财报临近"],
"agreement": "agree",
"model": "claude-opus-4-8",
"generated_at": "2026-07-08T00:00:00Z",
}
base.update(overrides)
return base
def test_ok_wrapper_with_multiple_symbols(self):
payload = {
"contract_version": C.CONTRACT_VERSION,
"kind": C.KIND_AI_OPINION,
"status": "ok",
"symbols": {
"AAPL": self._ok_symbol(),
"MSFT": self._ok_symbol(stance="watch", agreement="diverge"),
},
}
result = C.validate_payload(C.KIND_AI_OPINION, payload)
self.assertEqual(result["symbols_count"], 2)
self.assertEqual(result["opined_symbols_count"], 2)
def test_top_level_unavailable_is_valid(self):
payload = {
"contract_version": C.CONTRACT_VERSION,
"kind": C.KIND_AI_OPINION,
"status": "unavailable",
"reason": "ANTHROPIC_API_KEY not configured",
}
self.assertEqual(C.validate_payload(C.KIND_AI_OPINION, payload)["symbols_count"], 0)
def test_per_symbol_unavailable_is_valid(self):
payload = {
"status": "ok",
"symbols": {
"AAPL": self._ok_symbol(),
"MSFT": {"status": "unavailable", "reason": "api timeout"},
},
}
result = C.validate_payload(C.KIND_AI_OPINION, payload)
self.assertEqual(result["symbols_count"], 2)
self.assertEqual(result["opined_symbols_count"], 1)
def test_invalid_stance_rejected(self):
payload = {"status": "ok", "symbols": {"AAPL": self._ok_symbol(stance="strong_buy")}}
with self.assertRaises(C.ContractError):
C.validate_payload(C.KIND_AI_OPINION, payload)
def test_invalid_agreement_rejected(self):
payload = {"status": "ok", "symbols": {"AAPL": self._ok_symbol(agreement="maybe")}}
with self.assertRaises(C.ContractError):
C.validate_payload(C.KIND_AI_OPINION, payload)
def test_missing_view_rejected(self):
payload = {"status": "ok", "symbols": {"AAPL": self._ok_symbol(view="")}}
with self.assertRaises(C.ContractError):
C.validate_payload(C.KIND_AI_OPINION, payload)
def test_per_symbol_unavailable_requires_reason(self):
payload = {"status": "ok", "symbols": {"AAPL": {"status": "unavailable"}}}
with self.assertRaises(C.ContractError):
C.validate_payload(C.KIND_AI_OPINION, payload)
class TestValidateCli(unittest.TestCase):
def test_cli_returns_json_status(self):
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Tests for the display-only AI opinion overlay (no real network)."""
import json
import os
import sys
import unittest
from unittest import mock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
import agent_contracts as C # noqa: E402
import ai_opinion # noqa: E402
def _analysis(verdict="是"):
return {
"last_close": 100.0,
"last_date": "2026-07-07",
"bars_count": 300,
"ma": {"MA60": 95.0, "MA200": 90.0, "above_MA60": True, "above_MA200": True},
"weekly": {"bearish_alignment": False},
"structure_30d": {"range_position_pct": 80.0, "dist_to_30d_high_pct": -1.5},
"returns_pct": {"r1m_21d": 4.0, "r3m_63d": 12.0, "r6m_126d": 25.0},
"relative_strength_pct": {"SPY": {"r3m_63d": 5.0}, "QQQ": {"r3m_63d": 3.0}},
"momentum": {"m12_1_pct": 30.0},
"volatility": {"ann_vol_3m_pct": 25.0},
"risk": {"max_drawdown_6m_pct": -12.0},
"trend_quality": {"adx": 28.0},
"macd": {"hist": 0.5},
"rsi": {"RSI14": 62.0},
"kdj": {"K": 70.0},
"volume": {"ratio_vs_ma20": 1.2},
"score": {
"verdict": verdict,
"composite": 81.0,
"raw_composite": 85.0,
"blocking_reasons": [],
"trade_plan": {"entry": 99.0, "stop": 93.0},
"account_overlay": {},
"suggested_position_pct": 10,
"data_flags": ["低流动性"],
},
}
def _result(verdict="是"):
return {
"feed": "iex",
"adjustment": "split",
"symbols": {
"AAPL": _analysis(verdict),
"SPY": {"last_close": 500.0, "score": {"verdict": "观察"}},
"QQQ": {"last_close": 400.0},
},
}
def _model_reply(stance="buy", confidence="medium"):
return {
"stance": stance,
"confidence": confidence,
"view": "趋势与相对强度领先,量价确认充分",
"key_risks": ["财报临近"],
}
def _available(test):
"""Patch SDK/key availability on for the duration of a test."""
for target, value in (("sdk_available", True), ("has_api_key", True)):
p = mock.patch.object(ai_opinion, target, return_value=value)
p.start()
test.addCleanup(p.stop)
class TestEvidencePayload(unittest.TestCase):
def test_evidence_never_contains_conclusion_fields(self):
"""防锚定红线:证据里不得出现 verdict/composite/trade_plan 等结论字段。"""
evidence = ai_opinion.build_evidence(
_analysis(), market_risk_off=None,
news_context={"red_flags": [{"type": "dilution", "severity": "medium", "note": "x"}]},
)
blob = json.dumps(evidence, ensure_ascii=False)
for forbidden in ("verdict", "composite", "trade_plan", "suggested_position",
"blocking_reasons", "account_overlay", "score"):
self.assertNotIn(forbidden, blob, f"evidence must not contain {forbidden}")
def test_evidence_keeps_whitelisted_indicators_and_facts(self):
evidence = ai_opinion.build_evidence(
_analysis(), market_risk_off=True,
news_context={"red_flags": [{"type": "dilution"}], "catalyst": "earnings"},
)
for key in ("ma", "weekly", "structure_30d", "relative_strength_pct", "volume"):
self.assertIn(key, evidence)
self.assertTrue(evidence["market_risk_off"])
self.assertEqual(evidence["news"]["catalyst"], "earnings")
self.assertEqual(evidence["data_flags"], ["低流动性"])
class TestAgreement(unittest.TestCase):
def test_mapping_table(self):
cases = [
("是", "buy", "agree"), ("是", "watch", "diverge"), ("是", "avoid", "diverge"),
("观察", "watch", "agree"), ("观察", "buy", "diverge"),
("否", "avoid", "agree"), ("否", "buy", "diverge"),
("持仓需减风险", "avoid", "agree"), ("持仓需减风险", "buy", "diverge"),
("无法评分", "buy", "not_applicable"), (None, "buy", "not_applicable"),
]
for verdict, stance, expected in cases:
self.assertEqual(
ai_opinion.compute_agreement(verdict, stance), expected,
f"verdict={verdict} stance={stance}",
)
def test_unavailable_opinions_checks_each_requested_symbol(self):
artifact = {
"status": "ok",
"symbols": {
"AAPL": {"status": "ok"},
"MSFT": {"status": "unavailable", "reason": "timeout"},
},
}
self.assertEqual(
ai_opinion.unavailable_opinions(artifact, ["AAPL", "MSFT", "NVDA"]),
{"MSFT": "timeout", "NVDA": "AI 意见条目缺失"},
)
class TestOpinionsForResult(unittest.TestCase):
def test_no_key_returns_unavailable_without_calling_model(self):
with mock.patch.object(ai_opinion, "sdk_available", return_value=True), \
mock.patch.object(ai_opinion, "has_api_key", return_value=False), \
mock.patch.object(ai_opinion, "_call_model") as call:
artifact = ai_opinion.opinions_for_result(_result())
self.assertEqual(artifact["status"], "unavailable")
call.assert_not_called()
def test_missing_sdk_returns_unavailable(self):
with mock.patch.object(ai_opinion, "sdk_available", return_value=False):
artifact = ai_opinion.opinions_for_result(_result())
self.assertEqual(artifact["status"], "unavailable")
self.assertIn("SDK", artifact["reason"])
def test_ok_path_skips_benchmarks_and_validates_contract(self):
_available(self)
with mock.patch.object(ai_opinion, "_call_model", return_value=_model_reply()) as call:
artifact = ai_opinion.opinions_for_result(_result())
C.validate_payload(C.KIND_AI_OPINION, artifact)
self.assertEqual(set(artifact["symbols"]), {"AAPL"}) # SPY/QQQ 不调用
self.assertEqual(call.call_count, 1)
entry = artifact["symbols"]["AAPL"]
self.assertEqual(entry["agreement"], "agree")
self.assertEqual(entry["model"], ai_opinion.MODEL)
def test_diverge_is_computed_by_script(self):
_available(self)
with mock.patch.object(ai_opinion, "_call_model", return_value=_model_reply(stance="avoid")):
artifact = ai_opinion.opinions_for_result(_result(verdict="是"))
self.assertEqual(artifact["symbols"]["AAPL"]["agreement"], "diverge")
def test_unscorable_symbol_never_hits_the_api(self):
_available(self)
with mock.patch.object(ai_opinion, "_call_model") as call:
artifact = ai_opinion.opinions_for_result(_result(verdict="无法评分"))
call.assert_not_called()
entry = artifact["symbols"]["AAPL"]
self.assertEqual(entry["status"], "unavailable")
C.validate_payload(C.KIND_AI_OPINION, artifact)
def test_api_failure_degrades_symbol_only(self):
_available(self)
result = _result()
result["symbols"]["MSFT"] = _analysis("观察")
replies = {"AAPL": _model_reply()}
def flaky(symbol, evidence):
if symbol in replies:
return replies[symbol]
raise TimeoutError("read timeout")
with mock.patch.object(ai_opinion, "_call_model", side_effect=flaky):
artifact = ai_opinion.opinions_for_result(result)
self.assertEqual(artifact["status"], "ok")
self.assertEqual(artifact["symbols"]["AAPL"]["status"], "ok")
self.assertEqual(artifact["symbols"]["MSFT"]["status"], "unavailable")
self.assertIn("TimeoutError", artifact["symbols"]["MSFT"]["reason"])
C.validate_payload(C.KIND_AI_OPINION, artifact)
def test_invalid_model_output_degrades(self):
_available(self)
with mock.patch.object(ai_opinion, "_call_model", return_value=_model_reply(stance="strong_buy")):
artifact = ai_opinion.opinions_for_result(_result())
self.assertEqual(artifact["symbols"]["AAPL"]["status"], "unavailable")
def test_symbols_filter(self):
_available(self)
result = _result()
result["symbols"]["MSFT"] = _analysis("观察")
with mock.patch.object(ai_opinion, "_call_model", return_value=_model_reply()):
artifact = ai_opinion.opinions_for_result(result, symbols=["MSFT"])
self.assertEqual(set(artifact["symbols"]), {"MSFT"})
if __name__ == "__main__":
unittest.main()
+100 -2
View File
@@ -58,15 +58,82 @@ class TestBuckets(unittest.TestCase):
def test_quintile_spread(self):
pairs = [(i, float(i)) for i in range(10)] # 完全单调
spread, top, bot = B.quintile_spread(pairs)
self.assertGreater(spread, 0)
gross, net, top, bot = B.quintile_spread(pairs)
self.assertGreater(gross, 0)
# 10 个样本 -> 五分位各 2 个:top=(8+9)/2,bot=(0+1)/2
self.assertEqual((top, bot), (8.5, 0.5))
# 净价差 = 毛 − 4×单边成本(默认 10bp → 0.4%)
self.assertAlmostEqual(net, gross - 0.4)
def test_quintile_net_respects_cost_param(self):
pairs = [(i, float(i)) for i in range(10)]
gross, net, _, _ = B.quintile_spread(pairs, cost_bps_oneway=25.0)
self.assertAlmostEqual(gross - net, 1.0)
def test_quintile_too_few_is_none(self):
self.assertIsNone(B.quintile_spread([(1, 1.0)] * 5))
class TestBootstrapCI(unittest.TestCase):
def test_deterministic_with_fixed_seed(self):
vals = [0.1, -0.05, 0.2, 0.08, -0.02, 0.15]
self.assertEqual(B.bootstrap_ci(vals), B.bootstrap_ci(vals))
def test_interval_brackets_sample_mean(self):
vals = [0.1, -0.05, 0.2, 0.08, -0.02, 0.15, 0.05, 0.12]
lo, hi = B.bootstrap_ci(vals)
mean = sum(vals) / len(vals)
self.assertLess(lo, mean)
self.assertGreater(hi, mean)
def test_constant_series_degenerate_interval(self):
lo, hi = B.bootstrap_ci([0.5, 0.5, 0.5, 0.5])
self.assertEqual((lo, hi), (0.5, 0.5))
def test_too_few_is_none(self):
self.assertIsNone(B.bootstrap_ci([0.1, 0.2]))
class TestSectorDemean(unittest.TestCase):
def test_removes_sector_mean_keeps_within_sector_order(self):
triples = [
(90, 10.0, "tech"), (80, 6.0, "tech"), # tech 均值 8
(70, -2.0, "energy"), (60, -6.0, "energy"), # energy 均值 -4
]
out = dict((s, f) for s, f in B.sector_demean(triples))
self.assertAlmostEqual(out[90], 2.0)
self.assertAlmostEqual(out[80], -2.0)
self.assertAlmostEqual(out[70], 2.0)
self.assertAlmostEqual(out[60], -2.0)
# 行业间差异被消除:两行业去均值后的组内均值都为 0
self.assertAlmostEqual(out[90] + out[80], 0.0)
self.assertAlmostEqual(out[70] + out[60], 0.0)
def test_singleton_sector_is_dropped(self):
triples = [(90, 10.0, "tech"), (80, 6.0, "tech"), (70, 5.0, "lonely")]
out = B.sector_demean(triples)
self.assertEqual(len(out), 2)
self.assertNotIn(70, [s for s, _ in out])
class TestUniverseRules(unittest.TestCase):
def test_every_universe_symbol_has_sector(self):
missing = [s for s in B.UNIVERSE if s not in B.SECTOR]
self.assertEqual(missing, [])
def test_basket_size_and_sector_coverage(self):
self.assertGreaterEqual(len(B.ALIVE), 120)
sectors = {}
for s in B.ALIVE:
sectors.setdefault(B.SECTOR[s], []).append(s)
self.assertGreaterEqual(len(sectors), 10)
for name, syms in sectors.items():
self.assertGreaterEqual(len(syms), 7, f"行业 {name} 票数不足")
def test_bankrupt_is_subset_of_delisted(self):
self.assertTrue(B.BANKRUPT.issubset(set(B.DELISTED)))
class TestPanelReturns(unittest.TestCase):
def test_fwd_return_uses_last_available_post_entry_close(self):
bars = {
@@ -83,6 +150,37 @@ class TestPanelReturns(unittest.TestCase):
panel = B.Panel(bars, "iex", "split")
self.assertEqual(panel.fwd_return_pct("AAPL", 0, 2), -50.0)
@staticmethod
def _panel_with_halted(sym):
"""sym 在第 3 个交易日后停牌(最后价 60,入场价 100)。"""
bars = {
"SPY": [{"t": f"2026-01-0{d}T00:00:00Z", "c": 400 + d} for d in range(1, 7)],
sym: [
{"t": "2026-01-01T00:00:00Z", "c": 100},
{"t": "2026-01-02T00:00:00Z", "c": 80},
{"t": "2026-01-03T00:00:00Z", "c": 60},
],
}
return B.Panel(bars, "iex", "split")
def test_bankrupt_symbol_counts_as_total_loss_after_halt(self):
panel = self._panel_with_halted("SIVB") # SIVB ∈ BANKRUPT
self.assertEqual(panel.fwd_return_pct("SIVB", 0, 5), -100.0)
def test_merged_symbol_keeps_last_close_terminal_value(self):
panel = self._panel_with_halted("ATVI") # 被并购:溢价退出,最后价即终值
self.assertEqual(panel.fwd_return_pct("ATVI", 0, 5), -40.0)
def test_bankrupt_symbol_still_trading_uses_real_close(self):
panel = self._panel_with_halted("SIVB")
# 停牌前(target 日仍有价):正常口径,不提前记 -100
self.assertEqual(panel.fwd_return_pct("SIVB", 0, 2), -40.0)
def test_bankrupt_symbol_missing_entry_price_is_none(self):
panel = self._panel_with_halted("SIVB")
# 入场日在停牌之后:无 c0,返回 None 而非 -100
self.assertIsNone(panel.fwd_return_pct("SIVB", 4, 1))
class TestFactorICFeatures(unittest.TestCase):
def test_trend_factor_is_computed_outside_alpha_breakdown(self):
+35 -4
View File
@@ -198,22 +198,53 @@ class TestFinnhubLLMContext(unittest.TestCase):
self.assertEqual(llm["AAPL"]["red_flags"][0]["type"], "dilution")
self.assertEqual(llm["AAPL"]["sources"][0]["id"], "finnhub_news_6")
def test_nearby_earnings_is_low_severity_event_only(self):
@staticmethod
def _earnings_llm(earnings_date, today):
ctx = F.normalize_finnhub_context({
"symbols": {
"AAPL": {
"status": "ok",
"earnings": [{"date": "2026-07-01", "hour": "amc"}],
"earnings": [{"date": earnings_date, "hour": "amc"}],
"data_flags": [],
}
}
})
llm = F.llm_context_from_finnhub(ctx, today=date(2026, 6, 27))
return F.llm_context_from_finnhub(ctx, today=today)
def test_earnings_within_soft_cap_window_is_medium(self):
# 4 天后财报(≤7)→ medium,经 LLM overlay 封顶 74(是→观察)
llm = self._earnings_llm("2026-07-01", date(2026, 6, 27))
rf = llm["AAPL"]["red_flags"][0]
self.assertEqual(rf["type"], "earnings_event")
self.assertEqual(rf["severity"], "low")
self.assertEqual(rf["severity"], "medium")
self.assertIn("2026-07-01", llm["AAPL"]["catalyst"])
def test_earnings_on_boundary_day7_is_medium(self):
llm = self._earnings_llm("2026-07-04", date(2026, 6, 27))
self.assertEqual(llm["AAPL"]["red_flags"][0]["severity"], "medium")
def test_earnings_same_day_is_medium(self):
llm = self._earnings_llm("2026-06-27", date(2026, 6, 27))
self.assertEqual(llm["AAPL"]["red_flags"][0]["severity"], "medium")
def test_earnings_day8_to_14_is_low_informational(self):
llm = self._earnings_llm("2026-07-05", date(2026, 6, 27)) # 8 天
self.assertEqual(llm["AAPL"]["red_flags"][0]["severity"], "low")
llm = self._earnings_llm("2026-07-11", date(2026, 6, 27)) # 14 天
self.assertEqual(llm["AAPL"]["red_flags"][0]["severity"], "low")
def test_earnings_beyond_window_is_dropped(self):
llm = self._earnings_llm("2026-07-12", date(2026, 6, 27)) # 15 天
self.assertEqual(llm, {})
def test_medium_earnings_flag_caps_verdict_via_llm_overlay(self):
# 端到端语义锁定:medium 财报红旗必须把封顶压到 74(是 不可能成立)
import scoring
llm = self._earnings_llm("2026-07-01", date(2026, 6, 27))
cap, reasons, _ = scoring._llm_overlay(llm["AAPL"])
self.assertEqual(cap, scoring.LLM_CAP_SOFT)
self.assertTrue(any("财报" in r for r in reasons))
def test_merge_appends_sources_and_red_flags(self):
primary = {"AAPL": {"sources": [{"id": "manual"}],
"red_flags": [{"type": "manual", "severity": "medium"}],
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""forward_log.py 纯函数的离线测试(merge/evaluate/bucket,不触网)。"""
import os
import sys
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
import forward_log as F # noqa: E402
def _rec(run_date, symbol, verdict="是", last_close=100.0, version="v1.0", **kw):
return {"run_date": run_date, "model_version": version, "symbol": symbol,
"composite": 80.0, "verdict": verdict, "market_risk_off": False,
"last_close": last_close, **kw}
class TestMergeRecords(unittest.TestCase):
def test_same_day_rerun_overwrites(self):
old = [_rec("2026-07-09", "AAPL", composite=70.0)]
new = [_rec("2026-07-09", "AAPL", composite=81.0)]
merged = F.merge_records(old, new)
self.assertEqual(len(merged), 1)
self.assertEqual(merged[0]["composite"], 81.0)
def test_history_is_append_only(self):
old = [_rec("2026-07-01", "AAPL")]
new = [_rec("2026-07-09", "AAPL")]
self.assertEqual(len(F.merge_records(old, new)), 2)
def test_different_version_not_overwritten(self):
old = [_rec("2026-07-09", "AAPL", version="v1.0")]
new = [_rec("2026-07-09", "AAPL", version="v1.1")]
self.assertEqual(len(F.merge_records(old, new)), 2)
class TestEvaluateRecords(unittest.TestCase):
"""5 个交易日的合成日历,horizon=2:d1 记录到期,d4 记录未到期。"""
def setUp(self):
self.calendar = [f"2026-07-0{d}" for d in range(1, 6)]
self.closes = {
"SPY": {d: 100.0 + i for i, d in enumerate(self.calendar)}, # d1=100,d3=102
"AAPL": {d: 50.0 * (1 + i) for i, d in enumerate(self.calendar)},
"HALT": {"2026-07-01": 10.0, "2026-07-02": 8.0}, # d2 后停牌
}
def test_matured_and_immature_split(self):
records = [
_rec("2026-07-01", "AAPL", last_close=50.0), # 到期:entry d1,target d3=150
_rec("2026-07-04", "AAPL", last_close=200.0), # 未到期:d4+2 越界
]
res = F.evaluate_records(records, self.closes, self.calendar, horizon=2)
self.assertEqual(len(res["matured"]), 1)
self.assertEqual(res["immature_count"], 1)
m = res["matured"][0]
self.assertAlmostEqual(m["realized_pct"], 200.0) # 50→150
self.assertAlmostEqual(m["spy_pct"], 2.0) # 100→102
self.assertAlmostEqual(m["excess_pct"], 198.0)
def test_halted_symbol_uses_last_available_close(self):
records = [_rec("2026-07-01", "HALT", last_close=10.0)]
res = F.evaluate_records(records, self.closes, self.calendar, horizon=2)
self.assertEqual(len(res["matured"]), 1)
self.assertAlmostEqual(res["matured"][0]["realized_pct"], -20.0) # 10→8
def test_no_matured_records(self):
records = [_rec("2026-07-05", "AAPL", last_close=250.0)]
res = F.evaluate_records(records, self.closes, self.calendar, horizon=2)
self.assertEqual(res["matured"], [])
self.assertEqual(res["immature_count"], 1)
def test_missing_entry_price_is_invalid(self):
records = [_rec("2026-07-01", "AAPL", last_close=None)]
res = F.evaluate_records(records, self.closes, self.calendar, horizon=2)
self.assertEqual(res["invalid_count"], 1)
def test_v11_uses_next_session_close_for_stock_and_spy(self):
records = [_rec(
"2026-07-02", "AAPL", last_close=999.0, version="v1.1",
signal_date="2026-07-01", entry_timing="trend_continuation",
)]
res = F.evaluate_records(records, self.closes, self.calendar, horizon=2)
m = res["matured"][0]
self.assertEqual(m["entry_date"], "2026-07-02")
self.assertEqual(m["entry_price"], 100.0) # 忽略记录中的 999,使用同日真实收盘
self.assertEqual(m["evaluation_method"], "next_session_close")
self.assertAlmostEqual(m["spy_pct"], round((103.0 / 101.0 - 1) * 100, 2))
def test_v11_missing_next_session_stock_price_is_invalid(self):
records = [_rec(
"2026-07-02", "HALT", version="v1.1", signal_date="2026-07-02",
entry_timing="pullback_reversal",
)]
res = F.evaluate_records(records, self.closes, self.calendar, horizon=2)
self.assertEqual(res["matured"], [])
self.assertEqual(res["invalid_count"], 1)
class TestBucketStats(unittest.TestCase):
def test_grouped_by_version_and_verdict(self):
matured = [
{**_rec("2026-07-01", "A"), "realized_pct": 10.0, "excess_pct": 4.0},
{**_rec("2026-07-01", "B"), "realized_pct": 20.0, "excess_pct": 6.0},
{**_rec("2026-07-01", "C", verdict="否"), "realized_pct": -5.0, "excess_pct": -8.0},
{**_rec("2026-07-01", "D", version="v1.1"), "realized_pct": 99.0, "excess_pct": 99.0},
]
st = F.bucket_stats(matured)
self.assertEqual(st["v1.0/是"], {"n": 2, "mean_pct": 15.0, "mean_excess_pct": 5.0})
self.assertEqual(st["v1.0/否"]["n"], 1)
self.assertIn("v1.1/是", st) # 跨版本不混算
def test_timing_buckets_are_versioned(self):
matured = [
{**_rec("2026-07-01", "A", version="v1.1", entry_timing="overextended"),
"realized_pct": 2.0, "excess_pct": -1.0},
{**_rec("2026-07-01", "B", version="v1.1", entry_timing="pullback_reversal"),
"realized_pct": 8.0, "excess_pct": 4.0},
{**_rec("2026-07-01", "C", version="v1.0"),
"realized_pct": 3.0, "excess_pct": 0.0},
]
stats = F.timing_bucket_stats(matured)
self.assertEqual(stats["v1.1/overextended"]["mean_pct"], 2.0)
self.assertEqual(stats["v1.1/pullback_reversal"]["mean_excess_pct"], 4.0)
self.assertIn("v1.0/unknown", stats)
class TestJsonlRoundtrip(unittest.TestCase):
def test_write_and_load(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
path = os.path.join(td, "data", "log.jsonl")
rows = [_rec("2026-07-09", "AAPL"), _rec("2026-07-09", "MSFT")]
F.write_jsonl(path, rows)
self.assertEqual(F.load_jsonl(path), rows)
def test_load_missing_file_is_empty(self):
self.assertEqual(F.load_jsonl("/nonexistent/x.jsonl"), [])
if __name__ == "__main__":
unittest.main()
+147
View File
@@ -373,5 +373,152 @@ class TestBuildResultInput(unittest.TestCase):
)
class TestAiOpinionIntegration(unittest.TestCase):
"""AI 意见 overlay 的接入测试:只进 supplemental,绝不改 score。"""
def _result(self):
closes = [100.0 * (1.004 ** i) for i in range(260)]
flat = [400.0 * (1.0005 ** i) for i in range(260)]
bars = {"WIN": _bars(closes), "SPY": _bars(flat), "QQQ": _bars(flat)}
return I.build_result(["WIN", "SPY", "QQQ"], bars, "iex", "split")
def _artifact(self, sym="WIN"):
return {
"contract_version": "worth-buy-stocks.agent.v1",
"kind": "ai_opinion",
"status": "ok",
"symbols": {
sym: {
"status": "ok",
"stance": "buy",
"confidence": "medium",
"view": "趋势与相对强度领先",
"key_risks": [],
"agreement": "agree",
"model": "claude-opus-4-8",
"generated_at": "2026-07-08T00:00:00Z",
}
},
}
def _args(self, **overrides):
base = {"ai_opinion": "auto", "ai_opinion_context_file": None, "input": None}
base.update(overrides)
return SimpleNamespace(**base)
def test_load_ai_opinion_context_file(self):
f = tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False)
json.dump(self._artifact(), f)
f.close()
try:
ctx = I._load_ai_opinion_context(f.name)
finally:
os.unlink(f.name)
self.assertEqual(ctx["status"], "ok")
self.assertEqual(ctx["symbols"]["WIN"]["stance"], "buy")
def test_invalid_ai_opinion_context_returns_unavailable(self):
bad = {"kind": "ai_opinion", "status": "ok", "symbols": {"WIN": {"status": "ok", "stance": "moon"}}}
f = tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False)
json.dump(bad, f)
f.close()
try:
ctx = I._load_ai_opinion_context(f.name)
finally:
os.unlink(f.name)
self.assertEqual(ctx["status"], "unavailable")
def test_apply_attaches_supplemental_and_score_is_untouched(self):
res = self._result()
score_before = json.dumps(res["symbols"]["WIN"]["score"], sort_keys=True)
I._apply_ai_opinion(res, self._artifact())
self.assertEqual(res["supplemental"]["ai_opinion"]["status"], "ok")
self.assertEqual(
res["symbols"]["WIN"]["supplemental"]["ai_opinion"]["stance"], "buy"
)
self.assertNotIn("ai_opinion", res["symbols"]["WIN"]["score"])
self.assertEqual(
json.dumps(res["symbols"]["WIN"]["score"], sort_keys=True), score_before
)
def test_apply_recomputes_untrusted_offline_agreement(self):
res = self._result()
artifact = self._artifact()
artifact["symbols"]["WIN"]["stance"] = "avoid"
artifact["symbols"]["WIN"]["agreement"] = "agree"
I._apply_ai_opinion(res, artifact)
self.assertEqual(
res["symbols"]["WIN"]["supplemental"]["ai_opinion"]["agreement"],
"diverge",
)
def test_apply_none_leaves_result_byte_identical(self):
res, ref = self._result(), self._result()
I._apply_ai_opinion(res, None)
self.assertEqual(json.dumps(res, sort_keys=True), json.dumps(ref, sort_keys=True))
def test_apply_unavailable_writes_top_level_reason_only(self):
res = self._result()
I._apply_ai_opinion(res, {"status": "unavailable", "reason": "no key"})
self.assertEqual(res["supplemental"]["ai_opinion"]["status"], "unavailable")
self.assertNotIn("supplemental", res["symbols"]["WIN"])
def test_off_and_offline_default_do_not_generate(self):
res = self._result()
self.assertIsNone(I._ai_opinion_from_args(self._args(ai_opinion="off"), res, None))
self.assertIsNone(I._ai_opinion_from_args(self._args(input="bars.json"), res, None))
def test_auto_without_key_does_not_call_model(self):
res = self._result()
with patch.object(I, "ai_opinion_has_api_key", return_value=False), \
patch.object(I, "opinions_for_result") as gen:
self.assertIsNone(I._ai_opinion_from_args(self._args(), res, None))
gen.assert_not_called()
def test_auto_with_key_generates_and_threads_llm_context(self):
res = self._result()
llm_ctx = {"WIN": {"red_flags": []}}
with patch.object(I, "ai_opinion_sdk_available", return_value=True), \
patch.object(I, "ai_opinion_has_api_key", return_value=True), \
patch.object(I, "opinions_for_result", return_value=self._artifact()) as gen:
artifact = I._ai_opinion_from_args(self._args(), res, llm_ctx)
self.assertEqual(artifact["status"], "ok")
gen.assert_called_once_with(res, llm_context=llm_ctx)
def test_on_raises_when_generation_unavailable(self):
res = self._result()
with patch.object(I, "ai_opinion_sdk_available", return_value=True), \
patch.object(I, "ai_opinion_has_api_key", return_value=True), \
patch.object(I, "opinions_for_result",
return_value={"status": "unavailable", "reason": "boom"}):
with self.assertRaises(RuntimeError):
I._ai_opinion_from_args(self._args(ai_opinion="on"), res, None)
def test_on_raises_when_symbol_generation_is_unavailable(self):
res = self._result()
artifact = self._artifact()
artifact["symbols"]["WIN"] = {
"status": "unavailable", "reason": "authentication failed"
}
with patch.object(I, "opinions_for_result", return_value=artifact):
with self.assertRaisesRegex(RuntimeError, "WIN: authentication failed"):
I._ai_opinion_from_args(self._args(ai_opinion="on"), res, None)
def test_context_file_wins_over_flag_and_needs_no_network(self):
res = self._result()
f = tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False)
json.dump(self._artifact(), f)
f.close()
try:
with patch.object(I, "opinions_for_result") as gen:
artifact = I._ai_opinion_from_args(
self._args(ai_opinion="off", ai_opinion_context_file=f.name), res, None
)
finally:
os.unlink(f.name)
gen.assert_not_called()
self.assertEqual(artifact["symbols"]["WIN"]["stance"], "buy")
if __name__ == "__main__":
unittest.main()
+62
View File
@@ -78,6 +78,68 @@ class TestMomentum121(unittest.TestCase):
round((closes[-22] / closes[-253] - 1) * 100, 2))
class TestEntryTiming(unittest.TestCase):
@staticmethod
def _ohlcv(closes, width=1.0, last_volume=2_000_000):
highs = [c + width for c in closes]
lows = [c - width for c in closes]
volumes = [1_000_000] * len(closes)
volumes[-1] = last_volume
return highs, lows, volumes
def test_insufficient_history_is_none(self):
closes = [100.0] * 59
highs, lows, volumes = self._ohlcv(closes)
self.assertIsNone(M.entry_timing(closes, highs, lows, volumes))
def test_overextended_monotonic_trend(self):
closes = [50.0 + 0.5 * i for i in range(80)]
highs, lows, volumes = self._ohlcv(closes)
timing = M.entry_timing(closes, highs, lows, volumes)
self.assertEqual(timing["classification"], "overextended")
def test_healthy_oscillating_uptrend_continues(self):
pattern = [0.0, 0.6, 1.2, 0.6, 0.0]
closes = [50.0 + 0.3 * i + pattern[i % 5] for i in range(300)]
highs, lows, volumes = self._ohlcv(closes)
timing = M.entry_timing(closes, highs, lows, volumes)
self.assertEqual(timing["classification"], "trend_continuation")
def test_ordered_peak_pullback_and_bounce_is_reversal(self):
closes = [80.0 + 0.2 * i for i in range(50)]
closes += [90, 93, 96, 94, 92, 90, 91, 92, 91, 94]
highs, lows, volumes = self._ohlcv(closes)
timing = M.entry_timing(closes, highs, lows, volumes)
self.assertEqual(timing["classification"], "pullback_reversal")
self.assertGreater(timing["pullback"]["days_since_30d_high"], 0)
self.assertGreaterEqual(timing["bounce"]["signal_count"], 2)
def test_ordered_pullback_without_bounce_waits(self):
closes = [80.0 + 0.2 * i for i in range(50)]
closes += [90, 93, 96, 94, 92, 90, 91, 92, 91, 91]
highs, lows, volumes = self._ohlcv(closes, last_volume=1_000_000)
timing = M.entry_timing(closes, highs, lows, volumes)
self.assertEqual(timing["classification"], "pullback_no_trigger")
self.assertFalse(timing["bounce"]["bounce_day"])
def test_new_high_after_old_low_is_not_a_pullback(self):
# 旧实现用今天的新高除以十日前低点,并用今天 MA20 回看历史,误判为 -4.12% 回调。
closes = [90.0 + 0.15 * i for i in range(50)]
closes += [97.15, 97.75, 98.15, 98.95, 99.55, 99.05, 100.05, 99.55, 99.35, 100.15]
highs, lows, volumes = self._ohlcv(closes, width=0.5)
highs[-1] = max(highs[-30:-1]) + 0.25 # 当天创 30 日新高
timing = M.entry_timing(closes, highs, lows, volumes)
self.assertNotEqual(timing["classification"], "pullback_reversal")
self.assertFalse(timing["pullback"]["touched_ma20_5d"])
self.assertGreater(timing["pullback"]["depth_from_30d_high_pct"], -4.0)
def test_broken_intermediate_trend(self):
closes = [100.0 + 0.1 * i for i in range(79)] + [80.0]
highs, lows, volumes = self._ohlcv(closes)
timing = M.entry_timing(closes, highs, lows, volumes)
self.assertEqual(timing["classification"], "trend_broken")
class TestATR(unittest.TestCase):
def test_constant_range(self):
# 每根 H-L=2,且无跳空 -> ATR=2
+184 -4
View File
@@ -9,6 +9,7 @@ import os
import sys
import tempfile
import unittest
import unittest.mock
from datetime import date, timedelta
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
@@ -35,6 +36,12 @@ def _rising(n=300, base=10.0, slope=0.6):
return [base + slope * i for i in range(n)]
def _healthy_uptrend(n=300, base=50.0, slope=0.3):
"""有规律回吐但不过热的强趋势,用于可买候选夹具。"""
pattern = [0.0, 0.6, 1.2, 0.6, 0.0]
return [base + slope * i + pattern[i % len(pattern)] for i in range(n)]
def _flat(n=300, base=100.0):
return [base for _ in range(n)]
@@ -106,7 +113,7 @@ class TestExtractCandidates(unittest.TestCase):
def test_only_yes_verdict_extracted_and_sorted(self):
# 强势票 → 是;平盘票 → 非"是"
result = self._result_with({
"STRG": _rising(), # 强势上行
"STRG": _healthy_uptrend(), # 强势但不过热
"FLAT": _flat(300, 50), # 平盘
})
cands = S.extract_candidates(result)
@@ -123,10 +130,66 @@ class TestExtractCandidates(unittest.TestCase):
self.assertIn("factor_breakdown", c)
def test_top_n_truncates(self):
result = self._result_with({"STRG": _rising()})
result = self._result_with({"STRG": _healthy_uptrend()})
cands = S.extract_candidates(result, top_n=0)
self.assertEqual(cands, [])
def test_entry_mode_filters_by_classification(self):
candidates = [
{"symbol": "PULL", "entry_timing": {"classification": "pullback_reversal"}},
{"symbol": "TREND", "entry_timing": {"classification": "trend_continuation"}},
{"symbol": "WAIT", "entry_timing": {"classification": "pullback_no_trigger"}},
]
self.assertEqual([c["symbol"] for c in S._filter_entry(candidates, "pullback")], ["PULL"])
self.assertEqual([c["symbol"] for c in S._filter_entry(candidates, "trend")], ["TREND"])
self.assertIs(S._filter_entry(candidates, "all"), candidates)
class TestScanEntryFilterOrdering(unittest.TestCase):
@staticmethod
def _analysis(classification):
return {
"last_close": 100.0,
"entry_timing": {"classification": classification, "timing_score_pct": 80},
"score": {
"composite": 90.0, "verdict": "是", "suggested_action": "entry",
"factor_breakdown": {}, "trade_plan": {}, "data_flags": [],
"blocking_reasons": [], "llm_overlay": None,
},
}
def test_entry_filter_runs_before_finnhub_verification(self):
lean = {"symbols": {
"PULL": self._analysis("pullback_reversal"),
"TREND": self._analysis("trend_continuation"),
"SPY": {}, "QQQ": {},
}}
verified = {"symbols": {
"PULL": self._analysis("pullback_reversal"),
"SPY": {}, "QQQ": {},
}}
args = S._build_args([
"--symbols", "PULL,TREND", "--entry", "pullback",
"--verify-news", "on", "--ai-opinion", "off",
])
with unittest.mock.patch.object(
S, "fetch_liquidity",
return_value=([{"symbol": "PULL"}, {"symbol": "TREND"}], []),
), unittest.mock.patch.object(
S, "fetch_pool_bars", return_value=({}, [])
), unittest.mock.patch.object(
S, "build_result", side_effect=[lean, verified]
), unittest.mock.patch.object(
S, "fetch_finnhub_context", return_value={"status": "ok", "symbols": {}}
) as fetch_news, unittest.mock.patch.object(
S, "llm_context_from_finnhub", return_value={}
):
result = S.scan(args)
self.assertEqual(fetch_news.call_args.args[0], ["PULL"])
self.assertEqual([c["symbol"] for c in result["candidates"]], ["PULL"])
self.assertEqual(result["counts"]["candidates_lean"], 2)
self.assertEqual(result["counts"]["candidates_entry_filtered"], 1)
class TestMergeNewsDowngrades(unittest.TestCase):
def _lean_cand(self, sym, composite):
@@ -137,7 +200,7 @@ class TestMergeNewsDowngrades(unittest.TestCase):
def test_splits_final_and_downgraded(self):
# 复核轮:KEEP 仍是"是";DROP 被降为"观察"带 cap 74
bars = {s: _bars(_rising()) for s in ("KEEP", "DROP")}
bars = {s: _bars(_healthy_uptrend()) for s in ("KEEP", "DROP")}
for b in ("SPY", "QQQ"):
bars[b] = _bench_bars()
verified = build_result(list(bars.keys()), bars, "iex", "split")
@@ -171,7 +234,7 @@ class TestOfflineScan(unittest.TestCase):
"STRG": {"daily_bar": {"c": 100.0, "v": 200000}},
"FLAT": {"daily_bar": {"c": 100.0, "v": 200000}}}}
bars = {
"STRG": _bars(_rising()),
"STRG": _bars(_healthy_uptrend()),
"FLAT": _bars(_flat(300, 100)),
"SPY": _bench_bars(),
"QQQ": _bench_bars(),
@@ -195,5 +258,122 @@ class TestOfflineScan(unittest.TestCase):
os.unlink(path)
class TestAttachAiOpinions(unittest.TestCase):
"""AI 意见只对最终候选调用、只附加展示字段、不影响排序与评分。"""
def _scored_result(self, syms=("STRG",)):
bars = {s: _bars(_healthy_uptrend()) for s in syms}
bars["SPY"] = _bench_bars()
bars["QQQ"] = _bench_bars()
return build_result(list(syms) + ["SPY", "QQQ"], bars, "iex", "split")
def _candidates(self, result):
return S.extract_candidates(result, top_n=None)
def _artifact(self, syms):
return {
"contract_version": "worth-buy-stocks.agent.v1",
"kind": "ai_opinion",
"status": "ok",
"symbols": {
s: {
"status": "ok", "stance": "buy", "confidence": "medium",
"view": "趋势领先", "key_risks": [], "agreement": "agree",
"model": "claude-opus-4-8", "generated_at": "2026-07-09T00:00:00Z",
} for s in syms
},
}
def test_off_mode_never_calls(self):
result = self._scored_result()
cands = self._candidates(result)
with unittest.mock.patch.object(S, "opinions_for_result") as gen:
flag = S._attach_ai_opinions(cands, result, "off")
gen.assert_not_called()
self.assertIsNone(flag)
self.assertNotIn("ai_opinion", cands[0])
def test_auto_without_key_returns_data_flag(self):
result = self._scored_result()
cands = self._candidates(result)
with unittest.mock.patch.object(S, "ai_opinion_has_api_key", return_value=False), \
unittest.mock.patch.object(S, "opinions_for_result") as gen:
flag = S._attach_ai_opinions(cands, result, "auto")
gen.assert_not_called()
self.assertIn("ANTHROPIC_API_KEY", flag)
def test_offline_default_does_not_call(self):
result = self._scored_result()
cands = self._candidates(result)
with unittest.mock.patch.object(S, "opinions_for_result") as gen:
flag = S._attach_ai_opinions(cands, result, "auto", offline=True)
gen.assert_not_called()
self.assertIsNone(flag)
def test_calls_scoped_to_final_candidates_only(self):
result = self._scored_result(("STRG", "STR2"))
cands = self._candidates(result)[:1] # 模拟 top-N 截断后只剩 1 个
only = cands[0]["symbol"]
with unittest.mock.patch.object(S, "ai_opinion_sdk_available", return_value=True), \
unittest.mock.patch.object(S, "ai_opinion_has_api_key", return_value=True), \
unittest.mock.patch.object(S, "opinions_for_result",
return_value=self._artifact([only])) as gen:
flag = S._attach_ai_opinions(cands, result, "auto")
self.assertIsNone(flag)
gen.assert_called_once()
self.assertEqual(gen.call_args.kwargs["symbols"], [only])
# llm_context 必须透传给 opinions_for_result,否则新闻上下文会丢失
self.assertEqual(gen.call_args.kwargs.get("llm_context"), None)
self.assertEqual(cands[0]["ai_opinion"]["stance"], "buy")
def test_ranking_fields_untouched_and_failure_degrades_symbol_only(self):
result = self._scored_result(("STRG", "STR2"))
cands = self._candidates(result)
before = [(c["symbol"], c["composite"], c["verdict"]) for c in cands]
artifact = self._artifact([cands[0]["symbol"]])
artifact["symbols"][cands[1]["symbol"]] = {"status": "unavailable", "reason": "api timeout"}
with unittest.mock.patch.object(S, "ai_opinion_sdk_available", return_value=True), \
unittest.mock.patch.object(S, "ai_opinion_has_api_key", return_value=True), \
unittest.mock.patch.object(S, "opinions_for_result", return_value=artifact):
S._attach_ai_opinions(cands, result, "auto")
self.assertEqual(before, [(c["symbol"], c["composite"], c["verdict"]) for c in cands])
self.assertEqual(cands[0]["ai_opinion"]["status"], "ok")
self.assertEqual(cands[1]["ai_opinion"]["status"], "unavailable")
def test_on_mode_raises_when_unavailable(self):
result = self._scored_result()
cands = self._candidates(result)
with unittest.mock.patch.object(S, "opinions_for_result",
return_value={"status": "unavailable", "reason": "no key"}):
with self.assertRaises(RuntimeError):
S._attach_ai_opinions(cands, result, "on")
def test_on_mode_raises_when_one_symbol_is_unavailable(self):
result = self._scored_result()
cands = self._candidates(result)
artifact = self._artifact([cands[0]["symbol"]])
artifact["symbols"][cands[0]["symbol"]] = {
"status": "unavailable", "reason": "timeout"
}
with unittest.mock.patch.object(S, "opinions_for_result", return_value=artifact):
with self.assertRaisesRegex(RuntimeError, "timeout"):
S._attach_ai_opinions(cands, result, "on")
def test_offline_scan_output_has_no_ai_opinion_by_default(self):
assets = [{"symbol": "STRG", "fractionable": True}]
snapshots = {"status": "ok", "symbols": {"STRG": {"daily_bar": {"c": 100.0, "v": 200000}}}}
bars = {"STRG": _bars(_healthy_uptrend()), "SPY": _bench_bars(), "QQQ": _bench_bars()}
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
json.dump({"assets": assets, "snapshots": snapshots, "bars": bars}, f)
path = f.name
try:
args = S._build_args(["--input", path, "--verify-news", "off"])
result = S._scan_offline(args)
finally:
os.unlink(path)
for cand in result["candidates"]:
self.assertNotIn("ai_opinion", cand)
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -176,6 +176,71 @@ class TestConfirmationOverlay(unittest.TestCase):
self.assertFalse(r["confirmation"]["ok"])
self.assertEqual(r["verdict"], "观察")
def test_missing_volume_is_not_confirmation(self):
a = _strong()
a["volume"]["ratio_vs_ma20"] = None
r = S.score(a)
self.assertIsNone(r["confirmation"]["volume_pct"])
self.assertFalse(r["confirmation"]["ok"])
self.assertEqual(r["verdict"], "观察")
class TestEntryTimingOverlay(unittest.TestCase):
@staticmethod
def _weaken_technical(a):
a["macd"] = {"bull": False, "above_zero": False,
"recent_cross": {"type": "death"}}
a["rsi"] = {"RSI14": 40}
a["kdj"] = {"bull": False, "above_50": False}
@staticmethod
def _pullback(a):
a["entry_timing"] = {
"classification": "pullback_reversal",
"timing_score_pct": 90,
"pullback": {"recent_low_10d": 106.0},
}
def test_overextended_downgrades_without_changing_composite(self):
base = S.score(_strong())
a = _strong()
a["entry_timing"] = {
"classification": "overextended", "timing_score_pct": 20,
"ext_atr_above_ma20": 3.2, "rsi14": 81.0,
}
timed = S.score(a)
self.assertEqual(timed["composite"], base["composite"])
self.assertEqual(timed["verdict_before_entry_timing"], "是")
self.assertEqual(timed["verdict"], "观察")
self.assertEqual(timed["trade_plan"]["status"], "wait_pullback")
def test_pullback_can_replace_weak_technical_when_volume_is_valid(self):
a = _strong()
self._weaken_technical(a)
self._pullback(a)
r = S.score(a)
self.assertFalse(r["confirmation"]["ok"])
self.assertGreaterEqual(r["confirmation"]["volume_pct"], 40)
self.assertEqual(r["verdict_before_entry_timing"], "观察")
self.assertEqual(r["verdict"], "是")
def test_pullback_cannot_upgrade_when_volume_is_missing(self):
a = _strong()
self._weaken_technical(a)
self._pullback(a)
a["volume"]["ratio_vs_ma20"] = None
r = S.score(a)
self.assertIsNone(r["confirmation"]["volume_pct"])
self.assertEqual(r["verdict"], "观察")
def test_pullback_cannot_upgrade_when_volume_is_weak(self):
a = _strong()
self._weaken_technical(a)
self._pullback(a)
a["volume"]["obv"] = {"divergence": "bearish"}
r = S.score(a)
self.assertLess(r["confirmation"]["volume_pct"], 40)
self.assertEqual(r["verdict"], "观察")
class TestPositionSizing(unittest.TestCase):
def test_higher_vol_smaller_position(self):