mirror of
https://github.com/starriv/worth-buy-stocks.git
synced 2026-11-02 07:46:12 +00:00
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:
@@ -191,3 +191,6 @@ $RECYCLE.BIN/
|
||||
# Local run artifacts
|
||||
*.out
|
||||
*.err
|
||||
|
||||
tasks/
|
||||
data/
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 广告
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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*
|
||||
@@ -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 样本外证据为后续决策准绳。
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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/
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 是否在 "
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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("无可买候选")
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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"}],
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user