mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 02:43:35 +08:00
* 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:
@@ -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#指数自选股配置) | ✅ |
|
||||
|
||||
**新闻源配置(推荐)**
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 之前即被拒绝(退出码 2),completed 须 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` 事件并退出 1(stderr 保留异常证据,`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/` 改动
|
||||
|
||||
@@ -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 或邮件 | 对应通知平台 |
|
||||
|
||||
### 可选配置项
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)**
|
||||
|
||||
|
||||
@@ -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 股 ETF(159934,走股票路径)与美股指数(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。
|
||||
|
||||
@@ -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
120
main.py
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user