feat: GitHub Actions 指数入口 + 指数注册表补全与自选股配置文档 (Refs #2303) (#2332)

* feat: GitHub Actions STOCK_LIST 指数入口与 CLI unsupported 明确拒绝

* feat: 指数注册表新增国证粮食与中证钢铁,补充指数自选股配置文档

- seed 与 bundled 指数清单 31 项扩展至 33 项(sz399365、csi930606)
- 中英 full-guide 新增指数自选股配置小节,README/DEPLOY 同步提示
- 相关确定性测试断言 31→33 并锁定新 canonical

* fix: market-only 分类跳过测试补齐交易日历 mock,消除周末失败

* fix: 测试类级默认关闭 GITHUB_ACTIONS,修复 CI runner 环境注入导致的分类分支误入

* fix: 入口分类按模式边界跳过不消费个股列表的模式,文档收窄指数入口适用范围

- 新增 _skips_stock_entry guard:--backtest/--market-review/--serve-only/--webui-only/--portfolio/--schedule/config.schedule_enabled 整体跳过 --stocks 与 Actions STOCK_LIST 的分类与索引刷新
- --schedule --stocks 的忽略快照警告与分类解耦(传 args.stocks or None)
- 恢复 --stocks + --portfolio 同框提示日志
- 测试:review 反例四格 + Actions portfolio 格 + portfolio 覆盖到达 pipeline.run 入参断言
- 文档:指数分类与整批拒绝收窄为 --stocks 与 GitHub Actions 两入口
This commit is contained in:
Elvis Wang
2026-09-05 22:03:49 +08:00
committed by GitHub
parent e866cfa48d
commit 303f4e1c18
13 changed files with 581 additions and 41 deletions

View File

@@ -108,7 +108,7 @@
| Secret 名称 | 说明 | 必填 |
|------------|------|:----:|
| `STOCK_LIST` | 自选股代码,如 `600519,hk00700,AAPL,7203.T,005930.KS,2330.TW` | ✅ |
| `STOCK_LIST` | 自选股代码,如 `600519,hk00700,AAPL,7203.T,005930.KS,2330.TW`;已登记指数显式代码(如 `sh000016``930606.CSI`)经一次性 `--stocks` 或 GitHub Actions 入口支持,规则见 [指数自选股配置](docs/full-guide.md#指数自选股配置) | ✅ |
**新闻源配置(推荐)**

View File

@@ -31677,6 +31677,7 @@
["011200.KS","011200.KS","HMM","HMM","HMM",["现代商船"],"KR","stock",true,100],
["090430.KS","090430.KS","爱茉莉太平洋","aimolitaipingyang","amltpy",["Amorepacific","아모레퍼시픽"],"KR","stock",true,100],
["086520.KQ","086520.KQ","Ecopro","Ecopro","Ecopro",["에코프로"],"KR","stock",true,100],
["csi930606","930606.CSI","中证钢铁","zhongzhenggangtie","zzgt",[],"CN","index",true,100],
["csi930955","930955.CSI","红利低波100","honglidibo100","hldb100",[],"CN","index",true,100],
["csi931052","931052.CSI","国信价值","guoxinjiazhi","gxjz",[],"CN","index",true,100],
["csi931446","931446.CSI","东证红利低波","dongzhenghonglidibo","dzhldb",[],"CN","index",true,100],
@@ -31702,6 +31703,7 @@
["sz399303","sz399303","国证2000","guozheng2000","gz2000",["399303.SZ"],"CN","index",true,100],
["sz399324","sz399324","深证红利","shenzhenghongli","szhl",["399324.SZ"],"CN","index",true,100],
["sz399330","sz399330","深证100","shenzheng100","sz100",["399330.SZ"],"CN","index",true,100],
["sz399365","sz399365","国证粮食","guozhengliangshi","gzls",["399365.SZ"],"CN","index",true,100],
["sz399967","sz399967","中证军工","zhongzhengjungong","zzjg",["399967.SZ"],"CN","index",true,100],
["sz399975","sz399975","证券公司","zhengquangongsi","zqgs",["399975.SZ"],"CN","index",true,100],
["sz399986","sz399986","中证银行","zhongzhengyinhang","zzyh",["399986.SZ"],"CN","index",true,100],

View File

@@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
> For user-friendly release highlights, see the [GitHub Releases](https://github.com/ZhuLinsen/daily_stock_analysis/releases) page.
## [Unreleased]
- [修复] 入口分类的模式边界修复PR3 review不消费个股列表的模式`--backtest`/`--market-review`/`--serve-only`/`--webui-only`/`--portfolio`/`--schedule`/`config.schedule_enabled`)在模式分发前整体跳过 `--stocks` 与 GitHub Actions `STOCK_LIST` 的分类与索引刷新,未登记 `.CSI` 等坏 token 不再拦截这些模式(此前 `GITHUB_ACTIONS=true``--backtest``--portfolio futu``--schedule` 配坏 watchlist 会在进入模式主体前被整批拒绝);`--schedule --stocks` 的"警告后忽略启动快照"语义保留;`--stocks` 与 GitHub Actions 入口的指数分类与整批拒绝契约不回归,文档同步把指数自选股配置收窄为仅这两类入口(本地 `.env`/Docker 无参数默认运行保持股票语义,分析指数请配 `--stocks`)。
- [新功能] 指数注册表新增国证粮食(`sz399365`)与中证钢铁(`csi930606`):已登记 seed`scripts/stock_index_seeds/index_registry.csv`)与 bundled 指数清单(`apps/dsa-web/public/stocks.index.json`)由 31 项扩展到 33 项,已登记指数的 `sh`/`sz`/`csi` 前缀与 `.SH`/`.SZ`/`.CSI` 显式形态均可作为自选股指数目标ETF 与美股指数不进 CN 注册表,路由语义不变。
- [文档] 中英 full-guide 新增「指数自选股配置」小节:说明已登记指数前缀/显式后缀规则、未登记 `.CSI` 整批拒绝、裸码不自动提升为指数、NDX 等美股指数裸码即正确路由、ETF 走股票路径,并同步补充 README / DEPLOY 的 `STOCK_LIST` 提示;修正 full-guide 中已过期的「已登记 5 个沪深指数」表述。
- [新功能] GitHub Actions 每日工作流支持已登记指数入口:`GITHUB_ACTIONS=true` 下无参数 `python main.py``STOCK_LIST` 按与一次性 `--stocks` 相同的判型规则分类为结构化 target显式指数 token 进入指数路径、个股 token 保持既有 legacy 路径),指数与个股同批复用同一 Pipeline不新增第二条能力矩阵本地与 `--schedule` 热刷新默认路径不在入口构造 target语义不变。
- [修复] `main.py --stocks` 对未登记 `.CSI` 目标在入口明确报错并整批拒绝本轮运行(此前被静默按股票 token 交给 provider拒绝发生在任何 provider 请求之前,与 API 层"provider 调用前明确拒绝"的边界对齐CLI/Actions 整批拒绝与 API 异步批量仅拒绝该目标的差异已在文档中说明。
- [测试] 新增 Bot 指数入口 transport-independent 在线 E2E smoke`scripts/smoke_bot_index_entry.py`worker 子进程经真实 `CommandDispatcher.dispatch_async` 提交并在同进程轮询 `TaskService` 贯穿到 `StockAnalysisPipeline`,父进程只负责 deadline、进程树清理Windows `taskkill /T /F`、POSIX 杀进程组与退出码0=成功/1=失败/124=超时),输出单行 `E2E_EVENT {json}` 事件(`phase=submitted|completed|failed|timeout`smoke 覆盖 `SH.000016`/`上证50`/`930955.CSI` 矩阵,期望 code/name 取脚本内置权威映射(不信任响应/结果自报身份,提交 code mismatch 输出含期望值与实际值的显式错误并携带结构化实际 `stock_code`dispatcher 路由错误也失败),矩阵外 target 与非正 `--timeout` 在提交与 spawn 之前即被拒绝(退出码 2completed 须 exact canonical code、exact 注册名称且 `analysis_summary`/`operation_advice`/`trend_prediction` 非空仅空白视为空失败或结果不完整即非零退出超时清理进程树Windows `taskkill /T /F`、POSIX 杀进程组),清理成功输出 `timeout` 事件并退出 124、清理失败输出含清理错误的 `failed` 事件并退出 1不回滚 DB/报告/通知副作用;用户 Ctrl-C 中止时父进程同样先清理进程树,清理成功透传中断、清理失败输出含清理错误的 `failed` 事件并退出 1绝不静默吞掉清理失败worker 意外异常输出 `failed` 事件并退出 1stderr 保留异常证据,`KeyboardInterrupt` 不按普通失败处理),父进程将 worker 任意其他退出码归一化为 1运行时契约只暴露 0/1/124父进程以内部 `--worker` flag 显式拉起子进程(不依赖环境变量,防外部预置绕过硬超时);不 mock 在线依赖、不 dry-run、不修 transport不加入离线 gate。
- [新功能] Bot `/analyze` 支持已登记指数入口:显式代码(`sh000016`、CSI alias`930955.CSI` 收敛为 `csi930955`)与注册中文名(`上证50`)均可提交,指数以结构化 `AnalysisTarget``TaskService` 贯穿到 Pipeline `process_single_stock``sh000016` 不再被改写为 `SH000016`);注册名称查询独立于 parser identity alias中文名不进入 `find_by_explicit_key`/`parse_analysis_target`),同名歧义返回明确错误并要求显式代码,未登记 CSI 与未知名称返回明确错误且不提交任务个股代码A/HK/US保持既有 legacy code 路径不变,股票名称输入(如 `贵州茅台`)由本次 Bot 入口新暴露——复用既有名称解析器(`resolve_name_to_code`)解析后提交 legacy code不携带结构化 target提交成功响应在 `BotResponse.extra` 暴露内部任务 identity`task_id`/`stock_code`)供在线验收等内部流程使用,文本与错误路径不变、平台适配器可忽略 `extra`
- [新功能] 新增最小 Agent 轨迹评估入口 `evals/agent_trajectory/`(Refs #1956):纯函数指标层只消费真实 `tool_calls_log + AgentResult`,冻结最小指标契约(工具命中、冗余/缓存、失败/重试、总步数/max_steps),`run_eval.py``build_agent_executor` 真实执行并输出文本摘要 + 结构化 JSON 报告;评估为 reporter 非 gate,零 `src/` 改动

View File

@@ -213,7 +213,7 @@ journalctl -u stock-analyzer -f
| 配置项 | 说明 | 获取方式 |
|--------|------|----------|
| `ANSPIRE_API_KEYS` / `AIHUBMIX_KEY` / `GEMINI_API_KEY` / `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | AI 模型至少配置一个;推荐优先 Anspire 或 AIHubMix | 对应服务商控制台 |
| `STOCK_LIST` | 自选股列表 | 逗号分隔的股票代码 |
| `STOCK_LIST` | 自选股列表 | 逗号分隔的股票代码;已登记指数显式形态(如 `sh000016``930606.CSI``sz399365`)经一次性 `--stocks` 或 GitHub Actions 入口支持(本地 `.env`/Docker 无参数默认运行保持股票语义,指数需配 `--stocks`),规则见 [指数自选股配置](full-guide.md#指数自选股配置) |
| 通知渠道 | 至少配置一个如企业微信、飞书、Telegram 或邮件 | 对应通知平台 |
### 可选配置项

View File

@@ -201,7 +201,7 @@ journalctl -u stock-analyzer -f
| Config Item | Description | How to Get |
|--------|------|----------|
| `ANSPIRE_API_KEYS` / `AIHUBMIX_KEY` / `GEMINI_API_KEY` / `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | Configure at least one AI model key; Anspire or AIHubMix is recommended first | Provider console |
| `STOCK_LIST` | Watchlist | Comma-separated stock codes |
| `STOCK_LIST` | Watchlist | Comma-separated stock codes; registered-index explicit forms (e.g. `sh000016`, `930606.CSI`, `sz399365`) are supported via the one-shot `--stocks` argument or the GitHub Actions entry (local `.env`/Docker no-arg default runs keep stock semantics; use `--stocks` there for indices) — see [Index watchlist configuration](full-guide_EN.md#index-watchlist-configuration) |
| Notification channel | Configure at least one, such as WeChat Work, Feishu, Telegram, or email | Notification provider |
### Optional Configuration

View File

@@ -109,7 +109,7 @@ More channels, signatures, email groups, and Markdown-to-image settings are in [
| Secret Name | Description | Required |
|-------------|-------------|:--------:|
| `STOCK_LIST` | Watchlist codes, such as `600519,hk00700,AAPL,7203.T,005930.KS,2330.TW` | ✅ |
| `STOCK_LIST` | Watchlist codes, such as `600519,hk00700,AAPL,7203.T,005930.KS,2330.TW`; registered-index explicit codes (e.g. `sh000016`, `930606.CSI`) are supported via the one-shot `--stocks` argument or the GitHub Actions entry — see [Index watchlist configuration](full-guide_EN.md#index-watchlist-configuration) | ✅ |
**News sources (recommended)**

View File

@@ -452,7 +452,7 @@ daily_stock_analysis/
> - 台股:在美股/港股 offshore 基础路径之外,`institution` 区块额外展示三大法人原始买卖超净额TWSE T86 / TPEx默认开启、fail-open取不到数据时维持 `not_supported``capital_flow`、`dragon_tiger`、`boards` 仍为 `not_supported`
> - 任何异常走 fail-open仅记录错误不影响技术面/新闻/筹码主链路。
> - 配置 `TICKFLOW_API_KEY` 后TickFlow 会作为可选 A 股日 K 数据源和大盘复盘增强源实例化;`TICKFLOW_PRIORITY` 只影响普通 A 股日 K/通用数据源回退链。实时行情优先级由 `REALTIME_SOURCE_PRIORITY` 单独控制,只有显式包含 `tickflow` 时才会使用 TickFlow 实时行情。`REALTIME_SOURCE_PRIORITY` 中排在 `tickflow` 前面的数据源会先被尝试。
> - 当前 `IndexRegistry` 已登记的 5 个沪深指数为 `sh000016`上证50、`sh000688`科创50、`sz399001`(深证成指)、`sz399006`(创业板指)和 `sh000300`沪深300使用显式市场输入(也接受 `000016.SH` 等交易所后缀形式)时,它们不参与通用 priority 排序,固定按 Tencent → AkShare → TickFlow → YFinance 降级;未配置或不可用的来源会跳过。裸 `000016` 等代码仍按股票处理,不触发指数链。该固定链不读取 `EFINANCE_PRIORITY`、`AKSHARE_PRIORITY`、`TUSHARE_PRIORITY`、`TICKFLOW_PRIORITY`、`PYTDX_PRIORITY`、`BAOSTOCK_PRIORITY`、`YFINANCE_PRIORITY` 或 `TENCENT_PRIORITY`,不影响普通股票和实时行情的既有顺序。
> - 当前 `IndexRegistry` 已登记的全部沪深指数(`sh`/`sz`/`csi` 前缀,具体清单以 [指数自选股配置](#指数自选股配置) 的注册表说明与 `scripts/stock_index_seeds/index_registry.csv` 为准)在使用显式市场输入(也接受 `000016.SH` 等交易所后缀形式)时,不参与通用 priority 排序,固定按 Tencent → AkShare → TickFlow → YFinance 降级;未配置或不可用的来源会跳过。裸 `000016` 等代码仍按股票处理,不触发指数链。该固定链不读取 `EFINANCE_PRIORITY`、`AKSHARE_PRIORITY`、`TUSHARE_PRIORITY`、`TICKFLOW_PRIORITY`、`PYTDX_PRIORITY`、`BAOSTOCK_PRIORITY`、`YFINANCE_PRIORITY` 或 `TENCENT_PRIORITY`,不影响普通股票和实时行情的既有顺序。
> - 已登记指数名称优先来自本地注册表;只有注册名称无效时才按 Tencent → AkShare → TickFlow 查询,名称链不使用 YFinance。指数日线四源全部失败时返回空结果并记录汇总告警普通股票仍保持既有的 `DataFetchError` 最终失败契约。
> - TickFlow 日 K 默认 `TICKFLOW_KLINE_ADJUST=none`;日线 `volume` 从手统一转为股,`amount` 保持元口径。
> - TickFlow 日 K 区间请求会显式传入 `start_time` / `end_time` / `count`;官方 quickstart 明确说明时间范围查询仍受 `count` 限制。若返回非空但行数打满 `count` 且首个返回交易日晚于请求起始交易日,系统会判定为疑似截断,不写入缓存并让 manager 继续回退。
@@ -732,7 +732,7 @@ python main.py --stocks sh000016,000016
python main.py --stocks sh000016 --dry-run
```
指数目标在 Pipeline 内以 `market=cn` 统一处理市场阶段、日线目标日期、断点续传日期、历史窗口与 `DecisionSignal`;筹码分布、基本面、板块归属、资金流、龙虎榜与公司事件等个股专属模块会被集中跳过。未登记的 `.CSI` 输入(如 `930956.CSI`)会在任何行情数据 provider 请求前明确拒绝,且不影响同批其他目标。搜索与报告使用注册表中文指数名称,不携带机器码。
指数目标在 Pipeline 内以 `market=cn` 统一处理市场阶段、日线目标日期、断点续传日期、历史窗口与 `DecisionSignal`;筹码分布、基本面、板块归属、资金流、龙虎榜与公司事件等个股专属模块会被集中跳过。未登记的 `.CSI` 输入(如 `930956.CSI`)会在任何行情数据 provider 请求前明确拒绝CLI `--stocks` 与 Actions 每日工作流对含未登记目标的整批拒绝本轮运行不执行同批任何目标Web/API 则为异步批量中仅该目标进入 `rejected`、同步/单股返回 4xx。搜索与报告使用注册表中文指数名称,不携带机器码。
指数与 A 股共享交易日语义:启用交易日检查时,已登记指数(`sh`/`sz` 前缀或 `.CSI` alias`market=cn` 参与 A 股休市过滤A 股休市日指数会被跳过;市场仍无法识别的非指数 code 保持既有 fail-open 行为。`--force-run` 可强制在非交易日执行。
@@ -746,11 +746,46 @@ API `/analyze` 对显式指数输入构造结构化 `AnalysisTarget``sh000016
Bot `/analyze` 已支持已登记指数的显式代码(`sh000016`、CSI alias`930955.CSI`)和注册中文名(`上证50`)。指数以 registry canonical 和结构化 `AnalysisTarget` 进入与 CLI/API 相同的 Pipeline未登记 CSI、未知名称或歧义注册名称会明确报错且不提交任务。普通 A/HK/US 代码与股票名称继续沿用 legacy code 路径。
> **Phase 2 边界**:默认 `STOCK_LIST`、`--schedule`、Bot `/ask`、Bot `/batch` 与 GitHub Actions 每日工作流仍未开放指数入口Web/API、Bot `/analyze` 与一次性 `--stocks` 已支持指数,其余定时/每日工作流入口留待后续 PR
### 指数 GitHub Actions 入口Phase 2 PR3
GitHub Actions 每日工作流(`.github/workflows/00-daily-analysis.yml`)把 `STOCK_LIST` 配置中的显式指数 token 与个股同批分析:`GITHUB_ACTIONS=true` 环境下 `python main.py` 无参数运行时(`full``stocks-only` 模式;`market-only` 不经个股列表,不参与此分类),`STOCK_LIST` 会按与 `--stocks` 相同的判型规则分类为结构化 target显式指数 token`sh000016``930955.CSI`)进入指数路径、个股 token 保持既有路径,不新增第二条 Pipeline 或能力矩阵;含未登记 `.CSI` 目标时整批拒绝本轮运行。本地无参数默认路径(`.env`/Docker 的 `STOCK_LIST`)与 `--schedule`/`config.schedule_enabled` 定时模式不参与此分类:本地 `--stocks` 参与分类,但本地无参默认路径与定时模式不经 `--stocks` 构造 targets`STOCK_LIST` 的其它运行环境语义不变。
> **Phase 2 边界**`--schedule`、Bot `/ask`、Bot `/batch` 仍未开放指数入口Web/API、Bot `/analyze`、一次性 `--stocks` 与 GitHub Actions 每日工作流已支持指数。
### 指数自选股配置
`STOCK_LIST` 支持在两类入口按下列规则混入指数目标:一次性 `--stocks` 与 GitHub Actions 每日工作流(`GITHUB_ACTIONS=true` 无参数运行的 full/stocks-only 模式)。本地 `.env` 或 Docker 无参数默认运行不参与指数分类,`STOCK_LIST` 保持既有股票语义;本地 `.env`/Docker 若想分析指数,请显式加 `--stocks`(一次性运行)或改用 GitHub Actions 入口。分类判断先于股票解析:命中已登记指数的显式形态时按 `market=cn` 的指数 Pipeline 执行;未命中则继续走股票路径(`.CSI` 前缀除外,见下文拒绝规则)。判断发生在**归一化之前**,因此必须写显式代码形态,不要依赖裸码“自动识别”。
**可用的已登记指数代码形态:**
| 指数家族 | canonical注册身份 | display / 等价显式写法 | 示例 |
|----------|----------------------|------------------------|------|
| 上海(上证/中证系列 SH 挂牌) | `sh` + 6 位 | `{code}.SH`(另有跨家族 alias 如 `sz399300``sh000300` | `sh000016``000016.SH`上证50 |
| 深圳(深证/国证系列) | `sz` + 6 位 | `{code}.SZ` | `sz399006``399006.SZ`(创业板指);`sz399365``399365.SZ`(国证粮食) |
| 中证CSI 代码段) | `csi` + 6 位 | `{code}.CSI` | `csi930606``930606.CSI`(中证钢铁);`csi930955``930955.CSI`红利低波100 |
| 美股指数 | 不进 CN 注册表 | 直接裸码 | `NDX`纳斯达克100`SPX``DJI` |
> 完整清单以 `scripts/stock_index_seeds/index_registry.csv` 为准,构建产物 `apps/dsa-web/public/stocks.index.json` 由它生成(`python scripts/generate_index_from_csv.py --index-only`。SH/SZ 家族的等价后缀与 canonical 等价;`csi` 家族的 canonical 是 `csi930606`,等价显式写法是 `930606.CSI`(两者都收敛到同一指数)。
**规则与正误示例:**
- **必须写已登记代码的显式形态**(前缀或交易所后缀),不能写未登记代码段(会整批拒绝或按股票处理)。大小写不敏感:`930955.csi``CSI930955``930955.CSI` 等价;同一指数的 canonical 与显式 alias/display 等价(`csi930606``930606.CSI``sz399365``399365.SZ` 等价)。写错交易所后缀(如 `399365.SH`)会直接报 unsupported该后缀不接受该代码基码而裸 6 位码才落回股票路径。
- **未登记 `.CSI` 目标整批拒绝(仅限 `--stocks` 与 GitHub Actions 入口)**:含未登记 `.CSI`(如 `930956.CSI``930606.CSI` 以外的 CSI 段)的 `--stocks` 或 GitHub Actions 每日工作流的 `STOCK_LIST` 会整批拒绝本轮运行,任何一个目标都不执行;本地 `.env`/Docker 无参数默认路径不参与指数分类不存在此整批拒绝。Web/API 异步批量只拒绝该目标、同步/单股返回 4xx见下方 Web/API 指数入口小节的 rejected 列表语义)。写错交易所后缀(如 `399365.SH`)同样直接报 unsupported。
- **裸码不自动提升为指数**:裸 `399365` / `930606` / `000016` 等 6 位代码一律按股票路径处理契约保持“裸码默认股票”只记录歧义提示。注意SZ 家族指数(如 `sz399365`)的股票 canonical 与指数 canonical 同为 `sz{code}` 前缀形态,同码裸股与指数共用同一落库键域,混合自选时请留意下方 canonical 隔离小节的折叠语义。
- **NDX 等美股指数直接写裸码就是正确写法**:它们走 `us_index_mapping` 的专用美股指数路由,**不要**套用 CN 前缀/后缀(如 `NDX.US``usNDX`),加了反而可能落入股票路径。
- **ETF如 159934 黄金 ETF、XOP不进指数注册表**ETF 语义走股票路径,与同数字开头的指数无关;直接写裸码即可,不需要也不支持指数前缀。
**配置示例(`STOCK_LIST`,逗号分隔):**
```text
600519,sh000016,930606.CSI,sz399365,159934,NDX
```
上面混排了个股600519 贵州茅台、上证指数sh000016、CSI 指数930606.CSI、SZ 指数sz399365、A 股 ETF159934走股票路径与美股指数NDX走美股指数路由。批量含未登记 `.CSI` 时整批不运行,因此**推荐对每个指数先核对注册表再配置**。
### 指数与个股 Dashboard canonical 隔离PR #2312
已登记指数以 lowercase canonical`sh000016`/`sz399001`/`csi930955`写入历史存储历史筛选、按代码删除、计数与个股栏stock-bar聚合统一使用 parser 判型,指数记录与同码裸股票(如 `000016`)严格隔离,互不折叠
已登记指数以 lowercase canonical`sh000016`/`sz399001`/`csi930955`写入历史存储历史筛选、按代码删除、计数与个股栏stock-bar聚合统一使用 parser 判型,指数记录与同码裸股票(如 `000016`)严格隔离,互不折叠。例外SZ 家族指数(如 `sz399365`)的裸股票 canonical 与指数 canonical 同为 `sz{code}` 前缀形态(裸 `399365` 按股票解析时 canonical 即 `sz399365`),二者天然共键;该家族的同码裸股与指数记录不在上述隔离范围,混合自选时按同一键折叠。
- **历史候选**:指数查询(`sh000016``SH000016``000016.SH``sz399001``csi930955``930955.CSI` 等显式形式)会命中 lowercase canonical、uppercase legacy canonical 与显式 alias 的既有记录,但**不会**命中裸同码股票记录;裸码查询(`000016`/`930955`)也不会命中指数记录。股票 alias、港股与海外市场的既有等价匹配保持不变。
- **删除与计数**`DELETE /api/v1/history/by-code/{code}` 与历史总数对指数 canonical 收敛全部显式形态;无记录时仍返回 `deleted=0`,不引入破坏性 404。

View File

@@ -424,7 +424,7 @@ For the notification baseline, diagnostics, and deployment notes, see [Notificat
> Behavior notes:
> - When `TICKFLOW_API_KEY` is configured, TickFlow is instantiated as an optional A-share daily K-line data source and CN market-review enhancer. `TICKFLOW_PRIORITY` only affects the generic A-share daily K-line/provider fallback chain. Realtime quote priority is controlled separately by `REALTIME_SOURCE_PRIORITY`; TickFlow realtime quotes are used only when that list explicitly includes `tickflow`, and any source listed before `tickflow` is tried first.
> - The five SH/SZ indices currently registered in `IndexRegistry` are `sh000016` (SSE 50), `sh000688` (STAR 50), `sz399001` (SZSE Component), `sz399006` (ChiNext), and `sh000300` (CSI 300). Explicit-market inputs (exchange-suffix forms such as `000016.SH` are also accepted) bypass generic priority sorting and use the fixed Tencent → AkShare → TickFlow → YFinance fallback chain; unconfigured or unavailable providers are skipped. Bare `000016`-style inputs remain stocks and do not enter the index chain. This fixed chain ignores `EFINANCE_PRIORITY`, `AKSHARE_PRIORITY`, `TUSHARE_PRIORITY`, `TICKFLOW_PRIORITY`, `PYTDX_PRIORITY`, `BAOSTOCK_PRIORITY`, `YFINANCE_PRIORITY`, and `TENCENT_PRIORITY`. Existing stock and realtime-quote ordering is unchanged.
> - All SH/SZ indices currently registered in `IndexRegistry` (the `sh`/`sz`/`csi` prefixes; see the [Index watchlist configuration](#index-watchlist-configuration) registry notes and `scripts/stock_index_seeds/index_registry.csv` for the full list) bypass generic priority sorting when given as explicit-market inputs (exchange-suffix forms such as `000016.SH` are also accepted) and use the fixed Tencent → AkShare → TickFlow → YFinance fallback chain; unconfigured or unavailable providers are skipped. Bare `000016`-style inputs remain stocks and do not enter the index chain. This fixed chain ignores `EFINANCE_PRIORITY`, `AKSHARE_PRIORITY`, `TUSHARE_PRIORITY`, `TICKFLOW_PRIORITY`, `PYTDX_PRIORITY`, `BAOSTOCK_PRIORITY`, `YFINANCE_PRIORITY`, and `TENCENT_PRIORITY`. Existing stock and realtime-quote ordering is unchanged.
> - Registered index names normally come from the local registry. Only an invalid registry name triggers the Tencent → AkShare → TickFlow name fallback; YFinance is not part of the name chain. If all four daily providers fail, an index request returns an empty result with a summary warning, while ordinary stocks retain the existing final `DataFetchError` contract.
> - TickFlow daily K-lines default to `TICKFLOW_KLINE_ADJUST=none`; daily `volume` is converted from lots to shares, while `amount` remains in yuan.
> - TickFlow daily K-line range requests pass explicit `start_time` / `end_time` / `count`. Because the official quickstart documents that time-range queries are still limited by `count`, non-empty count-capped responses whose first returned trading date is later than the requested start trading date are rejected before normalization or cache writes, allowing manager fallback to continue.
@@ -671,7 +671,7 @@ python main.py --stocks sh000016,000016
python main.py --stocks sh000016 --dry-run
```
Index targets are handled with `market=cn` throughout the Pipeline for market phase, daily-bar target date, resume/checkpoint date, history window, and `DecisionSignal`. Stock-only modules (chip distribution, fundamentals, board membership, capital flow, LHB, corporate events) are centrally skipped. An unregistered `.CSI` input (e.g. `930956.CSI`) is rejected before any market-data provider request without affecting other targets in the batch. Search and reports use the registry Chinese index name and never carry machine codes.
Index targets are handled with `market=cn` throughout the Pipeline for market phase, daily-bar target date, resume/checkpoint date, history window, and `DecisionSignal`. Stock-only modules (chip distribution, fundamentals, board membership, capital flow, LHB, corporate events) are centrally skipped. An unregistered `.CSI` input (e.g. `930956.CSI`) is rejected before any market-data provider request: the CLI `--stocks` entry and the Actions daily workflow reject the whole batch for the run (no target in the batch runs), while Web/API rejects only that target in an async batch (it enters `rejected`) or returns 4xx for a sync/single request. Search and reports use the registry Chinese index name and never carry machine codes.
Indices share the A-share trading-day semantics: when the trading-day check is enabled, registered indices (`sh`/`sz` prefix or `.CSI` alias) participate in CN holiday filtering as `market=cn`, so indices are skipped on A-share holidays; a market-unknown non-index code keeps the existing fail-open behavior. `--force-run` forces execution on non-trading days.
@@ -685,11 +685,46 @@ The API `/analyze` endpoint builds a structured `AnalysisTarget` for explicit in
Bot `/analyze` now accepts registered-index explicit codes (`sh000016`), CSI aliases (`930955.CSI`), and registered Chinese names (`上证50`). The registry canonical and structured `AnalysisTarget` flow into the same Pipeline used by CLI/API; unregistered CSI forms, unknown names, and ambiguous registered names return an explicit error without submitting a task. Existing A/HK/US codes and stock names keep the legacy-code path.
> **Phase 2 boundary**: default `STOCK_LIST`, `--schedule`, Bot `/ask`, Bot `/batch`, and the GitHub Actions daily workflow still do not expose index entrypoints. Web/API, Bot `/analyze`, and the one-shot `--stocks` entry support indices; the remaining scheduled/daily-workflow entry lands in a later PR.
### GitHub Actions index entry (Phase 2 PR3)
The GitHub Actions daily workflow (`.github/workflows/00-daily-analysis.yml`) analyzes explicit index tokens from the `STOCK_LIST` config in the same batch as stocks: when `GITHUB_ACTIONS=true`, a no-arg `python main.py` run (the `full` and `stocks-only` modes; `market-only` does not analyze the stock list and is exempt from this classification) classifies `STOCK_LIST` with the same typing rules as `--stocks`, so explicit index tokens (e.g. `sh000016`, `930955.CSI`) take the index path while stock tokens keep the existing path — no second Pipeline or capability matrix is introduced; a batch containing an unregistered `.CSI` target is rejected as a whole. The local no-arg default path (`.env`/Docker `STOCK_LIST`) and scheduled hot-reload (`--schedule`/`config.schedule_enabled`) do not participate in this classification: local `--stocks` runs do classify, but the local no-arg default path and scheduled mode construct no targets from `--stocks`, so the default `STOCK_LIST` semantics in those environments are unchanged.
> **Phase 2 boundary**: `--schedule`, Bot `/ask`, and Bot `/batch` still do not expose index entrypoints. Web/API, Bot `/analyze`, the one-shot `--stocks` entry, and the GitHub Actions daily workflow support indices.
### Index watchlist configuration
`STOCK_LIST` accepts index targets mixed in with stocks under the following rules at two entry points: the one-shot `--stocks` argument and the GitHub Actions daily workflow (`GITHUB_ACTIONS=true` no-arg `full`/`stocks-only` runs). Local `.env`/Docker no-arg default runs do not participate in index classification; `STOCK_LIST` keeps its existing stock semantics there. To analyze indices from local `.env`/Docker, pass `--stocks` explicitly (one-shot run) or use the GitHub Actions entry. Classification runs before stock parsing: an explicit form that matches a registered index takes the `market=cn` index Pipeline; anything else continues on the stock path (`.CSI` prefixed forms excepted — see the rejection rule below). The classification happens **before normalization**, so you must write an explicit code form; bare codes are never "auto-promoted".
**Recognized code forms for registered indices:**
| Family | Canonical (registry identity) | Display / equivalent explicit forms | Example |
|--------|-------------------------------|--------------------------------------|---------|
| Shanghai (SSE / CSI series listed on SH) | `sh` + 6 digits | `{code}.SH` (plus cross-family aliases such as `sz399300``sh000300`) | `sh000016`, `000016.SH` (SSE 50) |
| Shenzhen (SZSE / CNI series) | `sz` + 6 digits | `{code}.SZ` | `sz399006`, `399006.SZ` (ChiNext); `sz399365`, `399365.SZ` (CNI Grain) |
| CSI (CSI index code ranges) | `csi` + 6 digits | `{code}.CSI` | `csi930606`, `930606.CSI` (CSI Steel); `csi930955`, `930955.CSI` (CSI Dividend Low Vol 100) |
| US indices | not in the CN registry | bare code as-is | `NDX` (Nasdaq 100), `SPX`, `DJI` |
> The full list is defined by `scripts/stock_index_seeds/index_registry.csv`; the bundled `apps/dsa-web/public/stocks.index.json` is generated from it (`python scripts/generate_index_from_csv.py --index-only`). For SH/SZ families the exchange-suffix form is equivalent to the canonical; for the `csi` family the canonical is `csi930606` and the equivalent explicit form is `930606.CSI` (both converge to the same index).
**Rules with correct/incorrect examples:**
- **Use a registered code in its explicit form** (prefix or exchange suffix); do not write unregistered code ranges (the whole batch is rejected, or the token is treated as a stock). Matching is case-insensitive: `930955.csi`, `CSI930955`, and `930955.CSI` are equivalent; the canonical and the explicit alias/display of the same index are equivalent (`csi930606``930606.CSI`, `sz399365``399365.SZ`). A wrong exchange suffix (e.g. `399365.SH`) is rejected as unsupported (that suffix does not accept that base code), while a bare 6-digit code falls back to the stock path.
- **An unregistered `.CSI` target rejects the whole batch (only at the `--stocks` and GitHub Actions entries)**: a one-shot `--stocks` or a GitHub Actions daily-workflow `STOCK_LIST` containing an unregistered `.CSI` (e.g. `930956.CSI`, or any CSI range other than the registered ones) is rejected as a whole — none of the targets run. The local `.env`/Docker no-arg default path does not classify indices, so this whole-batch rejection does not apply there. Web/API async batches reject only that target and sync/single requests return 4xx (see the Web/API index entry section below for the rejected-list semantics). A wrong exchange suffix (e.g. `399365.SH`) is likewise rejected as unsupported.
- **Bare codes are never auto-promoted to indices**: a bare 6-digit code such as `399365` / `930606` / `000016` is always treated on the stock path (the "bare code defaults to stock" contract stays); only an ambiguity note is logged. Note: for SZ-family indices (e.g. `sz399365`) the stock canonical and the index canonical share the same `sz{code}` prefixed form, so the same-code bare stock and the index live in the same persistence key domain — mind the fold semantics in the canonical-isolation section below when mixing them in one watchlist.
- **For US indices such as `NDX`, the bare code is already the correct form**: they use the dedicated `us_index_mapping` US-index route; do **not** add CN prefixes/suffixes (e.g. `NDX.US`, `usNDX`), which could push them onto the stock path.
- **ETFs (e.g. 159934 gold ETF, XOP) are not in the index registry**: they keep the stock-path ETF semantics, unrelated to any index sharing the same digits; write the bare code directly — an index prefix is neither needed nor supported.
**Example `STOCK_LIST` (comma-separated):**
```text
600519,sh000016,930606.CSI,sz399365,159934,NDX
```
This mixes a stock (600519 Kweichow Moutai), an SSE index (sh000016), a CSI index (930606.CSI), an SZ index (sz399365), an A-share ETF (159934 — stock path) and a US index (NDX — US-index route). Because a batch containing an unregistered `.CSI` does not run at all, **check each index against the registry before configuring it**.
### Index vs stock Dashboard canonical isolation (PR #2312)
Registered indices are persisted under their lowercase canonical identity (`sh000016` / `sz399001` / `csi930955`). History filtering, delete-by-code, counts, and the stock bar now all use the parser for asset typing, so index records and the same-code bare stock (e.g. `000016`) are strictly isolated and never collapse:
Registered indices are persisted under their lowercase canonical identity (`sh000016` / `sz399001` / `csi930955`). History filtering, delete-by-code, counts, and the stock bar now all use the parser for asset typing, so index records and the same-code bare stock (e.g. `000016`) are strictly isolated and never collapse. Exception: for SZ-family indices (e.g. `sz399365`) the bare-stock canonical and the index canonical share the same `sz{code}` prefixed form (a bare `399365` parsed as a stock already canonicalizes to `sz399365`), so they naturally share one key; same-code bare stocks and indices in that family are outside the isolation scope and fold together when mixed in a watchlist:
- **History candidates**: index queries (`sh000016`, `SH000016`, `000016.SH`, `sz399001`, `csi930955`, `930955.CSI` and other explicit forms) reach records persisted under the lowercase canonical, the legacy uppercase canonical, or an explicit alias — but **never** the bare same-code stock record; a bare query (`000016` / `930955`) likewise never reaches index records. Stock aliases, HK, and offshore markets keep their existing equivalence semantics.
- **Delete and count**: `DELETE /api/v1/history/by-code/{code}` and history totals converge every explicit index form; a code with no records still returns `deleted=0` (no breaking 404).

120
main.py
View File

@@ -500,6 +500,46 @@ def _compute_trading_day_filter(
return (filtered_codes, effective_region, should_skip_all)
def _classify_stock_list_tokens(
tokens: List[str],
*,
entry_label: str,
) -> Optional[Tuple[List[str], List[AnalysisTarget]]]:
"""Classify stock-list tokens into aligned ``(stock_codes, analysis_targets)``.
Shared by the one-shot ``--stocks`` entry and the GitHub Actions
``STOCK_LIST`` entry: explicit index tokens resolve through
``parse_analysis_target`` and keep their index canonical, while every
other token keeps the legacy ``resolve_index_stock_code_for_analysis``
path. ``unsupported`` tokens (e.g. an unregistered ``.CSI``) reject the
whole run up-front with an explicit error — provider calls are never
reached for any token in the batch. Returns ``None`` after logging the
rejection, so callers exit without running analysis.
"""
targets = [parse_analysis_target(t) for t in tokens]
unsupported = [
(token, target.unsupported_reason or "unsupported target")
for token, target in zip(tokens, targets)
if target.asset_type == ParseStatus.UNSUPPORTED
]
if unsupported:
token, reason = unsupported[0]
logger.error(
"%s 包含不支持的目标 %r%s;本轮不执行任何分析。",
entry_label,
token,
reason,
)
return None
stock_codes = [
t.canonical_id
if t.asset_type == ParseStatus.INDEX
else resolve_index_stock_code_for_analysis(raw)
for t, raw in zip(targets, tokens)
]
return stock_codes, targets
def _run_market_review_with_shared_lock(
config: Config,
run_market_review_func: Callable[..., Any],
@@ -1363,6 +1403,31 @@ def _resolve_scheduled_stock_codes(stock_codes: Optional[List[str]]) -> Optional
return None
def _skips_stock_entry(args: argparse.Namespace, config: Config) -> bool:
"""Whether the active mode consumes a stock list at all.
Modes that do not analyze the watchlist must not parse/classify
``--stocks``/``STOCK_LIST`` tokens nor refresh the stock-index registry
before the mode dispatch, otherwise an unsupported index token would
wrongly block a run that never consumes it. Covered modes:
``--backtest``, ``--market-review``, ``--serve-only``/``--webui-only``,
``--portfolio`` (any value) and ``--schedule``/``config.schedule_enabled``.
``--serve`` (not serve-only) and plain one-shot runs still consume the
stock list and stay outside the guard. The webui-only flag is read
directly here because the webui -> serve mapping happens only later in
``main()``.
"""
return bool(
getattr(args, "backtest", False)
or getattr(args, "market_review", False)
or getattr(args, "serve_only", False)
or getattr(args, "webui_only", False)
or bool(getattr(args, "portfolio", None))
or getattr(args, "schedule", False)
or getattr(config, "schedule_enabled", False)
)
def _reload_runtime_config() -> Config:
"""Reload config from the latest persisted `.env` values for scheduled runs."""
_reload_env_file_values_preserving_overrides()
@@ -1499,28 +1564,50 @@ def main() -> int:
# 解析股票列表(统一为大写 Issue #355
# Story 1.5: 一次性 --stocks 入口使用 parse_analysis_target 构造结构化
# AnalysisTarget 列表指数目标sh/sz/csi 前缀与 .CSI alias在入口即保留
# 身份语义unsupported 目标在 Pipeline 内于 provider 调用前拒绝
# 身份语义unsupported 目标(如未登记 .CSI在入口明确拒绝不进入 provider。
# 不消费个股列表的模式(回测/大盘复盘/仅服务/portfolio/定时)整体跳过
# 分类与索引刷新_skips_stock_entry避免无关坏 token 拦截模式主体。
_skip_stock_entry = _skips_stock_entry(args, config)
if args.stocks and getattr(args, "portfolio", None):
logger.info("同时指定了 --portfolio实际分析时 portfolio 将覆盖 --stocks")
stock_codes = None
analysis_targets = None
if args.stocks:
if args.stocks and not _skip_stock_entry:
# 在解析 --stocks 前先 best-effort 刷新股票索引注册表,保证首次运行能吃到
# 刷新后的 alias/身份;失败/超时/禁用不阻断分析。仅 --stocks 入口需要,
# 其他模式由 run_full_analysis 内的既有刷新覆盖。
_refresh_stock_index_cache_for_analysis(config)
tokens = [c for c in split_stock_list(args.stocks) if (c or "").strip()]
targets = [parse_analysis_target(t) for t in tokens]
# 指数目标使用 parser canonical非指数目标沿用既有
# resolve_index_stock_code_for_analysis 语义(保留 JP/KR 等解析行为)。
stock_codes = [
t.canonical_id
if t.asset_type == ParseStatus.INDEX
else resolve_index_stock_code_for_analysis(raw)
for t, raw in zip(targets, tokens)
]
analysis_targets = targets
resolved = _classify_stock_list_tokens(tokens, entry_label="--stocks")
if resolved is None:
return 1
stock_codes, analysis_targets = resolved
logger.info(f"使用命令行指定的股票列表: {stock_codes}")
if getattr(args, "portfolio", None):
logger.info("同时指定了 --portfolio实际分析时 portfolio 将覆盖 --stocks")
elif (
os.getenv("GITHUB_ACTIONS") == "true"
and not _skip_stock_entry
):
# 每日工作流GITHUB_ACTIONS=true无参数运行 `python main.py`full 或
# stocks-only 模式):把 STOCK_LIST token 分类为与一次性 --stocks 等价的
# 结构化 target显式指数 token 进入指数路径、个股 token 保持既有路径。
# 不消费个股列表的模式由 _skips_stock_entry 整体跳过(不刷新不分类),
# 本地默认路径不在本入口构造 target。
raw_value = config.stock_list or []
raw_tokens = (
split_stock_list(raw_value)
if isinstance(raw_value, str)
else [str(c).strip() for c in raw_value if str(c or "").strip()]
)
raw_tokens = [c for c in raw_tokens if (c or "").strip()]
if raw_tokens:
_refresh_stock_index_cache_for_analysis(config)
resolved = _classify_stock_list_tokens(
raw_tokens, entry_label="GitHub Actions STOCK_LIST"
)
if resolved is None:
return 1
stock_codes, analysis_targets = resolved
logger.info(f"GitHub Actions 默认 STOCK_LIST: {stock_codes}")
# === 处理 --webui / --webui-only 参数,映射到 --serve / --serve-only ===
if args.webui:
@@ -1668,6 +1755,9 @@ def main() -> int:
# 模式2: 定时任务模式
if args.schedule or config.schedule_enabled:
if start_serve:
# 模式2 + Web 服务:此路径来自 --schedule/--webui/--serve 与
# config.schedule_enabled 的组合(均已入 guard由 API runtime
# scheduler 接管;--serve-only 已在上面 serve_only 分支返回。
logger.info("模式: Web/API runtime scheduler")
logger.info(f"Web 服务运行中: http://{args.host}:{args.port}")
logger.info("Web/API runtime scheduler 已接管定时任务,保存设置会作用于当前进程")
@@ -1692,7 +1782,7 @@ def main() -> int:
logger.info(f"启动时立即执行: {should_run_immediately}")
from src.scheduler import run_with_schedule
scheduled_stock_codes = _resolve_scheduled_stock_codes(stock_codes)
scheduled_stock_codes = _resolve_scheduled_stock_codes(args.stocks or None)
schedule_time_provider = _build_schedule_time_provider(config.schedule_time)
schedule_times_provider = _build_schedule_times_provider(config.schedule_time)

View File

@@ -30,3 +30,5 @@ csi931052,931052.CSI,国信价值,,东财 2.931052,100
csi931446,931446.CSI,东证红利低波,,东财 2.931446,100
csi931643,931643.CSI,科创创业50,,东财 2.931643,100
csi932366,932366.CSI,300现金流,,东财 2.932366,100
sz399365,sz399365,国证粮食,399365.SZ,腾讯,100
csi930606,930606.CSI,中证钢铁,,东财 2.930606,100
1 canonical_code display_code name_zh aliases name_source popularity
30 csi931446 931446.CSI 东证红利低波 东财 2.931446 100
31 csi931643 931643.CSI 科创创业50 东财 2.931643 100
32 csi932366 932366.CSI 300现金流 东财 2.932366 100
33 sz399365 sz399365 国证粮食 399365.SZ 腾讯 100
34 csi930606 930606.CSI 中证钢铁 东财 2.930606 100

View File

@@ -665,16 +665,16 @@ class TestPinyin:
# Index registry seed generation
# ---------------------------------------------------------------------------
class TestIndexRegistrySeed:
"""Seed generates exactly 31 index rows; index-only merge is stable."""
"""Seed generates exactly 33 index rows; index-only merge is stable."""
def test_seed_loads_31_rows(self):
def test_seed_loads_33_rows(self):
rows = load_index_registry_seed()
assert len(rows) == 31
assert len(rows) == 33
def test_seed_entries_build_valid_index_tuples(self):
rows = load_index_registry_seed()
entries = build_index_entries_from_seed(rows)
assert len(entries) == 31
assert len(entries) == 33
for entry in entries:
assert entry["market"] == "CN"
assert entry["assetType"] == "index"
@@ -687,10 +687,12 @@ class TestIndexRegistrySeed:
def test_seed_canonical_set_matches_manifest(self):
rows = load_index_registry_seed()
canonicals = {row["canonical_code"] for row in rows}
assert len(canonicals) == 31
assert len(canonicals) == 33
# Spot-check the 5 original + CSI entries.
assert {"sh000300", "sh000016", "sh000688", "sz399001", "sz399006"} <= canonicals
assert {"csi930955", "csi932365", "csi931052"} <= canonicals
# Newly added user-facing indices (Issue #2303).
assert {"sz399365", "csi930606"} <= canonicals
def test_seed_csi_display_is_code_dot_csi(self):
rows = load_index_registry_seed()
@@ -732,7 +734,7 @@ class TestIndexRegistrySeed:
with pytest.raises(ValueError, match="canonical must match"):
validate_index_registry(entries)
def test_index_only_preserves_non_index_rows_and_appends_31(self, tmp_path):
def test_index_only_preserves_non_index_rows_and_appends_33(self, tmp_path):
output = tmp_path / "stocks.index.json"
output.write_text(
json.dumps(
@@ -749,8 +751,8 @@ class TestIndexRegistrySeed:
index_rows = [x for x in merged if len(x) > 7 and x[7] == "index"]
# Non-index rows preserved in order.
assert [x[0] for x in non_index] == ["000001.SZ", "600519.SH"]
# Exactly 31 index rows appended.
assert len(index_rows) == 31
# Exactly 33 index rows appended.
assert len(index_rows) == 33
# Index rows sorted by canonical.
canonicals = [x[0] for x in index_rows]
assert canonicals == sorted(canonicals)
@@ -783,8 +785,8 @@ class TestIndexRegistrySeed:
run_index_only(output, test=True)
assert output.read_bytes() == before
def test_full_path_merge_includes_31_index_rows(self, tmp_path, monkeypatch):
"""The full rebuild path (``main()``) merges the same 31 index
def test_full_path_merge_includes_33_index_rows(self, tmp_path, monkeypatch):
"""The full rebuild path (``main()``) merges the same 33 index
rows before compression, so a full stock-index rebuild never erases the
index registry entries."""
import generate_index_from_csv as gen
@@ -810,7 +812,7 @@ class TestIndexRegistrySeed:
index.extend(index_entries)
compressed = compress_index(index)
index_rows = [item for item in compressed if len(item) > 7 and item[7] == "index"]
assert len(index_rows) == 31
assert len(index_rows) == 33
# Stock rows are preserved alongside the index rows.
stock_rows = [item for item in compressed if len(item) > 7 and item[7] == "stock"]
assert len(stock_rows) == 2

View File

@@ -61,6 +61,14 @@ class MainScheduleModeTestCase(unittest.TestCase):
os.chdir(self.temp_dir.name)
self.env_patch = patch.dict(os.environ, {"ENV_FILE": str(self.env_path)}, clear=False)
self.env_patch.start()
# CI runner 自动注入 GITHUB_ACTIONS=true会让无显式环境的 main.main()
# 测试误入 Actions STOCK_LIST 分类分支(其 _DummyConfig 不含
# stock_list 属性。类级默认关闭Actions 入口相关测试在用例内
# 显式 patch 为 true 覆盖。
self.actions_env_patch = patch.dict(
os.environ, {"GITHUB_ACTIONS": "false"}, clear=False
)
self.actions_env_patch.start()
Config.reset_instance()
root_logger = logging.getLogger()
self._original_root_handlers = list(root_logger.handlers)
@@ -80,6 +88,7 @@ class MainScheduleModeTestCase(unittest.TestCase):
os.chdir(self.original_cwd)
Config.reset_instance()
self.env_patch.stop()
self.actions_env_patch.stop()
for key in _MAIN_IMPORT_ENV_ADDITIONS:
os.environ.pop(key, None)
for key, value in _MAIN_IMPORT_ENV_OVERRIDES.items():
@@ -129,6 +138,7 @@ class MainScheduleModeTestCase(unittest.TestCase):
"agent_event_alert_rules_json": "",
"agent_event_monitor_interval_minutes": 5,
"daily_market_context_enabled": True,
"market_review_enabled": False,
}
defaults.update(overrides)
return _DummyConfig(**defaults)
@@ -528,6 +538,363 @@ class MainScheduleModeTestCase(unittest.TestCase):
self.assertEqual(calls[1][2][0].asset_type, ParseStatus.INDEX)
self.assertEqual(calls[1][2][0].canonical_id, "csi930955")
def test_standalone_run_rejects_unsupported_stocks_token(self) -> None:
"""`--stocks` 携带未登记 `.CSI` 目标时在入口明确报错并返回非零,
不再静默走股票路径或发起 provider 调用。"""
args = self._make_args(stocks="600519,930956.CSI")
config = self._make_config(run_immediately=True)
with patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main.setup_logging"), \
patch("main._refresh_stock_index_cache_for_analysis"), \
patch("main.run_full_analysis") as run_full_analysis:
exit_code = main.main()
self.assertEqual(exit_code, 1)
run_full_analysis.assert_not_called()
def test_standalone_run_actions_stock_list_builds_index_targets(self) -> None:
"""`GITHUB_ACTIONS=true` 且无 `--stocks` 时,`main.main()` 把默认
`STOCK_LIST` 的显式指数 token 分类为 INDEX target、个股 token 保持
legacy 语义(与一次性 `--stocks` 构造等价)。"""
args = self._make_args()
config = self._make_config(run_immediately=True)
config.stock_list = ["sh000016", "600519", "930955.CSI"]
with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}), \
patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main.setup_logging"), \
patch("main._refresh_stock_index_cache_for_analysis"), \
patch("main.run_full_analysis") as run_full_analysis:
exit_code = main.main()
self.assertEqual(exit_code, 0)
run_full_analysis.assert_called_once()
stock_codes = run_full_analysis.call_args.args[2]
analysis_targets = run_full_analysis.call_args.kwargs["analysis_targets"]
self.assertEqual(stock_codes, ["sh000016", "600519", "csi930955"])
self.assertEqual(
[t.asset_type for t in analysis_targets],
["index", "stock", "index"],
)
self.assertEqual(
[t.canonical_id for t in analysis_targets],
["sh000016", "sh600519", "csi930955"],
)
def test_standalone_run_local_default_does_not_build_targets(self) -> None:
"""非 Actions 环境的无参数默认路径不构造结构化 targets本地/
`--schedule` 热刷新语义保持不变)。"""
args = self._make_args()
config = self._make_config(run_immediately=True)
config.stock_list = ["600519"]
with patch.dict(os.environ, {"GITHUB_ACTIONS": ""}), \
patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main.setup_logging"), \
patch("main._refresh_stock_index_cache_for_analysis"), \
patch("main.run_full_analysis") as run_full_analysis:
exit_code = main.main()
self.assertEqual(exit_code, 0)
run_full_analysis.assert_called_once()
self.assertIsNone(run_full_analysis.call_args.args[2])
self.assertIsNone(run_full_analysis.call_args.kwargs.get("analysis_targets"))
def test_actions_backtest_with_bad_stock_list_reaches_backtest_service(self) -> None:
"""Review 反例:`GITHUB_ACTIONS=true` + `--backtest` 时不消费个股列表,
含未登记 `.CSI` 的 STOCK_LIST 不得整批拒绝,必须进入回测分支。"""
args = self._make_args(backtest=True)
config = self._make_config(run_immediately=True)
config.stock_list = ["930956.CSI"]
stats = {
"processed": 1,
"saved": 1,
"completed": 1,
"insufficient": 0,
"errors": 0,
}
with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}), \
patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main.setup_logging"), \
patch("main._refresh_stock_index_cache_for_analysis") as refresh, \
patch("main._classify_stock_list_tokens") as classify, \
patch(
"src.services.backtest_service.BacktestService",
) as backtest_class, \
patch("main.logger.error") as error_log:
backtest_service = backtest_class.return_value
backtest_service.run_backtest.return_value = stats
exit_code = main.main()
self.assertEqual(exit_code, 0)
refresh.assert_not_called()
classify.assert_not_called()
error_log.assert_not_called()
backtest_class.assert_called_once_with()
backtest_service.run_backtest.assert_called_once_with(
code=None,
force=False,
eval_window_days=None,
)
def test_actions_backtest_stock_list_classification_skipped_when_no_backtest(self) -> None:
"""对照组:`GITHUB_ACTIONS=true` 无模式参数的坏 STOCK_LIST 仍整批拒绝
(分类契约未回归)。"""
args = self._make_args()
config = self._make_config(run_immediately=True)
config.stock_list = ["930956.CSI"]
with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}), \
patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main.setup_logging"), \
patch("main._refresh_stock_index_cache_for_analysis"), \
patch("main.run_full_analysis") as run_full_analysis, \
patch("main.logger.error") as error_log:
exit_code = main.main()
self.assertEqual(exit_code, 1)
run_full_analysis.assert_not_called()
error_log.assert_any_call(
"%s 包含不支持的目标 %r%s;本轮不执行任何分析。",
"GitHub Actions STOCK_LIST",
"930956.CSI",
unittest.mock.ANY,
)
def test_portfolio_futu_with_bad_stocks_token_reaches_run_full_analysis(self) -> None:
"""Review 反例:`--portfolio futu --stocks 930956.CSI` 同框时分类整体跳过,
坏 token 不拦截run_full_analysis 内 portfolio 覆盖 `--stocks`。"""
args = self._make_args(portfolio="futu", stocks="930956.CSI")
config = self._make_config(run_immediately=True)
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main.setup_logging"), \
patch("main._refresh_stock_index_cache_for_analysis") as refresh, \
patch("main._classify_stock_list_tokens") as classify, \
patch(
"src.brokers.futu.portfolio.load_futu_stock_codes",
return_value=["AAPL"],
), \
patch(
"main._compute_trading_day_filter",
return_value=(["AAPL"], "us", False),
), \
patch("main._run_analysis_with_runtime_scheduler_lock") as run_with_lock:
exit_code = main.main()
self.assertEqual(exit_code, 0)
refresh.assert_not_called()
classify.assert_not_called()
run_with_lock.assert_called_once_with(config, args, None, None)
def test_portfolio_futu_with_bad_stocks_token_runs_portfolio_codes(self) -> None:
"""对照组:`--portfolio futu --stocks 600519` 时 run_full_analysis 内
portfolio 覆盖 `--stocks`,覆盖语义不回归。走真实运行时锁路径
threading.Lock无进程/磁盘副作用),验证 loader 被调用且进入
run_full_analysis 的代码为 portfolio 持仓。"""
args = self._make_args(portfolio="futu", stocks="600519")
config = self._make_config(run_immediately=True)
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main.setup_logging"), \
patch("main._refresh_stock_index_cache_for_analysis"), \
patch(
"src.brokers.futu.portfolio.load_futu_stock_codes",
return_value=["AAPL", "HK00700"],
) as loader, \
patch(
"main._compute_trading_day_filter",
return_value=(["AAPL", "HK00700"], "us,hk", False),
), \
patch("src.core.pipeline.StockAnalysisPipeline"):
exit_code = main.main()
self.assertEqual(exit_code, 0)
loader.assert_called_once_with()
def test_actions_portfolio_futu_with_bad_stock_list_reaches_run_full_analysis(self) -> None:
"""Review 反例补格:`GITHUB_ACTIONS=true` + `--portfolio futu` 时 Actions
分支的 STOCK_LIST 分类同样整体跳过,坏 watchlist 不拦截 portfolio 覆盖。"""
args = self._make_args(portfolio="futu")
config = self._make_config(run_immediately=True)
config.stock_list = ["930956.CSI"]
with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}), \
patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main.setup_logging"), \
patch("main._refresh_stock_index_cache_for_analysis") as refresh, \
patch("main._classify_stock_list_tokens") as classify, \
patch(
"src.brokers.futu.portfolio.load_futu_stock_codes",
return_value=["AAPL"],
), \
patch(
"main._compute_trading_day_filter",
return_value=(["AAPL"], "us", False),
), \
patch("main._run_analysis_with_runtime_scheduler_lock") as run_with_lock:
exit_code = main.main()
self.assertEqual(exit_code, 0)
refresh.assert_not_called()
classify.assert_not_called()
run_with_lock.assert_called_once_with(config, args, None, None)
def test_portfolio_futu_with_bad_stocks_token_runs_pipeline_with_portfolio_codes(self) -> None:
"""断言到达真实风险层portfolio 覆盖后 pipeline.run 收到的 stock_codes
来自 Futu loader与 `--stocks` 无关。"""
args = self._make_args(portfolio="futu", stocks="930956.CSI")
config = self._make_config(run_immediately=True)
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main.setup_logging"), \
patch("main._refresh_stock_index_cache_for_analysis"), \
patch(
"src.brokers.futu.portfolio.load_futu_stock_codes",
return_value=["AAPL", "HK00700"],
), \
patch(
"main._compute_trading_day_filter",
return_value=(["AAPL", "HK00700"], "us,hk", False),
), \
patch("src.core.pipeline.StockAnalysisPipeline") as pipeline_class:
exit_code = main.main()
self.assertEqual(exit_code, 0)
pipeline_run_kwargs = pipeline_class.return_value.run.call_args.kwargs
self.assertEqual(pipeline_run_kwargs["stock_codes"], ["AAPL", "HK00700"])
self.assertIsNone(pipeline_run_kwargs["analysis_targets"])
def test_schedule_mode_with_bad_stocks_token_reaches_scheduler(self) -> None:
"""Review 反例:`--schedule --stocks 930956.CSI` 不因坏 token 分类退出,
进入 scheduler且保留既有"忽略启动快照"警告。"""
args = self._make_args(schedule=True, stocks="930956.CSI")
config = self._make_config(schedule_enabled=False)
scheduled_call = {}
def fake_run_with_schedule(
task,
schedule_time,
run_immediately,
background_tasks=None,
schedule_time_provider=None,
):
scheduled_call["schedule_time"] = schedule_time
scheduled_call["run_immediately"] = run_immediately
scheduled_call["background_tasks"] = background_tasks or []
scheduled_call["resolved_schedule_time"] = (
schedule_time_provider() if schedule_time_provider is not None else None
)
task()
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main._reload_runtime_config", return_value=config), \
patch("main._build_schedule_time_provider", return_value=lambda: "18:00"), \
patch("main.setup_logging"), \
patch("main.run_full_analysis") as run_full_analysis, \
patch("main.logger.warning") as warning_log, \
patch("main._refresh_stock_index_cache_for_analysis") as refresh, \
patch("main._classify_stock_list_tokens") as classify, \
patch("src.scheduler.run_with_schedule", side_effect=fake_run_with_schedule):
exit_code = main.main()
self.assertEqual(exit_code, 0)
refresh.assert_not_called()
classify.assert_not_called()
self.assertEqual(
scheduled_call,
{
"schedule_time": "18:00",
"run_immediately": True,
"background_tasks": [],
"resolved_schedule_time": "18:00",
},
)
run_full_analysis.assert_called_once_with(config, args, None)
warning_log.assert_any_call(
"定时模式下检测到 --stocks 参数;计划执行将忽略启动时股票快照,并在每次运行前重新读取最新的 STOCK_LIST。"
)
def test_serve_only_with_bad_stocks_token_enters_service_loop(self) -> None:
"""Review 反例:`--serve-only --stocks 930956.CSI` 不因坏 token 分类退出,
进入仅服务模式循环time.sleep 以 KeyboardInterrupt 收尾)。"""
args = self._make_args(serve_only=True, stocks="930956.CSI")
config = self._make_config(webui_enabled=False)
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main.prepare_webui_frontend_assets", return_value=True), \
patch("main.start_api_server"), \
patch("main.start_bot_stream_clients"), \
patch("main._refresh_stock_index_cache_for_analysis") as refresh, \
patch("main._classify_stock_list_tokens") as classify, \
patch("main.time.sleep", side_effect=KeyboardInterrupt):
exit_code = main.main()
self.assertEqual(exit_code, 0)
refresh.assert_not_called()
classify.assert_not_called()
def test_standalone_run_actions_stock_list_rejects_unsupported(self) -> None:
"""Actions STOCK_LIST 携带未登记 `.CSI` token 时与 `--stocks` 同样
在入口明确拒绝并返回非零,不会静默丢弃该 token 后继续运行。"""
args = self._make_args()
config = self._make_config(run_immediately=True)
config.stock_list = ["sh000016", "930956.CSI"]
with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}), \
patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main.setup_logging"), \
patch("main._refresh_stock_index_cache_for_analysis"), \
patch("main.run_full_analysis") as run_full_analysis:
exit_code = main.main()
self.assertEqual(exit_code, 1)
run_full_analysis.assert_not_called()
def test_standalone_run_actions_market_only_skips_token_classification(self) -> None:
"""Actions 的 market-only 模式(`--market-review`)不经个股列表分析,
STOCK_LIST 分类入口必须整体跳过(含 unsupported 拒绝),保持既有
大盘复盘行为。"""
args = self._make_args(market_review=True)
config = self._make_config(run_immediately=True)
config.stock_list = ["930956.CSI"]
with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}), \
patch("main.parse_arguments", return_value=args), \
patch("main.get_config", return_value=config), \
patch("main.setup_logging"), \
patch("main._refresh_stock_index_cache_for_analysis") as refresh, \
patch("main.run_full_analysis") as run_full_analysis, \
patch("main._run_market_review_with_shared_lock") as run_review, \
patch("src.core.trading_calendar.get_open_markets_today", return_value={"cn"}), \
patch("src.core.trading_calendar.compute_effective_region", return_value="cn"):
run_review.return_value = object()
exit_code = main.main()
self.assertEqual(exit_code, 0)
refresh.assert_not_called()
run_full_analysis.assert_not_called()
run_review.assert_called_once()
def test_standalone_run_returns_nonzero_when_startup_analysis_reports_failure(self) -> None:
args = self._make_args()
config = self._make_config(run_immediately=True)

View File

@@ -372,18 +372,20 @@ class TestEdgeCases:
# Default registry — public API surface.
# ---------------------------------------------------------------------------
class TestDefaultIndexRegistry:
def test_default_registry_has_31_entries(self) -> None:
def test_default_registry_has_33_entries(self) -> None:
registry = default_index_registry()
assert len(registry) == 31
assert len(registry) == 33
def test_default_registry_canonical_ids(self) -> None:
registry = default_index_registry()
ids = {entry.canonical_id for entry in registry}
assert len(ids) == 31
assert len(ids) == 33
# The 5 original hard-coded indices are preserved.
assert {"sh000300", "sh000016", "sh000688", "sz399001", "sz399006"} <= ids
# CSI entries are present.
assert {"csi930955", "csi932365"} <= ids
# Newly added user-facing indices (Issue #2303).
assert {"sz399365", "csi930606"} <= ids
def test_default_registry_find_by_prefixed_code(self) -> None:
registry = default_index_registry()