diff --git a/.agents/skills/tushare/SKILL.md b/.agents/skills/tushare/SKILL.md new file mode 100644 index 0000000..d729570 --- /dev/null +++ b/.agents/skills/tushare/SKILL.md @@ -0,0 +1,822 @@ +--- +name: tushare +description: 面向中文自然语言的 Tushare 数据研究技能。用于把“看看这只股票最近怎么样”“帮我查财报趋势”“最近哪个板块最强”“北向资金在买什么”“给我导出一份行情数据”这类请求,转成可执行的数据获取、清洗、对比、筛选、导出与简要分析流程。适用于 A 股、指数、ETF/基金、财务、估值、资金流、公告新闻、板块概念与宏观数据等研究场景。 +author: tushare.pro +version: 1.1.12 +credentials: + - name: TUSHARE_TOKEN + description: Tushare Token,用于认证和授权访问Tushare数据服务。 + how_to_get: "https://tushare.pro/register" +requirements: + python: 3.7+ + packages: + - name: tushare + environment_variables: + - name: TUSHARE_TOKEN + required: false + sensitive: true + network_access: true +--- + +# tushare + +把自然语言财经数据请求,转成可执行的 Tushare 数据工作流。 + +这是一个面向自然语言的金融数据研究 skill。 + + +## What this skill is for + +使用这个 skill 的典型场景: + +- 看某只股票、指数、ETF 最近走势 +- 查公司基本资料、估值、财务趋势 +- 做多标的横向对比 +- 看资金流、北向资金、龙虎榜、板块强弱 +- 梳理公告、新闻、研报、政策线索 +- 查看 CPI / PPI / PMI / 社融 / 利率等宏观数据 +- 导出 CSV / parquet 供后续分析或回测使用 +- 生成简洁研究摘要,而不是只吐原始字段表 + +先理解用户要解决什么问题,再去选接口、取数、整理、解释、交付。 + +*** + +## When to use + +当用户表达以下意图时,优先使用本 skill: + +### 行情 / 趋势 + +- 看下 XX 最近怎么样 +- XX 这段时间涨得怎么样 +- 今年以来表现如何 +- 最近有没有放量 +- 这票最近强不强 + +### 财务 / 估值 / 公司质量 + +- 看下 XX 财报 +- 最近几个季度利润趋势 +- 财务质量怎么样 +- 现金流好不好 +- 现在估值算高吗 +- 帮我看 PE / PB / ROE / 毛利率 + +### 对比 / 排行 / 筛选 + +- XX 和 YY 谁更强 +- 帮我横向比较一下 +- 哪些公司利润增长更快 +- 帮我筛一下高 ROE 低负债 +- 给我排个前十 + +### 板块 / 指数 / 主题 + +- 最近哪个板块最强 +- 半导体最近怎么样 +- 机器人为什么涨 +- 指数成分股有哪些 +- 哪些主题最热 + +### 资金流 / 情绪 + +- 最近资金在买什么 +- 北向资金最近流向哪里 +- 哪个板块最吸金 +- 主力资金流入最多的是谁 +- 龙虎榜上有什么看点 + +### 公告 / 新闻 / 研报 / 政策 + +- 最近有什么公告 +- 帮我梳理下 XX 公告 +- 最近有没有什么催化 +- 最近新闻面怎么样 +- 最近有什么重要政策 + +### 宏观 / 跨市场 + +- 最近宏观环境怎么样 +- CPI / PMI 最近怎么看 +- 当前市场风格偏什么 +- 大盘环境偏多还是偏空 +- 港股 / 美股 / 美债最近怎么样 + +### 数据导出 / 研究准备 + +- 给我导出一份行情数据 +- 把近两年日线拉成 CSV +- 生成可回测的数据表 +- 拉一个研究表供后续分析 + +*** + +## What this skill is NOT for + +这个 skill 不适合: + +- 直接给买卖建议或替代投资顾问 +- 自动下单或执行交易 +- 需要毫秒级实时交易决策的场景 +- 复杂回测引擎、组合优化系统本身的实现(那是另一个工程) +- 在没有 Tushare 权限/积分支持的情况下强行伪造数据 + +如果数据权限不够、接口不可用或时间范围不合理,要明确说出限制,不要硬编。 + +*** + +## Natural-language trigger guide + +即使用户完全不说 `tushare`、`financials`、`macro` 这些术语,只要意图符合以下含义,也应该触发本 skill。 + +### 常见口语触发 + +- 看看这个股票最近怎么样 +- 给我快速研究一下 XX +- 上次说的那只票现在什么情况 +- 帮我看下财报 +- 最近哪个板块最强 +- 北向最近在买什么 +- 有什么催化消息 +- 这个公司值不值得重点看 +- 给我拉份数据 +- 导出成 CSV +- 帮我筛一批票 +- 把这几个公司对比一下 + +### 中文自然语言优先原则 + +用户说人话时,先理解任务,不要先回到接口名和字段名。 +优先把: + +- “最近” 解释成合理时间窗 +- “财报” 解释成最近 8 个季度 / 最近年度 +- “强不强” 解释成走势 + 相对强弱 + 活跃度 +- “资金关注” 解释成净流入、活跃成交、龙虎榜/北向等可用口径 + +如果任务有多个合理解释,再做最小澄清。 + +*** + +## Environment check + +在真正请求数据之前,先做前置校验: + +1. 检查 Python 是否可用, 版本要求 3.7+ +2. 检查 `tushare` 包是否已安装· +3. 检查 `TUSHARE_TOKEN` 是否存在. +4. 必要时做一次轻量接口冒烟测试(如交易日历 / 基础接口) +5. 如用户请求高权限接口,提前提示可能存在积分/权限限制 + +若缺失 token,直接提示最短修复路径,例如: + +```bash +export TUSHARE_TOKEN=your_token +``` + +不要等到主查询跑失败了才暴露环境问题。 + +*** + +## Intent taxonomy + +先识别任务类型,再决定接口组合。 + +### 1. 行情 / 趋势 + +典型问题: + +- 最近走势怎么样 +- 今年涨了多少 +- 最近波动大不大 +- 最近有没有放量 + +常用接口: + +- `daily` +- `pro_bar` +- `weekly` +- `monthly` +- `stk_mins` +- `rt_k` / `rt_min`(如确需实时口径且权限允许) +- `daily_basic` + +### 2. 基本资料 / 标的识别 + +典型问题: + +- 这是什么公司 / 什么指数 / 什么基金 +- 是创业板吗 / 是 ST 吗 / 什么时候上市 + +常用接口: + +- `stock_basic` +- `fund_basic` +- `index_basic` +- `stock_company` +- `stock_st` / `st` + +### 3. 财务 / 公司质量 + +典型问题: + +- 最近几个季度利润趋势 +- 最近几个季度营收和净利润趋势 +- 财务质量怎么样 +- ROE / 毛利率 / 现金流如何 + +常用接口: + +- `income`(营收 / 净利润趋势优先) +- `fina_indicator`(ROE / 毛利率 / 净利率等质量指标补充) +- `balancesheet` +- `cashflow` +- `forecast` +- `express` +- `disclosure_date` + +### 4. 估值 / 基本面指标 + +典型问题: + +- 现在估值高不高 +- 谁更便宜 +- PE / PB / 股息率如何 + +常用接口: + +- `daily_basic` +- `fina_indicator` + +### 5. 资金流 / 市场行为 + +典型问题: + +- 北向最近买什么 +- 主力资金流向 +- 龙虎榜情况 + +常用接口: + +- `moneyflow` +- `moneyflow_hsgt` +- `hsgt_top10` +- `top_list` +- `top_inst` +- `moneyflow_ind_dc` +- `moneyflow_mkt_dc` + +### 6. 板块 / 指数 / 主题 + +典型问题: + +- 最近哪个板块最强 +- 行业轮动如何 +- 某板块有哪些成分股 + +常用接口: + +- `index_basic` +- `index_daily` +- `index_classify` +- `index_member_all` +- `sw_daily` +- `ths_index` +- `ths_member` +- `dc_index` +- `dc_member` + +### 7. 打板 / 情绪 / 活跃度 + +典型问题: + +- 今天涨停梯队 +- 连板结构 +- 炸板率 / 情绪强弱 + +常用接口: + +- `limit_list_d` +- `limit_step` +- `kpl_list` +- `dc_hot` +- `ths_hot` + +### 8. 公告 / 新闻 / 研报 / 政策 + +典型问题: + +- 最近有什么公告或催化 +- 最近有什么研究报告 +- 最近政策面发生了什么 + +常用接口: + +- `anns_d` +- `news` +- `major_news` +- `research_report` +- `npr` +- `irm_qa_sh` +- `irm_qa_sz` + +### 9. 宏观 / 跨市场 + +典型问题: + +- CPI / PMI / 社融 / M2 +- 利率与收益率曲线 +- 港股 / 美股 / 美债数据 + +常用接口: + +- `cn_cpi` +- `cn_ppi` +- `cn_pmi` +- `cn_gdp` +- `cn_m` +- `sf_month` +- `shibor` +- `shibor_lpr` +- `us_tycr` +- `us_daily` +- `hk_daily` +- `index_global` + +### 10. 导出 / 研究准备 + +典型问题: + +- 导出某标的一段时间行情 +- 生成回测用数据表 +- 输出 CSV / parquet + +常用接口: + +- 取决于上游任务,核心是统一输出规则与命名规范 + +*** + +## Entity resolution rules + +### 标的解析 + +- 优先识别股票名、股票代码、指数名、ETF 名、基金名 +- 对中文简称先尝试匹配标准对象 +- 若重名或多解,列出候选并做最小澄清 +- 证券代码内部统一为标准格式,如:`600519.SH`、`000001.SZ` + +### 市场识别 + +- 默认先按 A 股理解,除非用户明确提到港股 / 美股 / 基金 / 债券 / 期货 +- 指数、ETF、个股要分开判断,不要混用接口 + +### 时间默认值 + +若用户没有明确给时间范围,使用合理默认: + +- “最近走势” → 默认近 20 个交易日 +- “这段时间 / 最近一段时间” → 默认近 3 个月 +- “财报 / 业绩” → 默认最近 8 个季度 + 最近年度 +- “资金流最近如何” → 默认近 5~20 个交易日,按任务粒度调整 +- “宏观最近如何” → 默认看最近 6~12 期 + +### 板块口径默认值 + +若用户只说“板块 / 行业 / 概念”但未指定分类体系: + +- 行业优先用申万 / 中信等较稳定口径 +- 概念优先同花顺 / 东方财富等主题口径 +- 若结论依赖具体口径差异,要明确说明使用了哪种分类 + +*** + +## Input normalization rules + +在请求数据前先做规范化: + +- 日期统一为 `YYYYMMDD` +- 检查 `start_date <= end_date` +- 用户输入未来日期时,自动裁剪到最近可用日期并提示 +- 裸代码如 `000001` 不要盲猜,能补全则说明补全规则,不能补全则澄清 +- 对冲突参数(如 `trade_date` 与 `start_date/end_date` 同时给)要先裁决,不要直接乱传 + +*** + +## Data retrieval rules + +### 文档先行 + +在写请求代码前,先确认: + +- 接口名是否正确 +- 必填参数 +- 可选参数 +- 返回字段 +- 积分 / 频率限制 + +不要仅凭记忆硬写字段名。 + +### 字段确认 + +对 `fields` 参数,优先使用已知字段白名单或接口文档确认。 +若用户要求字段不存在,应明确说明,而不是盲查。 + +### 默认分段拉取 + +长区间数据不要一次性全拉。 +建议: + +- 日线 / 周线 / 月线:按年或季度切片 +- 财报:按年份 / 报告期切片 +- 分钟数据:按月 / 周切片 +- 大批量多标的:按标的分批 + 日期分段 + +### 重试与限流 + +- 仅对瞬时错误(网络抖动、超时、429)进行有限重试 +- 参数错误、权限不足、字段错误不要盲重试 +- 批量拉取时加入节流,避免高频撞限 + +### 分段合并 + +分段拉取后: + +- 合并 +- 去重 +- 按主键排序 +- 记录失败分段 +- 若部分成功,要明确告诉用户哪些段失败了 + +*** + +## Output contract + +除非用户明确只要原始表,否则优先按这个结构输出: + +1. **一句话结论** +2. **数据范围与口径** +3. **关键指标 / 关键表格** +4. **异常点 / 风险点 / 解释限制** +5. **如有本地输出,给出文件路径** + +### 结果交付形态 + +按任务复杂度选择: + +- 小结果:Markdown 摘要 + 简短表格 +- 中等数据表:CSV +- 大规模 / 后续分析:Parquet +- 需要可复用流程:附 Python 脚本 +- 需要可视化时:输出图表 PNG 或说明可绘制图表 + +### 元信息 + +生成数据文件时,尽量同时记录: + +- 接口名 +- 请求参数 +- 拉取时间 +- 数据行数 +- 字段列表 +- 是否存在失败分段 / 缺失 + +*** + +## Workflow templates + +下面这些模板,是本 skill 的核心。 +不要直接从接口想起,而要从任务模板想起。 + +### 1. 单标的行情分析 + +适用: + +- 看下 XX 最近怎么样 +- 这票最近强不强 +- 今年以来表现如何 + +默认流程: + +1. 解析标的 +2. 确定时间范围 +3. 取行情 + 必要基础指标 +4. 总结区间涨跌、成交活跃度、高低点、波动 +5. 输出一句结论 + 关键数字 + +### 2. 多标的横向对比 + +适用: + +- XX 和 YY 谁更强 +- 把这几家公司对比一下 + +默认流程: + +1. 锁定对象 +2. 统一时间口径 +3. 选 3~5 个关键指标 +4. 输出对比表 +5. 给出“谁在哪方面更强”的总结 + +### 3. 财务质量快照 + +适用: + +- 看下 XX 财报 +- 最近几个季度利润趋势 +- 财务质量怎么样 + +默认流程: + +1. 拉最近 8 个季度 + 最近年度财务核心数据 +2. 区分营收、利润、毛利率、ROE、现金流 +3. 标出改善 / 恶化 / 波动点 +4. 说明累计值、单季值、同比口径 + +### 4. 估值分析 / 筛选 + +适用: + +- 现在估值高不高 +- 谁更便宜 +- 筛低估值高股息 + +默认流程: + +1. 明确标的池 +2. 拉 `daily_basic` 等估值指标 +3. 必要时联动财务质量 +4. 输出排序、极值、口径说明 + +### 5. 资金流追踪 + +适用: + +- 最近资金在买什么 +- 北向最近流向哪里 +- 主力资金流入最多的是谁 + +默认流程: + +1. 明确资金口径(北向 / 主力 / 龙虎榜 / 板块资金) +2. 确定时间窗 +3. 拉净流入 / 活跃成交 / 持续性 +4. 和价格表现联动解释 +5. 避免把单日噪声说成趋势 + +### 6. 板块 / 题材轮动分析 + +适用: + +- 最近哪个板块最强 +- 机器人最近强在哪 +- 某概念板块里有哪些成分股 + +默认流程: + +1. 确定分类口径 +2. 拉板块区间表现 +3. 必要时联动成分股、资金流、涨停梯队 +4. 输出强势板块排行与代表标的 + +### 7. 公告 / 新闻 / 事件梳理 + +适用: + +- 最近有什么公告 +- 有没有什么催化 +- 最近新闻面怎么样 + +默认流程: + +1. 明确对象和时间窗 +2. 拉公告 / 新闻 / 研报 / 政策数据 +3. 去噪,提炼 3~5 条主线 +4. 区分事实、公告、媒体解读 +5. 必要时结合股价异动做弱因果解释 + +### 8. 数据导出与研究准备 + +适用: + +- 拉一份 CSV +- 做回测数据表 +- 导出某段时间的行情/财务数据 + +默认流程: + +1. 明确数据范围、频率、字段 +2. 采用分段策略取数 +3. 清洗、去重、统一字段类型 +4. 输出 CSV / parquet +5. 给出文件路径和元信息 + +### 9. 综合研究简报 + +适用: + +- 给我快速研究一下 XX +- 做个投资者视角简报 +- 先给个全景判断 + +默认流程: + +1. 一句话结论 +2. 行情表现 +3. 财务趋势 +4. 估值水平 +5. 资金流情况 +6. 公告 / 新闻催化 +7. 风险点 +8. 值得继续深挖的问题 + +*** + +## Data quality rules + +拉取完成后,至少做这些检查: + +- schema 校验 +- 关键字段存在性检查 +- 主键去重 +- 固定排序 +- 日期标准化 +- 数值字段类型规范化 + +### 空结果处理 + +空表不一定是失败,要区分: + +- 非交易日 +- 区间无数据 +- 股票未上市 +- 参数错误 +- 接口权限不足 + +不要把所有空结果都说成“接口坏了”。 + +*** + +## Cache and reuse rules + +为了让 skill 可长期复用,应优先支持: + +- 基础表缓存(如 `stock_basic`、交易日历、指数基础信息) +- 增量更新,而不是每次全量重拉 +- 大任务断点续跑 +- 结果文件规范命名 + +推荐命名格式: + +- `daily_600519.SH_20230101_20231231_20260322.csv` +- `fina_indicator_300750.SZ_20260322.parquet` + +缓存命中时,最好说明哪些来自缓存,哪些是新拉取的数据。 + +*** + +## Error handling + +优先用“人话 + 调试细节分层”的方式输出错误。 + +### 用户可见层 + +- token 未配置 +- 当前接口可能需要更高积分/权限 +- 时间范围过大,已自动改为分段拉取 +- 股票名称不唯一,请确认是哪一个 +- 当前结果为空,可能因为该日期非交易日 / 标的未上市 / 无权限 + +### 调试层 + +必要时补: + +- 接口名 +- 参数 +- 失败分段 +- 异常原文 + +### 部分成功原则 + +如果部分分段失败,不要说“成功完成”。 +应明确说: + +- 哪些部分成功 +- 哪些部分失败 +- 是否已生成不完整结果 + +*** + +## Recommended minimal interface set + +主 skill 正文不要塞几百个接口。 +优先记住 80% 常用任务的核心接口集: + +- `stock_basic` +- `trade_cal` +- `daily` +- `pro_bar` +- `daily_basic` +- `fina_indicator` +- `income` +- `balancesheet` +- `cashflow` +- `forecast` +- `express` +- `moneyflow` +- `moneyflow_hsgt` +- `hsgt_top10` +- `top_list` +- `index_basic` +- `index_daily` +- `index_classify` +- `sw_daily` +- `ths_index` +- `ths_member` +- `limit_list_d` +- `limit_step` +- `news` +- `major_news` +- `research_report` +- `anns_d` +- `cn_cpi` +- `cn_pmi` +- `us_tycr` + +全部数据接口,请参考 `references/数据接口.md`。 + +*** + +## Best practices + +- 先理解任务,再选接口 +- 能少取就少取,先核心数据,再扩展 +- 先给结论,再给证据 +- 默认说人话,不堆字段名 +- 对“最近 / 财报 / 强不强 / 资金关注”这类模糊中文表达,要有合理默认口径 +- 大任务先给执行计划,再开跑 +- 导出任务尽量保留脚本、元信息、文件路径,方便复用 + +*** + +## Examples + +### 单票行情 + +- 看下宁德时代最近三个月走势 +- 茅台今年以来涨了多少 +- 招行这两年最大回撤大概多少 + +### 财务 / 估值 + +- 看下比亚迪最近 8 个季度营收和净利润趋势 +- 茅台现在估值算高吗 +- 帮我找高 ROE 低负债的公司 + +### 对比 + +- 比一下茅台、五粮液、泸州老窖近一年的涨幅和估值 +- 对比一下沪深300、中证500、创业板今年表现 + +### 资金流 / 板块 + +- 今天北向资金流入最多的股票有哪些 +- 最近哪个板块最强 +- 半导体板块最近一个月强不强 + +### 公告 / 事件 + +- 帮我梳理下寒武纪最近的重要公告 +- 最近机器人板块有什么消息面催化 + +### 宏观 + +- 看一下最近 CPI、PPI、PMI 变化 +- 当前市场风格偏成长还是价值 + +### 导出 + +- 把沪深300成分股近两年日线导成 CSV +- 下载宁德时代 2020 到现在的复权行情 +- 把最近 3 年 ROE、PE、PB、营收增速拉成一个表 + +*** + +## Quick rule + +当用户在说: + +- 看走势 +- 查财报 +- 比较公司 +- 看板块 +- 看资金流 +- 梳理公告新闻 +- 看宏观 +- 拉数据导出 + +就不要先想“有哪些接口”。 +先想: +**这是什么任务?默认该走哪条数据工作流?结果应该怎样交付才真正有用?** diff --git a/.agents/skills/tushare/references/数据接口.md b/.agents/skills/tushare/references/数据接口.md new file mode 100644 index 0000000..54550c6 --- /dev/null +++ b/.agents/skills/tushare/references/数据接口.md @@ -0,0 +1,248 @@ + + +# 接口列表 + +根据需求确定接口,然后访问在线链接,读取具体的使用说明,比如入参,出参等。 + +| 在线文档 | 接口名 | 标题 | 分类 | 描述 | +|:--------------------------------------------|:-------------------|:-----------------|:-------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| https://tushare.pro/wctapi/documents/386.md | etf_index | ETF跟踪指数 | ETF专题 | 获取ETF基准指数列表信息 | +| https://tushare.pro/wctapi/documents/472.md | etf_sz_cons | 每日篮子组合(深市PCF) | ETF专题 | 获取深交所场内所有ETF每日盘前披露的一篮子组合信息,包括成分股票数量、申赎现金折溢价比例等数据 | +| https://tushare.pro/wctapi/documents/471.md | etf_sh_cons | 每日篮子组合(沪市PCF) | ETF专题 | 获取上交所场内所有ETF每日盘前披露的的一篮子组合信息,包括成分股票数量、申赎现金折溢价比例等数据 | +| https://tushare.pro/wctapi/documents/470.md | rt_etf_min_daily | ETF实时分钟-日累计 | ETF专题 | 获取ETF实时分钟数据日累计,包括1~60min | +| https://tushare.pro/wctapi/documents/460.md | idx_anns | 指数公司公告 | ETF专题 | 获取指数公司披露的相关公告信息,包括中证指数、国证指数、恒生指数和华证指数的及时与历史公告信息,跟踪指数最新信息和发展方向。 | +| https://tushare.pro/wctapi/documents/454.md | rt_etf_sz_iopv | ETF实时参考 | ETF专题 | ETF实时净值和申购赎回数据参考,目前只提供深市 | +| https://tushare.pro/wctapi/documents/416.md | rt_etf_min | ETF实时分钟 | ETF专题 | 获取ETF实时分钟数据,包括1~60min | +| https://tushare.pro/wctapi/documents/408.md | etf_share_size | ETF份额规模 | ETF专题 | 获取沪深ETF每日份额和规模数据,能体现规模份额的变化,掌握ETF资金动向,同时提供每日净值和收盘价;数据指标是分批入库,交易所于次日早8点30左右更新上一交易日的数据;另外,涉及海外的ETF数据更新会晚一些属于正常情况。 | +| https://tushare.pro/wctapi/documents/400.md | rt_etf_k | ETF实时日线 | ETF专题 | 获取ETF实时日k线行情,支持按ETF代码或代码通配符一次性提取全部ETF实时日k线行情 | +| https://tushare.pro/wctapi/documents/387.md | etf_mins | ETF历史分钟 | ETF专题 | 获取ETF分钟数据,支持1min/5min/15min/30min/60min行情,提供Python SDK和 http Restful API两种方式 | +| https://tushare.pro/wctapi/documents/385.md | etf_basic | ETF基本信息 | ETF专题 | 获取国内ETF基础信息,包括了QDII。数据来源与沪深交易所公开披露信息。 | +| https://tushare.pro/wctapi/documents/199.md | fund_adj | ETF复权因子 | ETF专题 | 获取基金复权因子,用于计算基金复权行情 | +| https://tushare.pro/wctapi/documents/127.md | fund_daily | ETF日线行情 | ETF专题 | 获取ETF行情每日收盘后成交数据,历史超过10年 | +| https://tushare.pro/wctapi/documents/187.md | cb_daily | 可转债行情 | 债券专题 | 获取可转债行情 | +| https://tushare.pro/wctapi/documents/186.md | cb_issue | 可转债发行 | 债券专题 | 获取可转债发行数据 | +| https://tushare.pro/wctapi/documents/185.md | cb_basic | 可转债基础信息 | 债券专题 | 获取可转债基本信息 | +| https://tushare.pro/wctapi/documents/392.md | cb_factor_pro | 可转债技术面因子(专业版) | 债券专题 | 获取可转债每日技术面因子数据,用于跟踪可转债当前走势情况,数据由Tushare社区自产,覆盖全历史;输出参数_bfq表示不复权,_qfq表示前复权 _hfq表示后复权,描述中说明了因子的默认传参,如需要特殊参数或者更多因子可以联系管理员评估 | +| https://tushare.pro/wctapi/documents/459.md | top10_cb_holders | 可转债十大持有人 | 债券专题 | 获取可转债前十大持有人 | +| https://tushare.pro/wctapi/documents/201.md | yc_cb | 国债收益率曲线 | 债券专题 | 获取中债收益率曲线,目前可获取中债国债收益率曲线即期和到期收益率曲线数据 | +| https://tushare.pro/wctapi/documents/458.md | cb_rating | 可转债债券评级 | 债券专题 | 获取可转债评级历史记录 | +| https://tushare.pro/wctapi/documents/233.md | eco_cal | 全球财经事件 | 债券专题 | 获取全球财经日历、包括经济事件数据更新 | +| https://tushare.pro/wctapi/documents/323.md | bc_bestotcqt | 柜台流通式债券最优报价 | 债券专题 | 柜台流通式债券最优报价 | +| https://tushare.pro/wctapi/documents/247.md | cb_share | 可转债转股结果 | 债券专题 | 获取可转债转股结果 | +| https://tushare.pro/wctapi/documents/256.md | repo_daily | 债券回购日行情 | 债券专题 | 债券回购日行情 | +| https://tushare.pro/wctapi/documents/269.md | cb_call | 可转债赎回信息 | 债券专题 | 获取可转债到期赎回、强制赎回等信息。数据来源于公开披露渠道,供个人和机构研究使用,请不要用于数据商业目的。 | +| https://tushare.pro/wctapi/documents/271.md | bond_blk | 大宗交易 | 债券专题 | 获取沪深交易所债券大宗交易数据 | +| https://tushare.pro/wctapi/documents/272.md | bond_blk_detail | 大宗交易明细 | 债券专题 | 获取沪深交易所债券大宗交易数据 | +| https://tushare.pro/wctapi/documents/246.md | cb_price_chg | 可转债转股价变动 | 债券专题 | 获取可转债转股价变动 | +| https://tushare.pro/wctapi/documents/305.md | cb_rate | 可转债票面利率 | 债券专题 | 获取可转债票面利率 | +| https://tushare.pro/wctapi/documents/322.md | bc_otcqt | 柜台流通式债券报价 | 债券专题 | 柜台流通式债券报价 | +| https://tushare.pro/wctapi/documents/19.md | fund_basic | 基金列表 | 公募基金 | 获取公募基金数据列表,包括场内和场外基金 | +| https://tushare.pro/wctapi/documents/462.md | mkt_idx_bmk | 基金业绩基准 | 公募基金 | 获取官方发布的ETF业绩比较基准列表信息,分为一类库、二类库 | +| https://tushare.pro/wctapi/documents/359.md | fund_factor_pro | 基金技术面因子(专业版) | 公募基金 | 获取场内基金每日技术面因子数据,用于跟踪场内基金当前走势情况,数据由Tushare社区自产,覆盖全历史;输出参数_bfq表示不复权,描述中说明了因子的默认传参,如需要特殊参数或者更多因子可以联系管理员评估 | +| https://tushare.pro/wctapi/documents/208.md | fund_manager | 基金经理 | 公募基金 | 获取公募基金经理数据,包括基金经理简历等数据 | +| https://tushare.pro/wctapi/documents/207.md | fund_share | 基金规模 | 公募基金 | 获取基金规模数据,包含上海和深圳ETF基金 | +| https://tushare.pro/wctapi/documents/121.md | fund_portfolio | 基金持仓 | 公募基金 | 获取公募基金持仓数据,季度更新 | +| https://tushare.pro/wctapi/documents/120.md | fund_div | 基金分红 | 公募基金 | 获取公募基金分红数据 | +| https://tushare.pro/wctapi/documents/119.md | fund_nav | 基金净值 | 公募基金 | 获取公募基金净值数据 | +| https://tushare.pro/wctapi/documents/118.md | fund_company | 基金管理人 | 公募基金 | 获取公募基金管理人列表 | +| https://tushare.pro/wctapi/documents/178.md | fx_obasic | 外汇基础信息(海外) | 外汇数据 | 获取海外外汇基础信息,目前只有FXCM交易商的数据 | +| https://tushare.pro/wctapi/documents/179.md | fx_daily | 外汇日线行情 | 外汇数据 | 获取外汇日线行情 | +| https://tushare.pro/wctapi/documents/143.md | news | 新闻快讯(短讯) | 大模型语料 | 获取主流新闻网站的快讯新闻数据,提供超过6年以上历史新闻。 | +| https://tushare.pro/wctapi/documents/154.md | cctv_news | 新闻联播文字稿 | 大模型语料 | 获取新闻联播文字稿数据,数据开始于2017年。 | +| https://tushare.pro/wctapi/documents/195.md | major_news | 新闻通讯(长篇) | 大模型语料 | 获取长篇通讯信息,覆盖主要新闻资讯网站,提供超过8年历史新闻。 | +| https://tushare.pro/wctapi/documents/366.md | irm_qa_sh | 上证e互动问答 | 大模型语料 | 获取上交所e互动董秘问答文本数据。上证e互动是由上海证券交易所建立、上海证券市场所有参与主体无偿使用的沟通平台,旨在引导和促进上市公司、投资者等各市场参与主体之间的信息沟通,构建集中、便捷的互动渠道。本接口数据记录了以上沟通问答的文本数据。 | +| https://tushare.pro/wctapi/documents/367.md | irm_qa_sz | 深证易互动问答 | 大模型语料 | 互动易是由深交所官方推出,供投资者与上市公司直接沟通的平台,一站式公司资讯汇集,提供第一手的互动问答、投资者关系信息、公司声音等内容。 | +| https://tushare.pro/wctapi/documents/406.md | npr | 国家政策库 | 大模型语料 | 获取国家行政机关公开披露的各类法规、条例政策、批复、通知等文本数据。 | +| https://tushare.pro/wctapi/documents/415.md | research_report | 券商研究报告 | 大模型语料 | 获取券商研究报告-个股、行业等,历史数据从20170101开始提供,增量每天两次更新 | +| https://tushare.pro/wctapi/documents/176.md | anns_d | 上市公司公告 | 大模型语料 | 获取全量公告数据,提供pdf下载URL | +| https://tushare.pro/wctapi/documents/465.md | monetary_policy | 央行货币政策执行报告 | 大模型语料 | 获取央行季度更新的货币政策执行报告,历史数据开始于2001年每年四篇,提供原始PDF下载链接,可用于分析过去20多年央行货币政策的动向、宏观以及金融市场的情况。 | +| https://tushare.pro/wctapi/documents/461.md | cn_schedule | 中国经济数据发布日程 | 宏观经济,国内宏观 | 获取国家统计局、中国人民银行等经济数据发布日程及对应tushare接口,持续更新中 | +| https://tushare.pro/wctapi/documents/245.md | cn_ppi | 工业生产者出厂价格指数(PPI) | 宏观经济,国内宏观,价格指数 | 获取PPI工业生产者出厂价格指数数据 | +| https://tushare.pro/wctapi/documents/228.md | cn_cpi | 居民消费价格指数(CPI) | 宏观经济,国内宏观,价格指数 | 获取CPI居民消费价格数据,包括全国、城市和农村的数据 | +| https://tushare.pro/wctapi/documents/149.md | shibor | Shibor利率 | 宏观经济,国内宏观,利率数据 | shibor利率 | +| https://tushare.pro/wctapi/documents/150.md | shibor_quote | Shibor报价数据 | 宏观经济,国内宏观,利率数据 | Shibor报价数据 | +| https://tushare.pro/wctapi/documents/151.md | shibor_lpr | LPR贷款基础利率 | 宏观经济,国内宏观,利率数据 | LPR贷款基础利率 | +| https://tushare.pro/wctapi/documents/152.md | libor | Libor利率 | 宏观经济,国内宏观,利率数据 | Libor拆借利率 | +| https://tushare.pro/wctapi/documents/174.md | gz_index | 广州民间借贷利率 | 宏观经济,国内宏观,利率数据 | 广州民间借贷利率 | +| https://tushare.pro/wctapi/documents/173.md | wz_index | 温州民间借贷利率 | 宏观经济,国内宏观,利率数据 | 温州民间借贷利率,即温州指数 | +| https://tushare.pro/wctapi/documents/153.md | hibor | Hibor利率 | 宏观经济,国内宏观,利率数据 | Hibor利率 | +| https://tushare.pro/wctapi/documents/227.md | cn_gdp | 国内生产总值(GDP) | 宏观经济,国内宏观,国民经济 | 获取国民经济之GDP数据 | +| https://tushare.pro/wctapi/documents/325.md | cn_pmi | 采购经理指数(PMI) | 宏观经济,国内宏观,景气度 | 采购经理人指数 | +| https://tushare.pro/wctapi/documents/310.md | sf_month | 社融增量(月度) | 宏观经济,国内宏观,金融,社会融资 | 获取月度社会融资数据 | +| https://tushare.pro/wctapi/documents/242.md | cn_m | 货币供应量(月) | 宏观经济,国内宏观,金融,货币供应量 | 获取货币供应量之月度数据 | +| https://tushare.pro/wctapi/documents/219.md | us_tycr | 国债收益率曲线利率 | 宏观经济,国际宏观,美国利率 | 获取美国每日国债收益率曲线利率 | +| https://tushare.pro/wctapi/documents/223.md | us_trltr | 国债长期利率平均值 | 宏观经济,国际宏观,美国利率 | 国债实际长期利率平均值 | +| https://tushare.pro/wctapi/documents/220.md | us_trycr | 国债实际收益率曲线利率 | 宏观经济,国际宏观,美国利率 | 国债实际收益率曲线利率 | +| https://tushare.pro/wctapi/documents/221.md | us_tbr | 短期国债利率 | 宏观经济,国际宏观,美国利率 | 获取美国短期国债利率数据 | +| https://tushare.pro/wctapi/documents/222.md | us_tltr | 国债长期利率 | 宏观经济,国际宏观,美国利率 | 国债长期利率 | +| https://tushare.pro/wctapi/documents/308.md | ci_daily | 中信行业指数日行情 | 指数专题 | 获取中信行业指数日线行情 | +| https://tushare.pro/wctapi/documents/469.md | sw_mins | SW历史分钟 | 指数专题 | 获取申万指数历史分钟数据 | +| https://tushare.pro/wctapi/documents/420.md | rt_idx_min | 指数实时分钟 | 指数专题 | 获取交易所指数实时分钟数据,包括1~60min | +| https://tushare.pro/wctapi/documents/419.md | idx_mins | 指数历史分钟 | 指数专题 | 获取交易所指数分钟数据,支持1min/5min/15min/30min/60min行情,提供Python SDK和 http Restful API两种方式 | +| https://tushare.pro/wctapi/documents/417.md | rt_sw_k | 申万实时行情 | 指数专题 | 获取申万行业指数的最新截面数据 | +| https://tushare.pro/wctapi/documents/403.md | rt_idx_k | 指数实时日线 | 指数专题 | 获取交易所指数实时日线行情,支持按代码或代码通配符一次性提取全部交易所指数实时日k线行情 | +| https://tushare.pro/wctapi/documents/373.md | ci_index_member | 中信行业成分 | 指数专题 | 按三级分类提取中信行业成分,可提供某个分类的所有成分,也可按股票代码提取所属分类,参数灵活 | +| https://tushare.pro/wctapi/documents/94.md | index_basic | 指数基本信息 | 指数专题 | 获取指数基础信息。 | +| https://tushare.pro/wctapi/documents/358.md | idx_factor_pro | 指数技术面因子(专业版) | 指数专题 | 获取指数每日技术面因子数据,用于跟踪指数当前走势情况,数据由Tushare社区自产,覆盖全历史;输出参数_bfq表示不复权描述中说明了因子的默认传参,如需要特殊参数或者更多因子可以联系管理员评估,指数包括大盘指数 申万行业指数 中信指数 | +| https://tushare.pro/wctapi/documents/96.md | index_weight | 指数成分和权重 | 指数专题 | 获取各类指数成分和权重,**月度数据** ,建议输入参数里开始日期和结束日分别输入当月第一天和最后一天的日期。 | +| https://tushare.pro/wctapi/documents/128.md | index_dailybasic | 大盘指数每日指标 | 指数专题 | 目前只提供上证综指,深证成指,上证50,中证500,中小板指,创业板指的每日指标数据 | +| https://tushare.pro/wctapi/documents/171.md | index_weekly | 指数周线行情 | 指数专题 | 获取指数周线行情 | +| https://tushare.pro/wctapi/documents/172.md | index_monthly | 指数月线行情 | 指数专题 | 获取指数月线行情,每月更新一次 | +| https://tushare.pro/wctapi/documents/181.md | index_classify | 申万行业分类 | 指数专题 | 获取申万行业分类,可以获取申万2014年版本(28个一级分类,104个二级分类,227个三级分类)和2021年本版(31个一级分类,134个二级分类,346个三级分类)列表信息 | +| https://tushare.pro/wctapi/documents/211.md | index_global | 国际主要指数 | 指数专题 | 获取国际主要指数日线行情 | +| https://tushare.pro/wctapi/documents/335.md | index_member_all | 申万行业成分(分级) | 指数专题 | 按三级分类提取申万行业成分,可提供某个分类的所有成分,也可按股票代码提取所属分类,参数灵活 | +| https://tushare.pro/wctapi/documents/268.md | sz_daily_info | 深圳市场每日交易情况 | 指数专题 | 获取深圳市场每日交易概况 | +| https://tushare.pro/wctapi/documents/327.md | sw_daily | 申万日线行情 | 指数专题 | 获取申万行业日线行情(默认是申万2021版行情) | +| https://tushare.pro/wctapi/documents/215.md | daily_info | 沪深市场每日交易统计 | 指数专题 | 获取交易所股票交易统计,包括各板块明细 | +| https://tushare.pro/wctapi/documents/95.md | index_daily | 指数日线行情 | 指数专题 | 获取指数每日行情,还可以通过bar接口获取。由于服务器压力,目前规则是单次调取最多取8000行记录,可以设置start和end日期补全。指数行情也可以通过[**通用行情接口**]( https://tushare.pro/document/2?doc_id=109)获取数据。本接口不包含[申万行业指数行情数据](https://tushare.pro/document/2?doc_id=327)。 | +| https://tushare.pro/wctapi/documents/158.md | opt_basic | 期权合约信息 | 期权数据 | 获取期权合约信息 | +| https://tushare.pro/wctapi/documents/159.md | opt_daily | 期权日线行情 | 期权数据 | 获取期权日线行情 | +| https://tushare.pro/wctapi/documents/341.md | opt_mins | 期权分钟行情 | 期权数据 | 获取全市场期权合约分钟数据,支持1min/5min/15min/30min/60min行情,提供Python SDK和 http Restful API两种方式。 | +| https://tushare.pro/wctapi/documents/139.md | fut_holding | 每日持仓排名 | 期货数据 | 获取每日成交持仓排名数据,注意"上期所"涵盖"上海国际能源交易中心"合约数据 | +| https://tushare.pro/wctapi/documents/140.md | fut_wsr | 仓单日报 | 期货数据 | 获取仓单日报数据,了解各仓库/厂库的仓单变化 | +| https://tushare.pro/wctapi/documents/141.md | fut_settle | 每日结算参数 | 期货数据 | 获取每日结算参数数据,包括交易和交割费率等 | +| https://tushare.pro/wctapi/documents/216.md | fut_weekly_detail | 期货主要品种交易周报 | 期货数据 | 获取期货交易所主要品种每周交易统计信息,数据从2010年3月开始 | +| https://tushare.pro/wctapi/documents/313.md | ft_mins | 历史分钟行情 | 期货数据 | 获取全市场期货合约分钟数据,支持1min/5min/15min/30min/60min行情,提供Python SDK和 http Restful API两种方式,如果需要主力合约分钟,请先通过主力[mapping](https://tushare.pro/document/2?doc_id=189)接口(需要有至少2000积分)获取对应的合约代码后提取分钟。 | +| https://tushare.pro/wctapi/documents/337.md | fut_weekly_monthly | 期货周月线行情(每日更新) | 期货数据 | 期货周/月线行情(每日更新) | +| https://tushare.pro/wctapi/documents/340.md | rt_fut_min | 实时分钟行情 | 期货数据 | 获取全市场期货合约实时分钟数据,支持1min/5min/15min/30min/60min行情,提供Python SDK、 http Restful API和websocket三种方式,如果需要主力合约分钟,请先通过主力[mapping](https://tushare.pro/document/2?doc_id=189)接口获取对应的合约代码后提取分钟。 | +| https://tushare.pro/wctapi/documents/368.md | ft_limit | 期货合约涨跌停价格 | 期货数据 | 获取所有期货合约每天的涨跌停价格及最低保证金率,数据开始于2005年。 | +| https://tushare.pro/wctapi/documents/467.md | fut_trade_cal | 期货交易日历 | 期货数据 | 获取各大期货交易所交易日历数据 | +| https://tushare.pro/wctapi/documents/468.md | fut_index_daily | 南华期货指数日线行情 | 期货数据 | 获取南华指数每日行情,指数行情也可以通过[**通用行情接口**]( https://tushare.pro/document/2?doc_id=109)获取数据. | +| https://tushare.pro/wctapi/documents/138.md | fut_daily | 日线行情 | 期货数据 | 期货日线行情数据 | +| https://tushare.pro/wctapi/documents/189.md | fut_mapping | 期货主力与连续合约 | 期货数据 | 获取期货主力(或连续)合约与月合约映射数据 | +| https://tushare.pro/wctapi/documents/135.md | fut_basic | 合约信息 | 期货数据 | 获取期货合约列表数据 | +| https://tushare.pro/wctapi/documents/388.md | hk_fina_indicator | 港股财务指标数据 | 港股数据 | 获取港股上市公司财务指标数据,为避免服务器压力,现阶段每次请求最多返回200条记录,可通过设置日期多次请求获取更多数据。 | +| https://tushare.pro/wctapi/documents/383.md | rt_hk_k | 港股实时日线 | 港股数据 | 获取港股实时日k线行情,支持按股票代码及股票代码通配符一次性提取全部股票实时日k线行情 | +| https://tushare.pro/wctapi/documents/390.md | hk_balancesheet | 港股资产负债表 | 港股数据 | 获取港股上市公司资产负债表 | +| https://tushare.pro/wctapi/documents/304.md | hk_mins | 港股分钟行情 | 港股数据 | 港股分钟数据,支持1min/5min/15min/30min/60min行情,提供Python SDK和 http Restful API两种方式 | +| https://tushare.pro/wctapi/documents/250.md | hk_tradecal | 港股交易日历 | 港股数据 | 获取交易日历 | +| https://tushare.pro/wctapi/documents/192.md | hk_daily | 港股日线行情 | 港股数据 | 获取港股每日增量和历史行情,每日18点左右更新当日数据 | +| https://tushare.pro/wctapi/documents/389.md | hk_income | 港股利润表 | 港股数据 | 获取港股上市公司财务利润表数据 | +| https://tushare.pro/wctapi/documents/401.md | hk_adjfactor | 港股复权因子 | 港股数据 | 获取港股每日复权因子数据,每天滚动刷新 | +| https://tushare.pro/wctapi/documents/391.md | hk_cashflow | 港股现金流量表 | 港股数据 | 获取港股上市公司现金流量表数据 | +| https://tushare.pro/wctapi/documents/191.md | hk_basic | 港股基础信息 | 港股数据 | 获取港股列表信息 | +| https://tushare.pro/wctapi/documents/339.md | hk_daily_adj | 港股复权行情 | 港股数据 | 获取港股复权行情,提供股票股本、市值和成交及换手多个数据指标 | +| https://tushare.pro/wctapi/documents/284.md | sge_basic | 上海黄金基础信息 | 现货数据 | 获取上海黄金交易所现货合约基础信息 | +| https://tushare.pro/wctapi/documents/285.md | sge_daily | 上海黄金现货日行情 | 现货数据 | 获取上海黄金交易所现货合约日线行情 | +| https://tushare.pro/wctapi/documents/338.md | us_daily_adj | 美股复权行情 | 美股数据 | 获取美股复权行情,支持美股全市场股票,提供股本、市值、复权因子和成交信息等多个数据指标 | +| https://tushare.pro/wctapi/documents/254.md | us_daily | 美股日线行情 | 美股数据 | 获取美股行情(未复权),包括全部股票全历史行情,以及重要的市场和估值指标 | +| https://tushare.pro/wctapi/documents/253.md | us_tradecal | 美股交易日历 | 美股数据 | 获取美股交易日历信息 | +| https://tushare.pro/wctapi/documents/252.md | us_basic | 美股基础信息 | 美股数据 | 获取美股列表信息 | +| https://tushare.pro/wctapi/documents/393.md | us_fina_indicator | 美股财务指标数据 | 美股数据 | 获取美股上市公司财务指标数据,目前只覆盖主要美股和中概股。为避免服务器压力,现阶段每次请求最多返回200条记录,可通过设置日期多次请求获取更多数据。 | +| https://tushare.pro/wctapi/documents/394.md | us_income | 美股利润表 | 美股数据 | 获取美股上市公司财务利润表数据(目前只覆盖主要美股和中概股) | +| https://tushare.pro/wctapi/documents/395.md | us_balancesheet | 美股资产负债表 | 美股数据 | 获取美股上市公司资产负债表(目前只覆盖主要美股和中概股) | +| https://tushare.pro/wctapi/documents/396.md | us_cashflow | 美股现金流量表 | 美股数据 | 获取美股上市公司现金流量表数据(目前只覆盖主要美股和中概股) | +| https://tushare.pro/wctapi/documents/402.md | us_adjfactor | 美股复权因子 | 美股数据 | 获取美股每日复权因子数据,在每天美股收盘后滚动刷新 | +| https://tushare.pro/wctapi/documents/58.md | margin | 融资融券交易汇总 | 股票数据,两融及转融通 | 获取融资融券每日交易汇总数据,交易所于每天8点30左右更新上一日数据 | +| https://tushare.pro/wctapi/documents/331.md | slb_len | 转融资交易汇总 | 股票数据,两融及转融通 | 转融通融资汇总 | +| https://tushare.pro/wctapi/documents/334.md | slb_len_mm | 做市借券交易汇总(停) | 股票数据,两融及转融通 | 做市借券交易汇总 | +| https://tushare.pro/wctapi/documents/333.md | slb_sec_detail | 转融券交易明细(停) | 股票数据,两融及转融通 | 转融券交易明细 | +| https://tushare.pro/wctapi/documents/332.md | slb_sec | 转融券交易汇总(停) | 股票数据,两融及转融通 | 转融通转融券交易汇总 | +| https://tushare.pro/wctapi/documents/326.md | margin_secs | 融资融券标的(盘前) | 股票数据,两融及转融通 | 获取沪深京三大交易所融资融券标的(包括ETF),每天盘前更新 | +| https://tushare.pro/wctapi/documents/59.md | margin_detail | 融资融券交易明细 | 股票数据,两融及转融通 | 获取沪深两市每日融资融券明细,,交易所于每天8点30左右更新上一日数据 | +| https://tushare.pro/wctapi/documents/61.md | top10_holders | 前十大股东 | 股票数据,参考数据 | 获取上市公司前十大股东数据,包括持有数量和比例等信息 | +| https://tushare.pro/wctapi/documents/62.md | top10_floatholders | 前十大流通股东 | 股票数据,参考数据 | 获取上市公司前十大流通股东数据 | +| https://tushare.pro/wctapi/documents/110.md | pledge_stat | 股权质押统计数据 | 股票数据,参考数据 | 获取股票质押统计数据 | +| https://tushare.pro/wctapi/documents/160.md | share_float | 限售股解禁 | 股票数据,参考数据 | 获取限售股解禁 | +| https://tushare.pro/wctapi/documents/111.md | pledge_detail | 股权质押明细数据 | 股票数据,参考数据 | 获取股票质押明细数据 | +| https://tushare.pro/wctapi/documents/161.md | block_trade | 大宗交易 | 股票数据,参考数据 | 大宗交易 | +| https://tushare.pro/wctapi/documents/164.md | stk_account | 股票开户数据(停) | 股票数据,参考数据 | 获取股票账户开户数据,统计周期为一周 | +| https://tushare.pro/wctapi/documents/453.md | stk_alert | 交易所重点提示证券 | 股票数据,参考数据 | 根据证券交易所交易规则的有关规定,交易所每日发布重点提示证券 | +| https://tushare.pro/wctapi/documents/452.md | stk_high_shock | 个股严重异常波动 | 股票数据,参考数据 | 根据证券交易所交易规则的有关规定,交易所每日发布股票交易严重异常波动情况 | +| https://tushare.pro/wctapi/documents/451.md | stk_shock | 个股异常波动 | 股票数据,参考数据 | 根据证券交易所交易规则的有关规定,交易所每日发布股票交易异常波动情况 | +| https://tushare.pro/wctapi/documents/175.md | stk_holdertrade | 股东增减持 | 股票数据,参考数据 | 获取上市公司增减持数据,了解重要股东近期及历史上的股份增减变化 | +| https://tushare.pro/wctapi/documents/166.md | stk_holdernumber | 股东人数 | 股票数据,参考数据 | 获取上市公司股东户数数据,数据不定期公布 | +| https://tushare.pro/wctapi/documents/165.md | stk_account_old | 股票开户数据(旧) | 股票数据,参考数据 | 获取股票账户开户数据旧版格式数据,数据从2008年1月开始,到2015年5月29,新数据请通过[股票开户数据](https://tushare.pro/document/2?doc_id=164)获取。 | +| https://tushare.pro/wctapi/documents/124.md | repurchase | 股票回购 | 股票数据,参考数据 | 获取上市公司回购股票数据 | +| https://tushare.pro/wctapi/documents/194.md | stk_rewards | 管理层薪酬和持股 | 股票数据,基础数据 | 获取上市公司管理层薪酬和持股 | +| https://tushare.pro/wctapi/documents/262.md | bak_basic | 股票历史列表 | 股票数据,基础数据 | 获取备用基础列表,数据从2016年开始 | +| https://tushare.pro/wctapi/documents/329.md | stk_premarket | 每日股本(盘前) | 股票数据,基础数据 | 每日开盘前获取当日股票的股本情况,包括总股本和流通股本,涨跌停价格等。 | +| https://tushare.pro/wctapi/documents/375.md | bse_mapping | 北交所新旧代码对照 | 股票数据,基础数据 | 获取北交所股票代码变更后新旧代码映射表数据 | +| https://tushare.pro/wctapi/documents/397.md | stock_st | ST股票列表 | 股票数据,基础数据 | 获取ST股票列表,可根据交易日期获取历史上每天的ST列表 | +| https://tushare.pro/wctapi/documents/193.md | stk_managers | 上市公司管理层 | 股票数据,基础数据 | 获取上市公司管理层 | +| https://tushare.pro/wctapi/documents/423.md | st | ST风险警示板股票 | 股票数据,基础数据 | ST风险警示板股票列表 | +| https://tushare.pro/wctapi/documents/112.md | stock_company | 上市公司基本信息 | 股票数据,基础数据 | 获取上市公司基础信息,单次提取4500条,可以根据交易所分批提取 | +| https://tushare.pro/wctapi/documents/100.md | namechange | 股票曾用名 | 股票数据,基础数据 | 历史名称变更记录 | +| https://tushare.pro/wctapi/documents/25.md | stock_basic | 股票列表 | 股票数据,基础数据 | 获取基础信息数据,包括股票代码、名称、上市日期、退市日期等 | +| https://tushare.pro/wctapi/documents/26.md | trade_cal | 交易日历 | 股票数据,基础数据 | 获取各大交易所交易日历数据,默认提取的是上交所 | +| https://tushare.pro/wctapi/documents/398.md | stock_hsgt | 沪深港通股票列表 | 股票数据,基础数据 | 获取沪深港通股票列表 | +| https://tushare.pro/wctapi/documents/123.md | new_share | IPO新股上市 | 股票数据,基础数据 | 获取新股上市列表数据 | +| https://tushare.pro/wctapi/documents/261.md | ths_member | THS概念板块成分 | 股票数据,打板专题数据 | 获取概念板块成分列表 | +| https://tushare.pro/wctapi/documents/260.md | ths_daily | THS概念板块行情 | 股票数据,打板专题数据 | 获取板块指数行情 | +| https://tushare.pro/wctapi/documents/376.md | tdx_index | TDX概念板块分类 | 股票数据,打板专题数据 | 获取板块基础信息,包括概念板块、行业、风格、地域等 | +| https://tushare.pro/wctapi/documents/369.md | stk_auction | 开盘竞价成交(当日) | 股票数据,打板专题数据 | 获取当日个股和ETF的集合竞价成交情况,每天9点26~29分之间可以获取当日的集合竞价成交数据。本接口历史数据开始于2025年1月。 | +| https://tushare.pro/wctapi/documents/363.md | dc_member | DC概念板块成分 | 股票数据,打板专题数据 | 获取板块每日成分数据,可以根据概念板块代码和交易日期,获取历史成分 | +| https://tushare.pro/wctapi/documents/362.md | dc_index | DC概念板块分类 | 股票数据,打板专题数据 | 获取每个交易日的概念板块数据,支持按日期查询 | +| https://tushare.pro/wctapi/documents/259.md | ths_index | THS概念板块分类 | 股票数据,打板专题数据 | 获取板块指数,包括概念、行业、特色指数。 | +| https://tushare.pro/wctapi/documents/356.md | limit_step | 涨停股票连板天梯 | 股票数据,打板专题数据 | 获取每天连板个数晋级的股票,可以分析出每天连续涨停进阶个数,判断强势热度 | +| https://tushare.pro/wctapi/documents/377.md | tdx_member | TDX概念板块成分 | 股票数据,打板专题数据 | 获取各板块成分股信息 | +| https://tushare.pro/wctapi/documents/355.md | limit_list_ths | THS涨跌停榜单 | 股票数据,打板专题数据 | 获取同花顺每日涨跌停榜单数据,历史数据从20231101开始提供,增量每天16点左右更新,:分类(limit_type 涨停池、连扳池、冲刺涨停、炸板池、跌停池,默认:涨停池)不同,字段返回有值情况也不同,如仅有涨停池、连扳池 有最大封单 lu_limit_order返回值,其他分类为空 | +| https://tushare.pro/wctapi/documents/347.md | kpl_list | 榜单数据(KP) | 股票数据,打板专题数据 | 获取涨停、跌停、炸板等榜单数据 | +| https://tushare.pro/wctapi/documents/321.md | dc_hot | DC热榜 | 股票数据,打板专题数据 | 获取热榜数据,包括A股市场、ETF基金、港股市场、美股市场等等,每日盘中提取4次,收盘后4次,最晚22点提取一次。 | +| https://tushare.pro/wctapi/documents/320.md | ths_hot | THS热榜 | 股票数据,打板专题数据 | 获取热榜数据,包括热股、概念板块、ETF、可转债、港美股等等,每日盘中提取4次,收盘后4次,最晚22点提取一次。 | +| https://tushare.pro/wctapi/documents/312.md | hm_detail | 游资交易每日明细 | 股票数据,打板专题数据 | 获取每日游资交易明细,数据开始于2022年8。游资分类名录,请点击游资名录 | +| https://tushare.pro/wctapi/documents/311.md | hm_list | 市场游资最全名录 | 股票数据,打板专题数据 | 获取游资分类名录信息 | +| https://tushare.pro/wctapi/documents/298.md | limit_list_d | 涨跌停和炸板数据 | 股票数据,打板专题数据 | 获取A股每日涨跌停、炸板数据情况,数据从2020年开始(不提供ST股票的统计) | +| https://tushare.pro/wctapi/documents/351.md | kpl_concept_cons | 题材成分(KP) | 股票数据,打板专题数据 | 获取概念题材的成分股 | +| https://tushare.pro/wctapi/documents/378.md | tdx_daily | TDX概念板块行情 | 股票数据,打板专题数据 | 获取各板块行情,包括成交和估值等数据 | +| https://tushare.pro/wctapi/documents/382.md | dc_daily | DC概念板块行情 | 股票数据,打板专题数据 | 获取概念板块、行业指数板块、地域板块行情数据,历史数据开始于2020年 | +| https://tushare.pro/wctapi/documents/357.md | limit_cpt_list | 涨停最强板块统计 | 股票数据,打板专题数据 | 获取每天涨停股票最多最强的概念板块,可以分析强势板块的轮动,判断资金动向 | +| https://tushare.pro/wctapi/documents/422.md | dc_concept_cons | 题材成分(DC) | 股票数据,打板专题数据 | 获取概念题材的成分股,每天盘后更新 | +| https://tushare.pro/wctapi/documents/107.md | top_inst | 龙虎榜机构交易单 | 股票数据,打板专题数据 | 龙虎榜机构成交明细 | +| https://tushare.pro/wctapi/documents/106.md | top_list | 龙虎榜每日统计单 | 股票数据,打板专题数据 | 龙虎榜每日交易明细 | +| https://tushare.pro/wctapi/documents/421.md | dc_concept | 题材数据(DC) | 股票数据,打板专题数据 | 获取概念题材列表,每天盘后更新 | +| https://tushare.pro/wctapi/documents/267.md | broker_recommend | 券商月度金股 | 股票数据,特色数据 | 获取券商月度金股,一般1日~3日内更新当月数据 | +| https://tushare.pro/wctapi/documents/274.md | ccass_hold_detail | 中央结算系统持股明细 | 股票数据,特色数据 | 获取中央结算系统机构席位持股明细,数据覆盖**全历史**,根据交易所披露时间,当日数据在下一交易日早上9点前完成 | +| https://tushare.pro/wctapi/documents/275.md | stk_surv | 机构调研数据 | 股票数据,特色数据 | 获取上市公司机构调研记录数据 | +| https://tushare.pro/wctapi/documents/292.md | report_rc | 券商盈利预测数据 | 股票数据,特色数据 | 获取券商(卖方)每天研报的盈利预测数据,数据从2010年开始,每晚19~22点更新当日数据 | +| https://tushare.pro/wctapi/documents/293.md | cyq_perf | 每日筹码及胜率 | 股票数据,特色数据 | 获取A股每日筹码平均成本和胜率情况,每天18~19点左右更新,数据从2018年开始 | +| https://tushare.pro/wctapi/documents/294.md | cyq_chips | 每日筹码分布 | 股票数据,特色数据 | 获取A股每日的筹码分布情况,提供各价位占比,数据从2018年开始,每天18~19点之间更新当日数据 | +| https://tushare.pro/wctapi/documents/188.md | hk_hold | 沪深股通持股明细 | 股票数据,特色数据 | 获取沪深港股通持股明细,数据来源港交所。 | +| https://tushare.pro/wctapi/documents/328.md | stk_factor_pro | 股票技术面因子(专业版) | 股票数据,特色数据 | 获取股票每日技术面因子数据,用于跟踪股票当前走势情况,数据由Tushare社区自产,覆盖全历史;输出参数_bfq表示不复权,_qfq表示前复权 _hfq表示后复权,描述中说明了因子的默认传参,如需要特殊参数或者更多因子可以联系管理员评估 | +| https://tushare.pro/wctapi/documents/353.md | stk_auction_o | 股票开盘集合竞价数据 | 股票数据,特色数据 | 股票开盘9:30集合竞价数据,每天盘后更新 | +| https://tushare.pro/wctapi/documents/354.md | stk_auction_c | 股票收盘集合竞价数据 | 股票数据,特色数据 | 股票收盘15:00集合竞价数据,每天盘后更新 | +| https://tushare.pro/wctapi/documents/364.md | stk_nineturn | 神奇九转指标 | 股票数据,特色数据 | 神奇九转(又称“九转序列”)是一种基于技术分析的股票趋势反转指标,其思想来源于技术分析大师汤姆·迪马克(Tom DeMark)的TD序列。该指标的核心功能是通过识别股价在上涨或下跌过程中连续9天的特定走势,来判断股价的潜在反转点,从而帮助投资者提高抄底和逃顶的成功率,日线级别配合60min的九转效果更好,数据从20230101开始。 | +| https://tushare.pro/wctapi/documents/399.md | stk_ah_comparison | AH股比价 | 股票数据,特色数据 | AH股比价数据,可根据交易日期获取历史 | +| https://tushare.pro/wctapi/documents/295.md | ccass_hold | 中央结算系统持股统计 | 股票数据,特色数据 | 获取中央结算系统持股汇总数据,覆盖全部历史数据,根据交易所披露时间,当日数据在下一交易日早上9点前完成入库 | +| https://tushare.pro/wctapi/documents/296.md | stk_factor | 股票技术面因子 | 股票数据,特色数据 | 获取股票每日技术面因子数据,用于跟踪股票当前走势情况,数据由Tushare社区自产,覆盖全历史 | +| https://tushare.pro/wctapi/documents/27.md | daily | 历史日线 | 股票数据,行情数据 | 获取股票行情数据,或通过[**通用行情接口**]( https://tushare.pro/document/2?doc_id=109)获取数据,包含了前后复权数据 | +| https://tushare.pro/wctapi/documents/374.md | rt_min | 实时分钟 | 股票数据,行情数据 | 获取全A股票实时分钟数据,包括1~60min | +| https://tushare.pro/wctapi/documents/214.md | suspend_d | 每日停复牌信息 | 股票数据,行情数据 | 按日期方式获取股票每日停复牌信息 | +| https://tushare.pro/wctapi/documents/255.md | bak_daily | 备用行情 | 股票数据,行情数据 | 获取备用行情,包括特定的行情指标(数据从2017年中左右开始,早期有几天数据缺失,近期正常) | +| https://tushare.pro/wctapi/documents/336.md | stk_weekly_monthly | 周月线行情(每日更新) | 股票数据,行情数据 | 股票周/月线行情(每日更新) | +| https://tushare.pro/wctapi/documents/365.md | stk_week_month_adj | 周月线复权行情(每日更新) | 股票数据,行情数据 | 股票周/月线行情(复权--每日更新) | +| https://tushare.pro/wctapi/documents/370.md | stk_mins | 历史分钟 | 股票数据,行情数据 | 获取A股分钟数据,支持1min/5min/15min/30min/60min行情,提供Python SDK和 http Restful API两种方式 | +| https://tushare.pro/wctapi/documents/372.md | rt_k | 实时日线 | 股票数据,行情数据 | 获取实时日k线行情,支持按股票代码及股票代码通配符一次性提取全部股票实时日k线行情 | +| https://tushare.pro/wctapi/documents/146.md | pro_bar | 复权行情 | 股票数据,行情数据 | | +| https://tushare.pro/wctapi/documents/457.md | rt_min_daily | A股实时分钟-日累计 | 股票数据,行情数据 | 获取A股当日盘中历史分钟数据,可以提取单只股票当日开盘以来的所有分钟数据 | +| https://tushare.pro/wctapi/documents/196.md | ggt_daily | 港股通每日成交统计 | 股票数据,行情数据 | 获取港股通每日成交信息,数据从2014年开始 | +| https://tushare.pro/wctapi/documents/183.md | stk_limit | 每日涨跌停价格 | 股票数据,行情数据 | 获取全市场(包含A/B股和基金)每日涨跌停价格,包括涨停价格,跌停价格等,每个交易日8点40左右更新当日股票涨跌停价格。 | +| https://tushare.pro/wctapi/documents/145.md | monthly | 月线行情 | 股票数据,行情数据 | 获取A股月线数据 | +| https://tushare.pro/wctapi/documents/28.md | adj_factor | 复权因子 | 股票数据,行情数据 | 本接口由Tushare自行生产,获取股票复权因子,可提取单只股票全部历史复权因子,也可以提取单日全部股票的复权因子。 | +| https://tushare.pro/wctapi/documents/32.md | daily_basic | 每日指标 | 股票数据,行情数据 | 获取全部股票每日重要的基本面指标,可用于选股分析、报表展示等。单次请求最大返回6000条数据,可按日线循环提取全部历史。 | +| https://tushare.pro/wctapi/documents/48.md | hsgt_top10 | 沪深股通十大成交股 | 股票数据,行情数据 | 获取沪股通、深股通每日前十大成交详细数据,每天18~20点之间完成当日更新 | +| https://tushare.pro/wctapi/documents/49.md | ggt_top10 | 港股通十大成交股 | 股票数据,行情数据 | 获取港股通每日成交数据,其中包括沪市、深市详细数据,每天18~20点之间完成当日更新 | +| https://tushare.pro/wctapi/documents/109.md | pro_bar | 通用行情接口 | 股票数据,行情数据 | | +| https://tushare.pro/wctapi/documents/144.md | weekly | 周线行情 | 股票数据,行情数据 | 获取A股周线行情,本接口每周最后一个交易日更新,如需要使用每天更新的周线数据,请使用[日度更新的周线行情接口](https://tushare.pro/document/2?doc_id=336)。 | +| https://tushare.pro/wctapi/documents/81.md | fina_mainbz | 主营业务构成 | 股票数据,财务数据 | 获得上市公司主营业务构成,分地区和产品两种方式 | +| https://tushare.pro/wctapi/documents/80.md | fina_audit | 财务审计意见 | 股票数据,财务数据 | 获取上市公司定期财务审计意见数据 | +| https://tushare.pro/wctapi/documents/33.md | income | 利润表 | 股票数据,财务数据 | 获取上市公司财务利润表数据 | +| https://tushare.pro/wctapi/documents/36.md | balancesheet | 资产负债表 | 股票数据,财务数据 | 获取上市公司资产负债表 | +| https://tushare.pro/wctapi/documents/44.md | cashflow | 现金流量表 | 股票数据,财务数据 | 获取上市公司现金流量表 | +| https://tushare.pro/wctapi/documents/45.md | forecast | 业绩预告 | 股票数据,财务数据 | 获取业绩预告数据 | +| https://tushare.pro/wctapi/documents/103.md | dividend | 分红送股数据 | 股票数据,财务数据 | 分红送股数据 | +| https://tushare.pro/wctapi/documents/79.md | fina_indicator | 财务指标数据 | 股票数据,财务数据 | 获取上市公司财务指标数据,为避免服务器压力,现阶段每次请求最多返回100条记录,可通过设置日期多次请求获取更多数据。 | +| https://tushare.pro/wctapi/documents/162.md | disclosure_date | 财报披露日期表 | 股票数据,财务数据 | 获取财报披露计划日期 | +| https://tushare.pro/wctapi/documents/46.md | express | 业绩快报 | 股票数据,财务数据 | 获取上市公司业绩快报 | +| https://tushare.pro/wctapi/documents/371.md | moneyflow_cnt_ths | 板块资金流向(THS) | 股票数据,资金流向数据 | 获取同花顺概念板块每日资金流向 | +| https://tushare.pro/wctapi/documents/47.md | moneyflow_hsgt | 沪深港通资金流向 | 股票数据,资金流向数据 | 获取沪股通、深股通、港股通每日资金流向数据,每次最多返回300条记录,总量不限制。 | +| https://tushare.pro/wctapi/documents/170.md | moneyflow | 个股资金流向 | 股票数据,资金流向数据 | 获取沪深A股票资金流向数据,分析大单小单成交情况,用于判别资金动向,数据开始于2010年。 | +| https://tushare.pro/wctapi/documents/343.md | moneyflow_ind_ths | 行业资金流向(THS) | 股票数据,资金流向数据 | 获取同花顺行业资金流向,每日盘后更新 | +| https://tushare.pro/wctapi/documents/344.md | moneyflow_ind_dc | 板块资金流向(DC) | 股票数据,资金流向数据 | 获取东方财富板块资金流向,每天盘后更新 | +| https://tushare.pro/wctapi/documents/345.md | moneyflow_mkt_dc | 大盘资金流向(DC) | 股票数据,资金流向数据 | 获取东方财富大盘资金流向数据,每日盘后更新 | +| https://tushare.pro/wctapi/documents/348.md | moneyflow_ths | 个股资金流向(THS) | 股票数据,资金流向数据 | 获取同花顺个股资金流向数据,每日盘后更新 | +| https://tushare.pro/wctapi/documents/349.md | moneyflow_dc | 个股资金流向(DC) | 股票数据,资金流向数据 | 获取东方财富个股资金流向数据,每日盘后更新,数据开始于20230911 | +| https://tushare.pro/wctapi/documents/445.md | p_save | 组合保存 | 自选组合 | 创建或修改自选股组合 | +| https://tushare.pro/wctapi/documents/446.md | p_list | 组合列表 | 自选组合 | 自选股组合查询,不加参数查询出所以自定义组合 | +| https://tushare.pro/wctapi/documents/447.md | p_delete | 组合删除 | 自选组合 | 删除自选股组合 | +| https://tushare.pro/wctapi/documents/449.md | p_get | 成分查询 | 自选组合 | 查询组合的成分列表 | diff --git a/.agents/skills/tushare/scripts/fund_data_demo.py b/.agents/skills/tushare/scripts/fund_data_demo.py new file mode 100644 index 0000000..bc25e82 --- /dev/null +++ b/.agents/skills/tushare/scripts/fund_data_demo.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +基金数据获取示例脚本 +""" + +import tushare as ts +import pandas as pd +import os + +# 读取环境变量中的token, 或者读取本地记录的token +token = os.getenv('TUSHARE_TOKEN') or ts.get_token() + +# 初始化pro接口 +pro = ts.pro_api(token) + + +def get_fund_list(): + """ + 获取基金列表 + """ + try: + data = pro.fund_basic(market='E', status='L', fields='ts_code,fund_name,fund_type,found_date,issue_date,delist_date') + print("基金列表获取成功:") + print(data.head()) + return data + except Exception as e: + print(f"获取基金列表失败:{e}") + return None + + +def get_fund_nav(ts_code, start_date, end_date): + """ + 获取基金净值数据 + """ + try: + data = pro.fund_nav(ts_code=ts_code, start_date=start_date, end_date=end_date) + print(f"{ts_code}基金净值数据获取成功:") + print(data.head()) + return data + except Exception as e: + print(f"获取基金净值数据失败:{e}") + return None + + +def get_fund_manager(): + """ + 获取基金经理数据 + """ + try: + data = pro.fund_manager(limit=10, fields='ts_code,fund_name,manager_name,begin_date,end_date') + print("基金经理数据获取成功:") + print(data.head()) + return data + except Exception as e: + print(f"获取基金经理数据失败:{e}") + return None + + +def main(): + """ + 主函数 + """ + print("===== tushare 基金数据获取示例 =====") + + # 获取基金列表 + fund_list = get_fund_list() + + if fund_list is not None: + # 获取第一只基金的代码 + ts_code = fund_list['ts_code'].iloc[0] + print(f"\n使用基金代码:{ts_code}") + + # 获取基金净值数据(最近30天) + import datetime + end_date = datetime.datetime.now().strftime('%Y%m%d') + start_date = (datetime.datetime.now() - datetime.timedelta(days=30)).strftime('%Y%m%d') + print(f"\n获取基金净值数据:{start_date} 至 {end_date}") + get_fund_nav(ts_code, start_date, end_date) + + # 获取基金经理数据 + print("\n获取基金经理数据:") + get_fund_manager() + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/tushare/scripts/stock_data_demo.py b/.agents/skills/tushare/scripts/stock_data_demo.py new file mode 100644 index 0000000..e537b34 --- /dev/null +++ b/.agents/skills/tushare/scripts/stock_data_demo.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +股票数据获取示例脚本 +""" + +import tushare as ts +import pandas as pd +import os + +# 读取环境变量中的token, 或者读取本地记录的token +token = os.getenv('TUSHARE_TOKEN') or ts.get_token() + +# 初始化pro接口 +pro = ts.pro_api(token) + + +def get_stock_list(): + """ + 获取股票列表 + """ + try: + data = pro.stock_basic(exchange='', list_status='L', fields='ts_code,symbol,name,area,industry,list_date') + print("股票列表获取成功:") + print(data.head()) + return data + except Exception as e: + print(f"获取股票列表失败:{e}") + return None + + +def get_daily_data(ts_code, start_date, end_date): + """ + 获取股票日线数据 + """ + try: + data = pro.daily(ts_code=ts_code, start_date=start_date, end_date=end_date) + print(f"{ts_code}日线数据获取成功:") + print(data.head()) + return data + except Exception as e: + print(f"获取日线数据失败:{e}") + return None + + +def get_financial_data(ts_code, year, quarter): + """ + 获取财务指标数据 + """ + try: + data = pro.fina_indicator(ts_code=ts_code, year=year, quarter=quarter) + print(f"{ts_code}财务指标数据获取成功:") + print(data.head()) + return data + except Exception as e: + print(f"获取财务指标数据失败:{e}") + return None + + +def main(): + """ + 主函数 + """ + print("===== tushare 股票数据获取示例 =====") + + # 获取股票列表 + stock_list = get_stock_list() + + if stock_list is not None: + # 获取第一只股票的代码 + ts_code = stock_list['ts_code'].iloc[0] + print(f"\n使用股票代码:{ts_code}") + + # 获取日线数据(最近30天) + import datetime + end_date = datetime.datetime.now().strftime('%Y%m%d') + start_date = (datetime.datetime.now() - datetime.timedelta(days=30)).strftime('%Y%m%d') + print(f"\n获取日线数据:{start_date} 至 {end_date}") + get_daily_data(ts_code, start_date, end_date) + + # 获取财务数据(最近一年) + current_year = datetime.datetime.now().year + print(f"\n获取财务数据:{current_year-1}年 第4季度") + get_financial_data(ts_code, current_year-1, 4) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/tushare b/.claude/skills/tushare new file mode 120000 index 0000000..219e1c4 --- /dev/null +++ b/.claude/skills/tushare @@ -0,0 +1 @@ +../../.agents/skills/tushare \ No newline at end of file diff --git a/.env.example b/.env.example index a51dab1..a7f86fe 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,11 @@ ZHIXING_MARKET_DATA_MAX_RETRIES=3 ZHIXING_MARKET_DATA_RETRY_BACKOFF_SECONDS=1.0 ZHIXING_MARKET_DATA_REQUEST_INTERVAL_SECONDS=0.2 ZHIXING_MARKET_DATA_ADVISORY_LOCK_KEY=7380521 +ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD=0.99 +ZHIXING_SECTOR_RADAR_MAX_RETRIES=3 +ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS=1.0 +ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS=0.2 +ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY=7380522 ZHIXING_SELECTION_MAX_WORKERS=4 ZHIXING_SELECTION_BATCH_SIZE=200 ZHIXING_SELECTION_PATTERN_SCORING_ENABLED=true diff --git a/.pi/skills/tushare b/.pi/skills/tushare new file mode 120000 index 0000000..219e1c4 --- /dev/null +++ b/.pi/skills/tushare @@ -0,0 +1 @@ +../../.agents/skills/tushare \ No newline at end of file diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md index f8cd15f..1f5f89c 100644 --- a/.trellis/spec/backend/index.md +++ b/.trellis/spec/backend/index.md @@ -9,6 +9,7 @@ | [目录与模块边界](./directory-structure.md) | 包结构、bounded context 和导入边界 | | [配置与运行时](./configuration-and-runtime.md) | `Settings`、应用工厂和部署环境 | | [市场数据同步](./market-data-sync.md) | Tushare qfq、PostgreSQL、CSV 快照和一次性 Job 契约 | +| [Tushare 当前上市股票范围](./tushare-listed-stock-universe.md) | 所有股票型功能统一只使用构建时 `stock_basic(list_status=L)` 母集 | | [历史选股](./selection.md) | selection bounded context、目标交易日、qfq 读取和信号结果契约 | | [HTTP 契约](./http-api-contracts.md) | 路由组合、响应模型和同源 API 路径 | | [错误处理](./error-handling.md) | 当前 FastAPI 错误行为及跨层错误传递 | @@ -17,6 +18,7 @@ ## 开发前检查 - 先阅读 `docs/adr/0001-bounded-context-first-modular-monolith.md`,确认新业务是否有清晰的语言和所有权边界。 +- 涉及 Tushare 个股数据时先阅读 `tushare-listed-stock-universe.md`,所有新功能都必须从当前 `L` 股票母集继续缩小范围,禁止重新引入 `D/P/G/UN`。 - 先阅读目标上下文的 `modules//README.md`(如已存在),再决定 domain、application、infrastructure、presentation 的位置。 - 变更 HTTP 字段时同时检查 `zhixing-server/tests/`、前端 feature API 类型以及 `docs/adr/0002-use-a-same-origin-browser-api.md`。 - 不要为了“未来可能需要”创建空的数据库、服务或日志层;当前仓库没有这些实现。 diff --git a/.trellis/spec/backend/tushare-listed-stock-universe.md b/.trellis/spec/backend/tushare-listed-stock-universe.md new file mode 100644 index 0000000..20f327a --- /dev/null +++ b/.trellis/spec/backend/tushare-listed-stock-universe.md @@ -0,0 +1,103 @@ +# Tushare 当前上市股票范围 + +## Scenario: 所有股票型功能统一使用当前 `L` 股票池 + +### 1. Scope / Trigger + +- 触发:新增或修改任何通过 Tushare 获取个股基础资料、行情、资金流、板块成员、财务或估值数据的后端功能。 +- 目标:所有功能统一以构建时 `stock_basic(list_status="L")` 返回的当前上市股票为证券母集,禁止为了历史回溯获取 `D/P/G/UN`。 +- 历史语义:功能上线日视为最早业务历史日期;以后重跑旧日期仍使用重跑当时的当前 `L` 股票池,不保证还原目标日的退市证券。 +- 边界:指数、基金、期货、宏观等非个股数据不适用本股票状态契约;若未来产品必须恢复历史时点证券生命周期,必须先显式修改本规格及对应任务设计,不能在单个 adapter 内局部绕过。 + +### 2. Signatures + +所有直接读取股票基础档案的 Tushare adapter 必须显式传入 `list_status="L"`: + +```python +client.query( + "stock_basic", + exchange="", + list_status="L", + fields="ts_code,symbol,name,market,exchange,list_status,list_date,delist_date", +) +``` + +应用层不得通过 adapter 隐式缓存推断股票范围;需要候选股票的端口必须显式接收已经排序、去重并与当前 `L` 股票池相交的代码集合,例如: + +```python +def fetch_moneyflow_dc( + trade_date: date, + candidate_codes: Sequence[str], +) -> SourceResult[MoneyflowDcRow]: ... +``` + +### 3. Contracts + +- `stock_basic` 请求必须显式设置 `list_status="L"`,不能依赖供应商默认值,也不能循环请求 `D/P/G/UN`。 +- 当前股票母集至少以 `ts_code` 唯一;返回的非 `L` 记录不得进入业务目标集合。严格 source adapter 应将与请求分区不符的状态视为来源契约错误,已有宽松同步边界至少必须在领域过滤时排除。 +- 股票型功能可以继续执行自身既有的市场边界,例如沪深 A 股、B 股、北交所、ST 或风险警示过滤;这些过滤只能缩小 `L` 母集,不能重新引入其他上市状态。 +- `daily`、`moneyflow_dc`、`dc_member` 等不支持 `list_status` 的接口可以按其最有效的方式获取原始响应,但进入计算、排名、覆盖率、缺口补拉或持久化业务事实前,候选代码必须与当前 `L` 母集取交集。 +- 为审计保存的全市场原始 snapshot 可以包含非 `L` 行;非 `L` 行不得进入规范化事实、策略计算或“应覆盖股票数”。 +- 当前 `L` 股票池必须带有构建时来源快照或等价审计信息。重试若复用旧下游 snapshot,必须确认它仍覆盖本轮候选集合;候选扩大时应在同一次重试中刷新相应下游来源。 +- 本契约不要求各 bounded context 共享数据库表、缓存或 Tushare client;共享的是证券范围语义,而不是运行时耦合。 + +### 4. Validation & Error Matrix + +| 条件 | 必须行为 | +| --- | --- | +| `stock_basic` 请求未显式传 `list_status="L"` | 测试失败;不得发布该功能 | +| `L` 分区返回 `D/P/G/UN` | 严格 adapter 抛来源契约错误,或在既有宽松边界明确排除;非 `L` 不得进入业务集合 | +| 板块成员包含非当前 `L` 股票 | 保留原始成员审计,计算候选与当前 `L` 集合取交集 | +| 目标日期早于当前 `L` 股票的 `list_date` | 从该目标日候选集合排除 | +| 行情或资金流全市场响应包含非 `L` 股票 | 原始 snapshot 可保留,规范化事实和覆盖率忽略这些股票 | +| 缺失补拉收到不在请求候选集合内的代码 | 按来源契约错误 fail closed,禁止合并 | +| 重试时成员恢复导致当前候选集合扩大 | 检查旧下游 snapshot 覆盖;不足时同轮刷新,不能先发布一次可预见的 `partial` | +| 新需求要求历史退市股票或历史时点生命周期 | 先修改本规格并完成独立设计评审,禁止直接请求 `D/P/G/UN` | + +### 5. Good/Base/Bad Cases + +- Good:资金雷达只请求一次 `stock_basic(list_status="L")`,将有效板块成员与当前沪深 A 股交集传给资金流 source;全市场原始资金流即使含额外股票,也只补拉和计算交集内代码。 +- Base:普通行情同步从 `L` 股票池再排除 ST、北交所或不属于目标市场的证券;这是允许的模块级缩小,不改变全局母集。 +- Good:重试刷新成员后发现新增两个当前 `L` 候选,旧资金流 checkpoint 少两只,于是同一次重试只刷新资金流来源组并恢复成功。 +- Bad:为了回填旧日期,将 `stock_basic` 改为循环获取 `L/D/P/G/UN`,或者直接把 `dc_member` 的全部代码作为资金流覆盖分母。 +- Bad:看到全市场原始 snapshot 含非 `L` 股票便将它们写入策略事实,造成候选数量、覆盖率或排名口径漂移。 + +### 6. Tests Required + +- Tushare adapter 测试必须断言 `stock_basic` 的调用参数包含且只包含 `list_status="L"`,并断言非 `L` 返回记录不会进入结果。 +- 应用编排测试必须构造板块成员、未来上市记录和当前 `L` 记录,断言传给下游 source 的候选集合是稳定排序后的交集。 +- 规范化或策略测试必须断言非 `L`、目标日尚未上市、B 股或模块已排除市场不会贡献金额、覆盖率或排名。 +- 重试测试必须覆盖“成员刷新后候选扩大但旧下游 checkpoint 不完整”,断言同一次重试刷新必要来源组。 +- 新增股票型 bounded context 时,至少有一个边界测试证明它没有请求或引入 `D/P/G/UN`。 + +### 7. Wrong vs Correct + +#### Wrong + +```python +# 禁止:为历史回填循环获取全部生命周期状态。 +rows = tuple( + client.query("stock_basic", list_status=status) + for status in ("L", "D", "P", "G", "UN") +) +candidate_codes = tuple(member.stock_code for member in memberships) +``` + +#### Correct + +```python +# 正确:当前 L 是唯一母集,模块规则只能继续缩小它。 +listed = client.query("stock_basic", list_status="L") +listed_codes = { + row.ts_code + for row in listed + if row.list_status == "L" and is_module_eligible(row, target_trade_date) +} +candidate_codes = tuple( + sorted( + member.stock_code + for member in memberships + if member.stock_code in listed_codes + ) +) +``` diff --git a/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/check.jsonl b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/check.jsonl new file mode 100644 index 0000000..9646ffc --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/check.jsonl @@ -0,0 +1,8 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "检查后端模块边界与开发清单"} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "检查 Ruff、Pyright、pytest 与禁止模式"} +{"file": ".trellis/spec/frontend/index.md", "reason": "检查前端 feature 与质量清单"} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "检查格式、lint、类型、测试、构建和可访问性"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "检查后端响应、前端类型、query 与页面一致性"} +{"file": ".trellis/tasks/08-28-sector-capital-radar/research/implementation-evidence.md", "reason": "检查独立指标声明、单位、空值和凭据边界"} +{"file": "docs/research/onechartlab-sector-capital-radar.md", "reason": "禁止把未知公式伪装为原站公式"} +{"file": ".trellis/tasks/08-28-sector-capital-radar/research/tushare-radar-contract.md", "reason": "检查接口、PIT、单位、覆盖率和 last-good 质量语义"} diff --git a/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/design.md b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/design.md new file mode 100644 index 0000000..f5fe45f --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/design.md @@ -0,0 +1,118 @@ +# 板块资金雷达技术设计 + +## 设计结论 + +首个 MVP 建设 Tushare 独立生产链,生产运行时不读取 OneChartLab。后端新增 `sector_radar` bounded context,通过一次性 CLI 采集七类最小事实、保存 point-in-time 输入、计算三个带版本的知行独立指标、生成横截面排名并发布;FastAPI 只读取已发布结果。前端新增 `/sector-radar` feature,提供日期、板块类型、指标、强弱榜、排名变化、搜索和分页。 + +首个安全里程碑不实现成分股详情、历史轨迹图、导出、`moneyflow` 主买净额、`daily_basic` 流动性增强或 `moneyflow_ind_dc` 对账。架构为这些能力保留数据版本与指标策略 seam,但不创建空实现。 + +## Module 与 seam + +### `sector_radar` bounded context + +外部 interface 保持两个深模块: + +1. `BuildSectorRadar.execute(command) -> BuildSummary` 隐藏目标交易日解析、采集、校验、持久化、指标计算、排名和发布切换。 +2. `ReadSectorRadar.list_dates()` 与 `ReadSectorRadar.query(query) -> RankingPage` 隐藏 last-good 选择、筛选、排序和分页。 + +内部保留三个真实 seam: + +- `SectorRadarSource`:Tushare 生产 adapter 与测试 fake adapter;负责 `trade_cal`、`dc_index`、`dc_member`、`stock_basic`、`suspend_d`、`daily`、`moneyflow_dc`。 +- `SectorRadarRepository`:PostgreSQL 生产 adapter 与 application 测试 fake adapter;负责输入版本、发布批次和读取投影。 +- `MetricStrategy`:金额、单日资金率和波段资金率是三个实际可替换 adapter;每个结果必须带 `metric_version`、显示标签、排序值、单位和质量状态。 + +Tushare 请求协调能力已有第二个真实消费者后,将 `RequestCoordinator` 从 `market_data` infrastructure 提升为无业务所有权的小型 shared 基础能力;原市场同步与雷达 adapter 同时复用,且保留现有行为测试。 + +## 领域模型 + +- `SectorType`:`concept` 或 `industry`;地域板块不进入 MVP。 +- `SectorMembershipSnapshot`:`trade_date + sector_code + stock_code`,只表示该交易日的成员事实。 +- `StockDailyFact`:股票在交易日的生命周期、停牌、成交额和主力净额状态;缺行、NULL、0 和无效成员不同义。 +- `MetricObservation`:策略版本生成的值、单位、有效样本数、成员覆盖率和质量状态。 +- `RadarPublication`:针对一个目标交易日的不可变构建版本,状态为 `running|success|partial|failed`,包含输入 hash、universe 版本、指标版本集合、覆盖率、开始/完成时间和安全错误摘要。 +- `RadarRanking`:属于某个 publication 和排名池的指标值、1 基排名、排名百分位及 1—5 日变化。 +- `LastGoodPublication`:不使用可被失败构建覆盖的可变字段;读取时选择最近一个 `status=success` 的 publication。失败与 partial 批次仍保留审计。 + +## 数据采集与质量 + +每日 Job 的目标交易日由 `trade_cal` 确认。目标日采集 `dc_index` 的概念与行业、`dc_member`、全部上市状态的 `stock_basic`、`suspend_d`、`daily` 和 `moneyflow_dc`。首次初始化或补算按交易日顺序执行,至少准备 10 个交易日才能产生完整波段指标,至少保留 30 个已发布交易日供排名变化和后续轨迹使用。 + +原始响应按 `api_name + normalized_params + observed_at` 保存 JSONB、行数和 SHA-256;规范表保存 point-in-time 成员与股票事实。响应接近官方单次上限或覆盖率不足时必须分片重拉,不能接受可能截断的成功响应。 + +有效股票候选满足目标日生命周期并属于沪深 A 股,排除北交所与 B 股;不沿用选股模块的 ST 排除规则。停牌且无 `daily` 的股票标记为 suspended,不作为应有行情缺失;应有 `daily` 或 `moneyflow_dc` 却缺失的股票进入覆盖率缺口,不能补 0。 + +发布成功门使用可配置的全局事实覆盖率,默认沿用项目的 `0.99`。成员快照必须完整;任一必需接口硬失败、重复业务键、日期错误、非有限数、单位校验失败或覆盖率低于门槛,当前 publication 为 `partial` 或 `failed`,不得成为 last-good。每个板块仍输出有效样本数、成员覆盖率和质量状态;少于 5 个有效成员标记 `available_limited_sample`。 + +## 独立指标策略 + +对板块 `s`、交易日 `t`,使用当日 point-in-time 有效成员: + +```text +Net(s,t) = sum(moneyflow_dc.net_amount) × 10_000 元 +Turnover(s,t) = sum(daily.amount) × 1_000 元 +``` + +- `zhixing_amount_net_bn_v1 = Net(s,t) / 100_000_000`,排序值为亿元净额。 +- `zhixing_ratio_turnover_v1 = Net(s,t) / Turnover(s,t)`;分母为 0 或输入不完整时为 NULL。 +- 对每个 `w ∈ [3,10]`,`WindowRatio_w = sum(Net(s,d)) / sum(Turnover(s,d))`,其中每个 `d` 使用自己的成员快照;`zhixing_swing_equal_3_10_v1` 是八个完整 `WindowRatio_w` 的算术平均。任一窗口不完整时为 NULL。 + +这些名称和页面标签都明确写“知行独立实现”。接口不暴露原站的 `Ratio_Score` 或 `Swing_Score` 字段名,而统一返回 `metric_value`、`metric_version`、`unit` 和 `implementation_kind=independent`。 + +## 排名契约 + +概念与行业分别成池。NULL 指标不进入排名,但作为 unavailable 记录保留质量信息。非 NULL 值按 `metric_value DESC, sector_code ASC` 排序,后者是知行独立稳定键,不宣称原站并列规则。 + +```text +RankPct = 100 * (N - RankPos + 1) / N +Top = RankPct >= 90 +Bottom = RankPct <= 10 +RankChg = PastRank - CurrentRank +``` + +排名变化按过去第 1—5 个已发布交易日计算;过去或当前排名缺失时返回 NULL,而不是伪造 0。前端明确显示暂无可比历史。 + +## 持久化 + +新增一条 Alembic 迁移,至少包含: + +- `sector_radar_source_snapshot`:接口、参数、目标日、原始 JSONB、行数、hash、观测时间; +- `sector_radar_membership`:来源快照、交易日、板块类型、板块代码、股票代码及展示名; +- `sector_radar_stock_fact`:来源快照集合、交易日、股票代码、生命周期/停牌状态、成交额、主力净额和数据状态; +- `sector_radar_publication`:构建身份、状态、版本、覆盖率、input hash、时间和错误摘要; +- `sector_radar_ranking`:publication、板块身份、指标版本、数值、单位、质量、排名百分位和历史变化。 + +业务唯一键必须包含交易日和来源/发布版本,允许同一交易日修订共存。数值使用有限 `NUMERIC`/`Decimal`;批量写入沿用 staging + COPY + 幂等 upsert。原始 token、完整请求头和未经净化的异常不得持久化。 + +## CLI 与发布流程 + +新增 `sector-radar-build` 一次性 CLI: + +- 默认构建最近一个已收盘交易日; +- `--trade-date YYYY-MM-DD` 构建单日; +- `--start-date/--end-date` 按交易日顺序初始化或回填; +- `--retry-publication-id` 只重试失败来源分片。 + +Job 使用独立 advisory lock。顺序为准备 running publication、采集并保存原始版本、规范化与质量屏障、计算指标与排名、事务写入、标记 success。任何阶段失败都保留原 publication 审计,读取端继续选择 last-good。FastAPI 不启动定时器;生产 Compose 增加 job service,实际定时继续由外部调度器负责。 + +## HTTP 契约 + +挂载前缀 `/api/v1/sector-radar`: + +- `GET /dates`:返回可用成功日期、当前尝试状态、last-good 日期和发布时间; +- `GET /rankings`:参数为 `trade_date`(缺省 last-good)、`sector_type`、`view=amount|ratio|swing|rank_change`、`rank_change_metric`、`rank_change_days=1..5`、`side=top|bottom|all`、`search`、`page` 和 `page_size`。 + +响应包含 publication 元数据、指标定义与独立实现声明、分页信息和排名行。没有成功发布时返回稳定的 `no_data` 成功响应;非法参数由 Pydantic/FastAPI 返回 422;数据库不可用映射 503。外部源异常只发生在 Job,不从读取端点实时透传。 + +## 前端 + +新增 `features/sector-radar` 垂直切片以及 `/sector-radar` 路由和导航入口。URL 保存日期、类型、视角、榜侧、搜索和分页;服务器数据只进入 React Query。页面使用现有 `PageLayout`、`Card`、`Input`、`Select`、`Badge`、`Pagination` 和语义 table,显式显示 loading、error、no-data、stale/partial/success。 + +首个 MVP 使用排名表和状态摘要,不引入图表库。金额显示亿元,比率显示百分比,指标旁始终显示策略版本或“知行独立实现”。 + +## 兼容、回滚与风险 + +- 新表、新路由和新 feature 不改变现有 market-data/selection 契约;shared 请求协调器移动必须先保持现有测试通过。 +- 数据库迁移 downgrade 只删除新上下文表,不触碰现有市场数据。 +- 外部调度在 job 验证稳定前保持关闭;手工构建与读取验证通过后再启用。 +- 主要风险是目标账号实际权限/限流、`dc_member` 分片完整性和数据到达时间。首次实现必须提供不泄密的 capability probe 与覆盖率报告;没有 live token 时以 fake/golden 完成自动化验证,但不得声称生产采集通过。 +- OneChartLab 对账差异只记录为研究数据,不自动覆盖本地结果。 diff --git a/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/implement.jsonl b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/implement.jsonl new file mode 100644 index 0000000..c039892 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/implement.jsonl @@ -0,0 +1,8 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "后端 bounded context、HTTP、市场同步与质量规范入口"} +{"file": ".trellis/spec/backend/market-data-sync.md", "reason": "复用 Tushare、批次、幂等、单位和发布约束"} +{"file": ".trellis/spec/backend/http-api-contracts.md", "reason": "新增 sector radar 同源 HTTP 契约"} +{"file": ".trellis/spec/frontend/index.md", "reason": "前端 feature、类型、query 与页面规范入口"} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "保持 Pydantic 到前端页面的跨层字段一致"} +{"file": ".trellis/tasks/08-28-sector-capital-radar/research/implementation-evidence.md", "reason": "实现范围、仓库复用点、Tushare 客户端和独立指标契约"} +{"file": "docs/research/onechartlab-sector-capital-radar.md", "reason": "公开确认排名算法与未知公式边界"} +{"file": ".trellis/tasks/08-28-sector-capital-radar/research/tushare-radar-contract.md", "reason": "Radar MVP 所需 Tushare 接口、字段、单位、PIT 与质量规则"} diff --git a/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/implement.md b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/implement.md new file mode 100644 index 0000000..3a1bb84 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/implement.md @@ -0,0 +1,73 @@ +# 板块资金雷达执行计划 + +## 开始前门禁 + +- [ ] 用户审阅并明确批准 `prd.md`、`design.md` 和本计划后,运行 `task.py start`。 +- [ ] 从 `develop@ad9545e` 创建/切换 `codex/sector-capital-radar`,写入任务 branch/base-branch 元数据;保留旧调研任务不变。 +- [ ] 确认 `implement.jsonl` 与 `check.jsonl` 均含真实 spec/research 条目。 + +## 1. 纯领域安全里程碑 + +- [x] 在新的 `sector_radar` bounded context 定义板块类型、成员快照、股票事实、指标观察、发布与排名模型。 +- [x] 先写固定人工样本测试,再实现 `zhixing_amount_net_bn_v1`、`zhixing_ratio_turnover_v1`、`zhixing_swing_equal_3_10_v1`。 +- [x] 实现概念/行业分池、稳定并列键、1 基排名、百分位、TOP/BOTTOM 和 1—5 日排名变化。 +- [x] 覆盖乱序输入、NULL/0、非有限数、空池、单元素、并列、历史缺失、停牌和 point-in-time 成员变化。 +- [x] 运行 `uv run --directory zhixing-server pytest tests/unit/sector_radar`、Ruff 与 Pyright。此步绿灯是第一个可回滚安全点。 + +阶段结果(2026-08-29):三个透明指标策略、point-in-time 事实聚合、发布生命周期和横截面排名 seam 均已实现;13 个板块雷达领域测试通过。完整后端门禁为 96 passed、2 skipped,两个跳过项均为需要 `ZHIXING_TEST_DATABASE_URL` 的既有 PostgreSQL 集成测试。 + +## 2. Tushare 输入与持久化 + +- [x] 把已有 RequestCoordinator 提升到 shared 基础设施,保持 market-data 适配器及测试行为不变。 +- [x] 定义 `SectorRadarSource` 与 Tushare adapter,显式请求七类接口及 fields;token 仅由 `Settings` 注入。 +- [x] 实现服务端错误分类、有限重试、行数上限检测、`dc_member` 分片和账号 capability probe;输出不得包含 token。 +- [x] 新增 Alembic 表、约束、索引和 downgrade,保存原始 JSONB/hash、成员快照、股票事实、publication 与 ranking。 +- [x] 实现 PostgreSQL staging/COPY、幂等重跑、同日多修订、advisory lock 和 last-good 查询。 +- [x] 为 repository fake、Tushare fake、迁移和 PostgreSQL 集成补测试;仅在 `ZHIXING_TEST_DATABASE_URL` 存在时执行数据库集成测试。 +- [x] 若运行环境存在 `ZHIXING_TUSHARE_TOKEN`,执行只读 capability probe 并记录接口成功、字段和行数,不打印原始凭据;否则明确记录 live 验证未执行。 + +阶段结果(2026-08-29):七接口 source 契约、共享限流协调、源快照 hash、point-in-time 规范化、五张 PostgreSQL 表、COPY staging、同日修订与严格 `success` last-good 已落地。完整后端门禁为 109 passed、3 skipped;当前环境未设置 `ZHIXING_TUSHARE_TOKEN` 和 `ZHIXING_TEST_DATABASE_URL`,因此 live capability probe 与三项 PostgreSQL 集成测试未执行,未将其误报为通过。 + +## 3. 构建 Job + +- [x] 实现 `BuildSectorRadar.execute` 的单日与日期区间编排、质量屏障、publication 状态和失败保留 last-good。 +- [x] 新增 `sector-radar-build` CLI 及退出码;支持目标日、回填区间和失败 publication 重试。 +- [x] 增加 Compose job service,但不启用生产定时;更新运行文档与无凭据示例。 +- [x] 用 fake/golden 验证完整成功、部分数据、截断响应、重复运行、输入修订、并发锁和失败降级。 + +阶段结果(2026-08-29):单日/区间构建、上海时区最近已收盘日、provisional publication、同日锁、遗留 running 接管、内容 hash 去重、严格 last-good 与来源组检查点均已落地。failed 重试只补未完成来源组,partial 只刷新显式覆盖缺口;规范事实、日聚合、排名与 terminal publication 由 PostgreSQL 单事务完成。CLI、开发/生产 Compose entrypoint 和运行文档已提供。完整后端门禁为 122 passed、3 skipped;迁移头与离线升级 SQL、四种 Compose config 和 CLI help 已通过。当前未设置 `ZHIXING_TEST_DATABASE_URL`,三项真实 PostgreSQL 集成测试未执行;真实 Tushare capability 与数据到达时点也未在本阶段宣称通过。 + +## 4. HTTP 读取链 + +- [x] 实现 `ReadSectorRadar` 查询模块以及 `/dates`、`/rankings` Pydantic 契约。 +- [x] 在路由目录挂载 `/api/v1/sector-radar`;实现筛选、分页、搜索、rank-change 参数和 `no_data`/503 行为。 +- [x] 使用真实 `create_app()` 与 fake application dependency 写黑盒 HTTP 契约测试。 + +阶段结果(2026-08-29):读取端严格区分最新尝试、指定日期成功修订和全局 last-good;概念/行业分池支持 amount、ratio、swing、rank_change、普通百分位强弱榜、排名变化强弱榜、搜索与分页。响应携带 publication/source/universe/metric 版本、单位、质量与“知行独立实现”声明;无成功发布稳定返回 200 `no_data`,参数错误返回 422,存储错误返回脱敏 503。完整后端门禁为 134 passed、3 skipped;跳过项仍为需要 `ZHIXING_TEST_DATABASE_URL` 的真实 PostgreSQL 集成测试。 + +## 5. 前端 MVP + +- [x] 新建 feature API types、adapter 与 React Query hooks;API 边界校验稳定枚举和关键字段。 +- [x] 新增 `/sector-radar` 路由、导航、URL search 校验和活动路由映射。 +- [x] 实现状态摘要、筛选工具栏、排名表和分页;显示单位、质量状态、数据日期、last-good/stale 和独立指标版本。 +- [x] 页面测试覆盖成功、筛选、rank-change、loading、error、no-data、stale/partial;adapter 测试覆盖 URL、参数和 AbortSignal。 +- [x] 不引入图表依赖,不实现成分详情、历史轨迹或导出。 + +阶段结果(2026-08-29):新增独立 `features/sector-radar` API、运行时契约解析、React Query hooks、URL search 驱动的筛选与分页、桌面/移动排名表、发布来源与质量摘要,以及 `/sector-radar` 导航入口。页面明确区分初次加载、致命错误、无数据、后台刷新、后台刷新失败、partial/failed/running 新尝试和 success,并始终保留“知行独立实现”与版本声明;排名变化缺少历史时显示“暂无可比历史”。前端 lint、typecheck、全量 63 项 Vitest 和生产 build 通过,变更文件的 Prettier 检查通过;完整 `pnpm format:check` 仍被未修改的既有 `zhixing-web/DESIGN.md` 格式问题阻挡。浏览器已在默认桌面视口与 390×844 移动视口验证导航、筛选布局、错误降级和 rank-change URL 状态;本地后端未运行,因此成功数据态的视觉行为由页面测试覆盖,未声称真实数据库页面已验证。 + +## 6. 全量验证与审查 + +- [x] 后端:`uv run ruff format --check .`、`uv run ruff check .`、`uv run pyright`、`uv run pytest`。 +- [x] 前端:`pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm build`。 +- [x] 根级:`./dev.sh check`、`./dev.sh test`;验证开发和生产 Compose config。 +- [x] 使用 `trellis-check` 做全范围规范、PRD、跨层字段、单位、空值、版本声明和凭据泄漏检查,并修复发现项。 +- [x] 评估是否有经用户批准才应提升到 `.trellis/spec/` 的新知识;未经批准不写 Trellis spec。 + +阶段结果(2026-08-29):全范围终审补齐三项契约:`SourceSnapshot` identity 绑定返回字段、行上限和截断状态;显式空 `dc_member` 分区持久化为 `membership_unknown`,生成 unavailable 聚合并强制 publication 为 partial,只重试成员来源且绝不替换 last-good;HTTP 与前端把排名百分位统一收紧为 `(0, 100]`。新增迁移 head `0006_membership_unknown`,离线升级 SQL 已核对。最终后端 Ruff、Pyright 和全量测试为 139 passed、3 skipped,跳过项均需要 `ZHIXING_TEST_DATABASE_URL`;前端 format、lint、typecheck、全量 64 项 Vitest 与 build 通过,build 仅有既有单包大于 500 kB 的非阻塞提示;`./dev.sh check`、`./dev.sh test` 和开发/生产、默认/jobs 四种 Compose `config --quiet` 均通过。Compose 验证显式清空 `ZHIXING_TUSHARE_TOKEN` 并使用无敏感信息的占位数据库 URL。真实 PostgreSQL 集成、真实 Tushare capability、生产网络和部署权限仍未在本机环境验证,不将其误报为通过。全范围只读复核最终为 no blocking findings。经 `trellis-update-spec` 评估,unknown-membership 与快照 identity 属于可提升的候选知识,但用户未批准写 `.trellis/spec/`,本任务仅在设计、测试和本执行记录中保存。 + +## 风险与回滚点 + +- RequestCoordinator 提升后若现有 market-data 检查失败,先还原该重构,雷达 adapter 暂时内部组合相同行为,不改变现有同步。 +- 数据库迁移与 Job 在 HTTP/前端之前独立落地;迁移失败可 downgrade 新表,不能修改现有市场数据表。 +- live Tushare 调用只用于只读能力与数据质量验证;权限或到达时间不满足时,保留 fake/golden 里程碑并报告阻塞,不降低质量门或把缺失补 0。 +- 前端只读取 success/last-good;后端发布未稳定前不启用外部定时任务。 diff --git a/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/prd.md b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/prd.md new file mode 100644 index 0000000..4450d6c --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/prd.md @@ -0,0 +1,74 @@ +# 板块资金雷达模块 + +## Goal + +在知行系统中提供一个收盘后可查询的板块资金雷达,使用户能够按交易日分别查看概念板块与行业板块的资金强弱、前后榜和排名变化,并能辨认数据新鲜度、覆盖率与指标来源。实现以公开证据可复现为首要目标;任何未公开公式都必须采用有独立名称、版本和说明的可替换策略,不能宣称为 OneChartLab 原站公式。 + +## Background + +- OneChartLab 当前产品是收盘后的板块横截面排名系统,不是盘中实时雷达。公开证据已确认概念与行业为两个独立排名池,排名键分别为 `Swing_Score`、`Ratio_Score` 与 `Amount_Raw_BN`,排名百分位、前后 10% 榜以及 1—5 日排名变化算法可确定性复现。依据:`docs/research/onechartlab-sector-capital-radar.md`。 +- `Ratio_Score`、`Amount_Score`、3—10 日权重、`Swing_Score` 及相关异常值处理没有公开公式。独立实现不得根据字段名、线性拟合或作者口述伪造等价公式。依据:`docs/research/onechartlab-sector-capital-radar.md`、`docs/research/onechartlab-tushare-data-requirements.md`。 +- 公开消费层与独立生产层是两个不同目标。前者可原样保存 OneChartLab manifest、日期分片、排名历史和成分详情以复现当前页面;后者需要 Tushare 的 point-in-time 板块成员和资金事实,并只能先生成明确标注的替代指标。依据:`docs/research/onechartlab-tushare-data-requirements.md`。 +- 当前仓库已有 FastAPI 模块化单体、PostgreSQL 批次审计、Tushare 请求协调、原子 CSV 发布、同源 `/api/v1`、React Query 垂直切片及前后端测试模式,但尚无板块身份、板块成员快照、资金事实、雷达发布、显式 `last_good` 或图表基础设施。 +- 当前 checkout 为 detached HEAD,提交 `ad9545e` 同时是本地与远端 `develop` 的头;新任务 `task.json` 尚未设置工作分支。进入实现前应创建或切换到任务分支并写入任务元数据,不改动现有 `08-27-onechartlab-research` 调研任务。 +- 用户已选择 Tushare 独立生产 MVP,并确认具备所需 Tushare 权限。OneChartLab 公开 payload 只作为对账证据和测试样本来源,不作为生产运行时事实源。 + +## Requirements + +### R1. 可追溯发布 + +每个可查询发布版本必须至少携带交易日、观测/发布时间、来源类型、来源版本、内容 hash、指标策略版本、universe 版本、数据状态和覆盖率。相同交易日允许存在修订版本,读取端必须能区分当前发布与最近一个有效发布。 + +### R2. 排名池与确定性算法 + +概念和行业必须独立排名,不能硬编码板块数量或成员关系。对公开证据已确认的算法必须提供确定性实现和回归测试: + +- `Swing` 按 `Swing_Score` 降序,`Ratio` 按 `Ratio_Score` 降序,`Amount` 按 `Amount_Raw_BN` 降序; +- `RankPct = 100 * (N - rank + 1) / N`,排名从 1 开始; +- 普通强榜使用 `RankPct >= 90`,弱榜使用 `RankPct <= 10`; +- 排名变化为 `PastRank - CurrentRank`,正数表示上升;1—5 日历史不足或缺失时按明确契约处理; +- 排序必须确定性且不依赖输入遍历顺序;由于原站并列规则未知,本项目稳定键必须以独立实现契约命名并测试,不能标注为原站规则。 + +### R3. 未公开指标隔离 + +`Ratio`、`Swing` 及任何自建 score 的计算必须位于可替换指标策略 seam 后,并返回明确的 `metric_version`、参数、质量状态和所需输入。公开 payload 的预计算 score 与本项目独立策略不得混写为同一来源或同一版本。 + +### R4. Point-in-time 与空值语义 + +板块成员必须按交易日保存;缺失成员快照应为 `membership_unknown`,不得用当前成员回填历史。供应商缺行、NULL、数值 0、停牌、生命周期无效与低流动性必须保持不同语义,缺失资金流不得转成 0。 + +### R5. 单位与质量门 + +原始层保留供应商单位,规范层显式换算。MVP 使用的 `moneyflow_dc.net_amount` 为万元,`daily.amount` 为千元,两者必须先统一为元再计算比例。发布至少输出有效样本数、成员覆盖率、资金覆盖率和质量状态;未达完整性门槛时不得覆盖最近有效发布。 + +### R6. 读取契约与页面 + +后端在独立 bounded context 中提供稳定 Pydantic 响应,并经 `/api/v1` 同源路由暴露。前端在独立 feature 中使用 `requestJson`、React Query 和 URL/局部状态,显式呈现加载、错误、无数据、stale/partial 与成功状态。页面最少支持交易日、板块类型、指标视角、强弱榜切换、搜索和排名表。首个 MVP 不包含成分股详情、历史轨迹图或数据导出,但应保存足以计算 1—5 日排名变化的历史发布结果。 + +### R7. 收盘后执行与降级 + +生产流程沿用外部调度的一次性 Job,不在 FastAPI 生命周期内启动定时器。发布过程必须幂等并使用锁避免同一目标日期并发构建;上游未到齐或校验失败时保留 `last_good`,并把当前状态标记为 stale/partial/failed,不能发布假完整结果。 + +### R8. 安全与凭据 + +Tushare token 只从 `Settings`/环境注入,不得写入源码、日志、响应、测试 fixture 或 Trellis 任务文档。公开站点输入必须进行 schema 与有限数校验,不能仅依靠 TypeScript 泛型断言。 + +## Acceptance Criteria + +- [x] AC1:给定固定样本和乱序输入,概念/行业的三套排名、排名百分位、普通前后榜和 1—5 日排名变化结果可重复,且测试覆盖空池、单元素池、并列值、历史缺失和非有限数。 +- [x] AC2:每条雷达结果可追溯到唯一发布版本、来源版本、universe 版本和指标策略版本;响应和页面明确标注“知行独立实现”,不暴露或暗示原站 `Ratio_Score`、`Swing_Score` 字段。 +- [x] AC3:缺失资金流、成员未知、低流动性、部分覆盖与失败发布不会被展示成完整的零值结果;失败构建不覆盖 `last_good`。 +- [x] AC4:后端 HTTP 契约测试锁定筛选、分页/榜单、数据状态和错误行为;前端类型、API adapter、query 与页面测试覆盖 loading/error/no-data/stale/partial/success。 +- [x] AC5:页面可分别浏览概念与行业排名池,并按交易日、指标视角和强弱榜筛选;金额、比例、策略版本与排名变化的单位和方向符合本任务契约。 +- [x] AC6:收盘后 Job 可幂等重复执行,重复内容不产生无意义修订;同一日期并发执行被锁阻止,失败时保留最近有效发布。 +- [x] AC7:运行相关后端 Ruff、Pyright、pytest 与前端 format、lint、typecheck、Vitest、build;跨层链路通过根级检查。没有实际运行的检查不得标记为通过。 +- [x] AC8:生产运行时不请求 OneChartLab;Tushare 原始响应、point-in-time 成员和规范化股票事实足以重放同一指标策略版本,且公开样本只用于对账,不覆盖本地事实。 + +## Out of Scope + +- 盘中实时资金流、WebSocket 推送或分钟级雷达。 +- 在没有第一方公式和 point-in-time 输入证据时宣称完全复刻 `Ratio_Score`、`Swing_Score`、`Amount_Score` 或 3—10 日权重。 +- 使用当前板块成员回填历史、把缺失值补 0,或把 OneChartLab 当前 universe 数量硬编码进实现。 +- 宏观择时模块、交易执行、收益承诺和投资建议。 +- 在首个安全里程碑中一次性实现研究报告列出的全部 17 个 Tushare 接口。 +- 首个 MVP 的成分股详情、排名轨迹图、导出、盘中刷新、`daily_basic` 流动性增强和 `moneyflow_ind_dc` 三方对账;这些作为后续增量,不阻塞主榜生产。 diff --git a/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/research/implementation-evidence.md b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/research/implementation-evidence.md new file mode 100644 index 0000000..95ada05 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/research/implementation-evidence.md @@ -0,0 +1,32 @@ +# 板块资金雷达实现依据 + +## 已选择的产品边界 + +用户选择 Tushare 独立生产 MVP,并确认具备 Tushare 权限。生产运行时不依赖 OneChartLab;公开 payload 只用于验证公开契约、构造固定样本和对账。未公开公式必须使用知行系统自己的策略名称与版本。 + +## 仓库复用点 + +- 后端采用 `modules//{domain,application,infrastructure,presentation}`,依据 `docs/adr/0001-bounded-context-first-modular-monolith.md` 和 `.trellis/spec/backend/directory-structure.md`。雷达应创建独立 bounded context。 +- `market_data.infrastructure.tushare.RequestCoordinator` 已实现供应商请求冷却、退避和有限重试;`TushareAdapter` 已使用注入的 `pro_api(token)` client。相关实现位于 `zhixing-server/src/zhixing_server/modules/market_data/infrastructure/tushare.py:40-126,235-377`。 +- `SyncMarketData` 已实现 advisory lock、批次审计、部分成功和定向重试,位于 `zhixing-server/src/zhixing_server/modules/market_data/application/sync.py:150-165,255-261,408-532`。 +- PostgreSQL 适配器已有连接池、事务、staging + COPY 和幂等 upsert 模式,位于 `zhixing-server/src/zhixing_server/modules/market_data/infrastructure/postgres.py:26-57,296-401,769-925`。 +- FastAPI 业务路由由 `zhixing-server/src/zhixing_server/interfaces/http/router.py:10-18` 统一挂载;浏览器固定使用同源 `/api/v1`。 +- 前端垂直切片、路由和页面状态模式分别见 `zhixing-web/src/routes/route-tree.tsx:17-70`、`zhixing-web/src/features/home/api/`、`zhixing-web/src/features/home/pages/home-page.tsx`。当前没有图表依赖,因此首个 MVP 不加入轨迹图。 +- `.codegraph/` 不存在,跨文件影响分析只能使用源码、测试和 `rg`。 + +## 当前 Tushare 客户端核验 + +项目声明 `tushare>=1.4.24`,见 `zhixing-server/pyproject.toml:7-16`。2026-08-28 通过 Context7 解析 `/waditu/tushare` 与 `/websites/tushare_pro`,确认: + +- `ts.pro_api(token)` 创建 `DataApi`;客户端 `query(api_name, fields, **kwargs)` 把接口名、token、参数和字段列表发送到服务端。 +- 客户端本身不执行积分、权限、频率或行数限制;这些限制由 Tushare 服务端返回。因此采集 Job 必须记录安全错误类别、响应行数和覆盖率,并在返回数接近单次上限时分片重拉。 +- 不采用 `set_token()` 的用户目录持久化方式;项目继续通过 `Settings` 注入 token,避免凭据落盘或进入任务文档。 + +Context7 对具体板块接口的字段覆盖有限,接口字段、单位、历史边界和 2026-08-28 权限快照继续以 `docs/research/onechartlab-tushare-data-requirements.md` 所列 Tushare 第一方页面为实现依据。上线前由目标账号执行能力探测,不能把文档积分视为账号实测结果。 + +## 独立指标契约 + +- `zhixing_amount_net_bn_v1`:对当日有效成员的 `moneyflow_dc.net_amount` 求和,由万元除以 10,000 转为亿元;它是待公开样本对账的独立聚合,不宣称原站等价。 +- `zhixing_ratio_turnover_v1`:先统一为元,再计算板块 `sum(net_amount) / sum(daily.amount)`;分母为零或输入缺失时返回 NULL。 +- `zhixing_swing_equal_3_10_v1`:对窗口 3—10 个交易日分别计算 `sum(net_amount) / sum(turnover)`,再对八个完整窗口等权平均。任何窗口不完整时该指标不可用。该公式是透明、可替换的知行独立实现,不是 OneChartLab 的 3—10 日权重或 `Swing_Score`。 +- 横截面排名按指标值降序、板块代码升序稳定打破并列;概念与行业独立成池。`RankPct`、TOP/BOTTOM 阈值和 `PastRank - CurrentRank` 复用公开确认算法。 diff --git a/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/research/tushare-radar-contract.md b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/research/tushare-radar-contract.md new file mode 100644 index 0000000..f405113 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/research/tushare-radar-contract.md @@ -0,0 +1,35 @@ +# Tushare 板块雷达最小契约 + +本文件从 `docs/research/onechartlab-tushare-data-requirements.md` 提炼首个 Radar MVP 实际需要的接口,避免实现上下文被宏观择时和后续增强接口稀释。上线前仍以目标账号的只读 capability probe 为准。 + +## 最小接口 + +| 接口 | 作用 | 调用与必要字段 | 业务键与边界 | +| --- | --- | --- | --- | +| `trade_cal` | 确认开市日和窗口 | `exchange,start_date,end_date`;`exchange,cal_date,is_open,pretrade_date` | `(exchange,cal_date)`;初始化后按年刷新 | +| `dc_index` | 当日概念/行业 universe | `trade_date,idx_type`;`ts_code,trade_date,name,idx_type,level,pct_change,leading_code` | `(trade_date,ts_code)`;概念与行业分别拉取,单次上限公开页为 5,000 | +| `dc_member` | 当日 point-in-time 成员 | 优先 `trade_date`,必要时按 `ts_code` 分片;`trade_date,ts_code,con_code,name` | `(trade_date,ts_code,con_code)`;单次上限 5,000,命中上限或覆盖不足必须分片,不得用当前成员补历史 | +| `stock_basic` | 生命周期与市场过滤 | 分别拉 `list_status=L,D,P,G,UN`;`ts_code,symbol,name,market,exchange,list_status,list_date,delist_date` | `ts_code + observed_at`;默认只返回 L,不能漏掉其他状态 | +| `suspend_d` | 区分停牌与缺数 | `trade_date`;`ts_code,trade_date,suspend_timing,suspend_type` | `(ts_code,trade_date,suspend_type,suspend_timing)`;官方称不定期修订,需重叠回拉 | +| `daily` | 成交额、涨跌幅和行情可用性 | `trade_date`;`ts_code,trade_date,close,pre_close,pct_chg,vol,amount` | `(ts_code,trade_date)`;单次上限 6,000,停牌期间不返回;`amount` 单位千元 | +| `moneyflow_dc` | 个股东财口径主力净额 | `trade_date`;`trade_date,ts_code,name,net_amount,net_amount_rate,pct_change,close` | `(ts_code,trade_date)`;单次上限 6,000,历史始于 2023-09-11;`net_amount` 单位万元 | + +## 单位与规范化 + +- `moneyflow_dc.net_amount` 万元转元时乘 `10_000`,转亿元时除 `10_000`。 +- `daily.amount` 千元转元时乘 `1_000`,转亿元时除 `100_000`。 +- `daily.pct_chg=1.5` 表示 1.5%;不与 OneChartLab 小数比例字段直接混算。 +- 空字符串、`None` 和 `NaN` 规范为 NULL;`inf`、`-inf`、重复业务键和错误交易日属于硬错误。 +- 缺失资金流不是 0。只有生命周期有效、非停牌且源接口应有记录的股票进入缺失率分母。 + +## Universe 与质量 + +- 只纳入沪深 A 股,排除北交所与沪深 B 股;OneChartLab Radar 契约未声明排除 ST,因此不能复用现有选股股票池的 ST 过滤。 +- `dc_member` 某日缺失时标记 `membership_unknown`,不能向前或向后填充。 +- 全局分别计算成员、`daily` 和 `moneyflow_dc` 覆盖率;publication 只有在全部必需接口通过、成员完整且事实覆盖率达到配置门槛时才为 success。 +- 板块结果输出成员数、有效样本数、成交额、成员覆盖率与资金覆盖率;有效样本少于 5 时标记 `available_limited_sample`。 +- 初始回填必须按交易日顺序完成至少 10 日,才能生成完整 3—10 日独立波段指标;`moneyflow_dc` 的最早日期是硬边界。 + +## 客户端与安全 + +项目使用 `ts.pro_api(token)` 返回的 `DataApi`,动态调用最终进入 `query(api_name, fields, **params)`。客户端不执行权限、积分、频率或行数保护,采集 adapter 必须处理服务端错误、退避、返回行数和覆盖率。token 只从 `Settings` 注入,不调用 `set_token()` 写用户目录,不写入日志、原始请求清单或错误摘要。 diff --git a/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/task.json b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/task.json new file mode 100644 index 0000000..8b139c7 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/task.json @@ -0,0 +1,26 @@ +{ + "id": "sector-capital-radar", + "name": "sector-capital-radar", + "title": "板块资金雷达模块", + "description": "基于 Tushare point-in-time 事实独立生产收盘后板块资金排名、版本化指标、last-good API 与前端页面。", + "status": "completed", + "dev_type": null, + "scope": "fullstack", + "package": null, + "priority": "P2", + "creator": "yuxuanhui", + "assignee": "yuxuanhui", + "createdAt": "2026-08-28", + "completedAt": "2026-08-29", + "branch": "codex/zijin", + "base_branch": "develop", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/check.jsonl b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/check.jsonl new file mode 100644 index 0000000..49a051b --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/check.jsonl @@ -0,0 +1,7 @@ +{"file":".trellis/spec/backend/index.md","reason":"核验后端模块边界与开发规范"} +{"file":".trellis/spec/backend/market-data-sync.md","reason":"核验共享 coordinator 未改变 market-data 语义"} +{"file":".trellis/spec/backend/tushare-listed-stock-universe.md","reason":"核验所有业务候选与当前 L 股票母集相交"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"执行完整后端质量门禁"} +{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"核验共享能力没有越界或重复实现"} +{"file":".trellis/tasks/08-31-sector-radar-listed-moneyflow-recovery/research/radar-build-tushare-call-chain.md","reason":"核验 source group、候选集与 retry/checkpoint 契约"} +{"file":".trellis/tasks/08-31-sector-radar-listed-moneyflow-recovery/research/tushare-global-start-interval.md","reason":"核验两路 worker、共享启动间隔及确定性测试覆盖"} diff --git a/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/design.md b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/design.md new file mode 100644 index 0000000..423f446 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/design.md @@ -0,0 +1,29 @@ +# 设计:当前上市股票池与 `moneyflow_dc` 缺口补拉 + +## 边界与契约 + +应用层在完成板块成员与 `stock_basic(L)` 采集后,先规范化成员并计算“有效成员代码与当前 L 股票代码的交集”,再调用扩展后的 `SectorRadarSource.fetch_moneyflow_dc(trade_date, candidate_codes)`。候选集合通过端口显式传递;adapter 不缓存先前 `fetch_stock_basics` 的响应,因此 publication replay 和 retry 仍是无隐式状态的。 + +`stock_basic` source 由五分区请求收敛为单一 `L` 分区。领域层继续用现有代码、市场和 `list_date` 规则处理当前上市候选,不新增 ST 过滤或历史退市语义。 + +## 资金流数据流 + +adapter 首先保存按 `trade_date` 获取的全市场 snapshot。它对 rows 执行 typed parsing、目标日期和 `ts_code` 唯一性校验;初始 snapshot 达到 6000 行只表示全市场可能截断,不再单独构成失败。随后计算 `candidate_codes - returned_codes`。 + +缺失集合为空时返回首批 snapshot 与 rows。存在缺失时,按排序后的 `ts_code` 使用固定两路 executor 请求 `moneyflow_dc(trade_date=..., ts_code=...)`。每个成功分片形成独立、带 `partition_key=ts_code` 的 snapshot;分片只允许为空或返回所请求股票在目标日期的唯一记录。空分片以及重试耗尽的普通 provider 异常不产生伪造 snapshot/row,并保留为覆盖缺口;来源 schema、日期、代码、唯一键或 row-limit 契约错误立即上浮。 + +主线程按输入代码顺序汇总 future,保证 `source_order=0` 始终是全市场 snapshot,后续分片按 `ts_code` 稳定排列。合并 rows 后再次验证 `(trade_date, ts_code)` 唯一,防止首批与分片重叠。所有 snapshot 继续归入 `PublicationSourceGroup.MONEYFLOW_DC`,现有数据库模型无需迁移。 + +## 并发与限流 + +共享 `RequestCoordinator` 增加默认值为 0 的 `request_interval_seconds` 和受现有 `threading.Condition` 保护的下次启动时刻。每次 attempt 在调用 provider 前原子等待 cooldown 并预约请求启动槽,预约完成后释放锁,再执行真实请求。资金雷达默认 coordinator 接收现有的 0.2 秒配置,移除 adapter 请求完成后的独立 sleep;因此两个 worker可重叠网络等待,但同一 adapter 中任意两次请求的启动时间仍至少相隔 0.2 秒。 + +当前锁定的 Tushare 1.4.29 `DataApi.query` 只读取 client 的 token、URL 和 timeout,在局部变量中构造参数并调用模块级 `requests.post`,未维护单次请求可变状态。两路 worker 共享该 client 的风险可接受,并由并发单元测试约束;该结论不扩展为 Tushare SDK 的通用线程安全保证。 + +## 兼容性、失败与回滚 + +`RequestCoordinator` 的新参数默认关闭,market-data bounded context 行为不变。端口签名变化同步更新 fake source 和 CLI/build 测试。普通补拉调用在 coordinator 的有限 retry 后仍失败时记录安全日志并留下覆盖缺口;契约错误保持 hard failure。日志不得包含 token 或完整 payload。 + +publication retry 只有在已保存的 `MONEYFLOW_DC` rows 仍覆盖本轮候选代码时才重放该来源组。若 `MEMBERS` 刷新后候选集合扩大,旧资金流 checkpoint 不足以覆盖新增候选,则在同一次 retry 中刷新 `MONEYFLOW_DC`,避免先发布一次可预见的 partial 再要求第二次重试。 + +回滚只需恢复 adapter 的单次 `moneyflow_dc` 请求、旧端口签名和协调器调用方式,不涉及 schema 或数据迁移。已生成的分片 snapshots 使用现有通用存储格式,旧版本即使不能主动生成,也仍可按 source group replay。 diff --git a/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/implement.jsonl b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/implement.jsonl new file mode 100644 index 0000000..02d8e17 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/implement.jsonl @@ -0,0 +1,9 @@ +{"file":".trellis/spec/backend/index.md","reason":"后端模块边界、开发前检查和质量入口"} +{"file":".trellis/spec/backend/directory-structure.md","reason":"共享协调器与 sector_radar bounded context 的所有权边界"} +{"file":".trellis/spec/backend/configuration-and-runtime.md","reason":"复用现有请求间隔配置并避免新增环境读取"} +{"file":".trellis/spec/backend/market-data-sync.md","reason":"现有 Tushare coordinator、并发与 checkpoint 相邻契约"} +{"file":".trellis/spec/backend/tushare-listed-stock-universe.md","reason":"所有股票型功能只使用构建时当前 L 股票母集"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"后端 Ruff、Pyright 和 pytest 门禁"} +{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"评估 RequestCoordinator 共享原语扩展"} +{"file":".trellis/tasks/08-31-sector-radar-listed-moneyflow-recovery/research/radar-build-tushare-call-chain.md","reason":"资金雷达调用顺序、候选集与 checkpoint 证据"} +{"file":".trellis/tasks/08-31-sector-radar-listed-moneyflow-recovery/research/tushare-global-start-interval.md","reason":"两路 worker 与全局启动间隔的适配点和测试模式"} diff --git a/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/implement.md b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/implement.md new file mode 100644 index 0000000..94a31c1 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/implement.md @@ -0,0 +1,25 @@ +# 实施计划 + +1. 创建 `codex/sector-radar-listed-moneyflow-recovery` 分支并读取目标 backend/shared、sector radar 代码及相关规格。 +2. 扩展 `RequestCoordinator`,实现线程安全的共享请求启动间隔,保留默认关闭与现有 cooldown/retry 行为;补充确定性单元测试。 +3. 将 `stock_basic` 收敛为单一 `L` 分区,并调整 source 测试与返回状态契约。 +4. 前移候选股票形成步骤,扩展 `SectorRadarSource.fetch_moneyflow_dc` 端口,向 adapter 显式传递稳定的当前 L 候选代码。 +5. 在 Tushare adapter 内实现全市场首拉、候选覆盖检查、两路缺失代码补拉、分片契约校验、稳定 snapshot/row 汇总和安全错误日志。 +6. 更新 FakeRadarSource、build/retry 测试和 CLI 组合测试,覆盖成功、6000 行、空分片、瞬时失败、错误日期/代码、重复键、分片触顶、两路 worker 与 checkpoint 重试。 +7. 更新必要的运维说明,明确当前 L 股票池、候选覆盖语义、同一 adapter 的 0.2 秒共享间隔以及不同定时任务不得重叠。 +8. 依次运行定向 pytest、Ruff format/lint、Pyright、完整 pytest,并由独立 Trellis check 代理核验规格和实现;修复所有本任务引入的问题后提交本地分支。 + +## 风险点与回滚检查 + +- `RequestCoordinator` 是共享模块,必须证明默认参数不改变 market-data 并发。 +- worker 完成顺序不能进入 publication source order 或 input hash。 +- 不能把普通 provider 异常与来源契约错误混为一类,也不能用空行伪造成功分片。 +- 端口签名变化必须同步所有 fake/replay 路径,完整测试前不得仅凭 source 单测判定完成。 +- 无数据库迁移;若验证失败,可按步骤分别回滚协调器启动槽和资金流分片逻辑。 + +## 验证结果 + +- `uv lock --check`、Ruff format/check、Pyright strict 全部通过。 +- 后端完整测试 `159 passed, 3 skipped`;跳过项均要求显式设置 `ZHIXING_TEST_DATABASE_URL`。 +- 根目录 `./dev.sh check` 与 `./dev.sh test` 通过;前端 `64 passed`。 +- 未执行真实 Tushare 账号并发调用与真实 PostgreSQL 集成测试,留待部署后的 capability/生产批次验证。 diff --git a/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/prd.md b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/prd.md new file mode 100644 index 0000000..f133f22 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/prd.md @@ -0,0 +1,40 @@ +# 资金雷达当前上市股票池与资金流缺口补拉 + +## Goal + +让板块资金雷达以“构建时当前上市股票”为唯一证券范围,并在 Tushare `moneyflow_dc` 单日响应触及 6000 行上限时,仍能安全验证和补齐雷达候选股票,而不是直接失败或接受可能截断的数据。 + +## Background + +- 生产构建目标日 `2026-08-28` 已在 `moneyflow_dc` 来源组因响应达到供应商 6000 行上限而失败。 +- Tushare 官方接口说明确认 `moneyflow_dc` 单次最多返回 6000 条,并支持按日期或股票代码循环提取。 +- 用户明确不要求历史时点证券生命周期还原;功能上线日视为最早历史日期,当前及未来均只研究构建时 `stock_basic(list_status=L)` 返回的股票。 +- Tushare 账号频率限制为 500 次/分钟;用户批准资金流缺口补拉使用 2 个 worker,但两个 worker必须共享同一请求启动限流器。 + +## Requirements + +1. `stock_basic` 只请求 `list_status=L`,不再请求 `D/P/G/UN`。保留资金雷达既有的沪深 A 股、B 股/北交所排除和上市日期校验,不额外引入 `market-data-sync` 的 ST 过滤语义。 +2. 资金流完整性只针对有效板块成员与当前 `L` 股票的交集。应用层必须在请求 `moneyflow_dc` 前形成稳定、去重的候选代码集合,并通过显式端口参数传给 source adapter,禁止依赖 adapter 内部调用顺序或缓存状态。 +3. `moneyflow_dc` 首次仍按目标交易日请求全市场。首次响应即使达到 6000 行,也必须先校验目标日期和业务唯一键,再检查候选股票覆盖率,不能直接接受或直接报截断。 +4. 首次响应缺少候选股票时,只按稳定排序后的缺失 `ts_code` 补拉。每个分片必须同时传入 `trade_date` 和 `ts_code`,并校验返回日期、返回代码、唯一键以及供应商是否忽略了分片参数。 +5. 缺口补拉固定使用 2 个 worker。同一 adapter 的所有首次请求、补拉请求及 retry 共享请求启动间隔,默认相邻请求启动至少间隔 0.2 秒;普通 provider 调用允许重叠,不得把整个请求放在协调器锁内。 +6. 空分片或重试耗尽的瞬时请求失败保留为真实缺口,不补零;构建继续走现有覆盖率逻辑并可发布 `partial`。日期错误、返回错误股票代码、重复业务键或分片再次触及供应商上限属于来源契约错误,必须 fail closed。 +7. 全市场首批 snapshot 与每个成功分片 snapshot 都属于现有 `MONEYFLOW_DC` source group,并按确定性顺序保存。重试继续复用已完成来源组,只刷新资金流来源组,不新增数据库表或 publication group。 +8. `RequestCoordinator` 的请求启动间隔默认关闭,只有资金雷达通过现有 `sector_radar_request_interval_seconds` 启用,不能改变 `market-data-sync` 当前八路并发语义。 + +## Acceptance Criteria + +- [x] 资金雷达构建只发出一次 `stock_basic(list_status=L)` 请求,并拒绝该分区返回非 `L` 状态。 +- [x] `moneyflow_dc` 首批低于或等于 6000 行且覆盖全部候选股票时均可成功解析;达到 6000 行本身不再导致 `SourceTruncatedError`。 +- [x] 首批未覆盖候选股票时,仅补拉缺失代码,调用总数为 `1 + 缺失代码数`,最终 snapshot 顺序与 worker 完成顺序无关。 +- [x] 两个补拉请求可以处于并发等待状态,但共享协调器记录的请求启动时间间隔不小于配置值;默认配置下理论总速率不超过约 300 次/分钟。 +- [x] 空补拉和瞬时请求失败不会被补零或伪装成完整覆盖;错误日期、错误代码、重复键和分片触顶会阻止发布错误结果。 +- [x] failed/partial publication 重试仍复用既有 source checkpoints,并只刷新需要重取的 `MONEYFLOW_DC` group。 +- [x] 共享协调器、sector radar source/build/CLI 相关单元测试、Ruff、Pyright 和完整后端 pytest 通过;需要真实 PostgreSQL 的测试若未配置,必须明确报告跳过状态。 + +## Out of Scope + +- 不保证历史日期按当时上市状态精确重建,也不保留已退市股票进入未来重跑结果。 +- 不复用 `market-data-sync` 的数据库股票池、行情或 Tushare client。 +- 不实现跨进程或跨定时任务的分布式限流;运维上仍要求 `market-data-sync` 与 `sector-radar-build` 不重叠运行。 +- 不改变板块评分公式、前端展示、数据库 schema 或其他 Tushare 来源组的请求策略。 diff --git a/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/research/radar-build-tushare-call-chain.md b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/research/radar-build-tushare-call-chain.md new file mode 100644 index 0000000..8df6a8f --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/research/radar-build-tushare-call-chain.md @@ -0,0 +1,85 @@ +# Research: 板块资金雷达 build 到 Tushare source 调用链 + +- Query: 定位板块资金雷达从 application build 到 Tushare source 的完整调用链,解释 `stock_basic` 为什么请求 `L/D/P/G/UN`、`moneyflow_dc` 在哪里按 6000 行拒绝、候选股票集合何时形成,以及重试时 publication/source checkpoint 如何复用。 +- Scope: internal +- Date: 2026-08-31 + +## Findings + +### 1. 完整调用链 + +生产入口由 `zhixing-server/pyproject.toml:36-38` 将 `sector-radar-build` 绑定到 `presentation.cli:main`。CLI 在 `zhixing-server/src/zhixing_server/modules/sector_radar/presentation/cli.py:42-47` 构造 `BuildSectorRadarCommand`,在同文件 `:62-77` 用 token 创建 `TushareSectorRadarAdapter`、创建 PostgreSQL repository,并调用 `BuildSectorRadar(...).execute(command)`。 + +application 层从 `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:199-222` 的 `BuildSectorRadar.execute` 开始。普通单日/区间模式先由 `_resolve_targets` 调 `source.fetch_trade_calendar` 解析目标交易日(`:224-247`);retry 模式则直接读取原 publication 并复用其目标交易日(`:225-231`)。随后 `_build_target` 获取按交易日的 advisory lock(`:257-271`),`_build_locked` 恢复遗留 running publication、创建新的 running publication、加载可复用来源组,再进入 `_collect`(`:285-310`)。 + +`_collect` 以固定顺序调用 `_fetch_group`:calendar、concept indices、industry indices、members、stock basics、suspensions、daily、moneyflow_dc,见 `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:498-563`。application 依赖的 source port 定义在 `zhixing-server/src/zhixing_server/modules/sector_radar/domain/ports.py:24-47`;生产实现是 `TushareSectorRadarAdapter`。每个 adapter 方法最终进入 `TushareSectorRadarAdapter._fetch_snapshot`,它组装显式 fields 后优先调用 `client.query(api_name, fields=..., **params)`,见 `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:368-405`。因此资金流主链是 `BuildSectorRadar.execute -> _build_target -> _build_locked -> _collect -> _fetch_group(MONEYFLOW_DC) -> SectorRadarSource.fetch_moneyflow_dc -> TushareSectorRadarAdapter.fetch_moneyflow_dc -> _fetch_snapshot -> client.query("moneyflow_dc", trade_date=..., fields=...)`。 + +`_fetch_group` 不只是调用 source:新拉或重放成功后,它立即保存 content-addressed raw snapshot,并以 `(publication_id, source_group, source_order)` 建立 publication checkpoint 链接,见 `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:457-496`。这意味着失败发生前已完成的每一组都已经具备可恢复检查点。 + +### 2. `stock_basic` 为什么请求 `L/D/P/G/UN` + +adapter 明确说明不能依赖 Tushare 默认只返回 `L`,并逐一请求五个文档化生命周期分区,见 `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:267-285`。每个响应还校验返回 `list_status` 必须与请求分区一致(`:280-282`),最后跨分区校验 `ts_code` 唯一(`:284`)。现有回归测试固定了五次请求顺序与五种状态均被汇总,见 `zhixing-server/tests/unit/sector_radar/test_tushare_source.py:308-333`。 + +业务原因是 radar 需要按目标交易日判断 point-in-time 生命周期,而不是只看“当前仍上市”的默认集合。`StockBasicRow` 保存 `list_date`/`delist_date`,见 `zhixing-server/src/zhixing_server/modules/sector_radar/domain/source.py:338-364`;真正的生命周期判断在 `zhixing-server/src/zhixing_server/modules/sector_radar/domain/normalize.py:200-210`,要求沪深 A 股、非 B 股/北交所、`list_date <= target` 且目标日不晚于 `delist_date`。因此完整状态分区主要用于避免历史目标日漏掉目前已退市/暂停等股票,并使未上市/过会等记录由日期规则明确排除。`list_status` 本身目前不直接决定资格,资格由代码、市场及上市/退市日期决定。 + +### 3. `moneyflow_dc` 的 6000 行拒绝点 + +`ROW_LIMITS["moneyflow_dc"]` 在 `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:73-81` 固定为 `6_000`。`_fetch_snapshot` 把该上限传给 `build_source_snapshot`(同文件 `:397-405`);snapshot builder 在 `zhixing-server/src/zhixing_server/modules/sector_radar/domain/source.py:147-160` 计算 `row_count`,并以 `row_count >= row_limit` 标记 `limit_reached=True`,所以恰好返回 6000 行也视为可能截断。 + +具体拒绝发生在 `TushareSectorRadarAdapter.fetch_moneyflow_dc`:取到 snapshot 后立刻调用 `_reject_limit`,见 `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:318-330`;`_reject_limit` 在同文件 `:465-468` 抛出 `SourceTruncatedError("moneyflow_dc reached its provider row limit")`。这里没有像 `dc_member` 那样的分区补拉逻辑;错误经 `_fetch_group` 和 `_build_locked` 上浮,最终 publication 被记为 failed(`application/build.py:391-421`)。 + +### 4. 候选股票集合形成时点 + +候选集不是在请求 `moneyflow_dc` 之前形成。`_collect` 先完成全部八个来源组,包括 full-market `daily` 与 `moneyflow_dc`(`zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:550-563`),然后才调用 `normalize_memberships`,从状态为 `AVAILABLE` 的板块成员记录中取非空 `stock_code`、去重并排序为 `candidate_codes`(`:565-574`)。随后 `candidate_codes` 才传入 `normalize_stock_facts`(`:575-582`)。 + +这个集合此时只是“当日概念/行业成员股票并集”,尚未完成生命周期过滤。`normalize_stock_facts` 在遍历候选代码时才逐只调用生命周期规则;不合法者被保留为 `LIFECYCLE_INVALID` fact,见 `zhixing-server/src/zhixing_server/modules/sector_radar/domain/normalize.py:159-170`。所以当前调用顺序无法用候选集缩小或分片本轮 `moneyflow_dc` 请求,这是本次“当前上市股票池与资金流缺口补拉”设计需要显式调整的结构性边界。 + +### 5. publication/source checkpoint 的重试复用 + +retry 命令只接受 `partial` 或 `failed` publication,并复用旧 publication 的目标交易日,见 `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:224-231`。实际重试不会继续写旧 publication,而是在持有日期锁后新建一个 running publication,再从旧 publication 加载 source checkpoints(`:285-310`)。 + +checkpoint 的领域模型是八个稳定的 `PublicationSourceGroup` 和有序 `PublicationSourceRecord`,后者持有 raw `SourceSnapshot` 与 `refresh_on_retry` 标志,见 `zhixing-server/src/zhixing_server/modules/sector_radar/domain/persistence.py:131-160`。数据库表以 `(publication_id, source_group, source_order)` 为主键,raw snapshot 外键采用 `RESTRICT`,见 `zhixing-server/migrations/versions/0005_radar_daily_aggregate.py:17-55`;repository 加载时 join raw snapshot 并按 group/order 返回,见 `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py:192-222`。 + +`_reusable_sources` 会忽略 `refresh_on_retry=True` 的记录;其余记录按 `source_order` 排序,并要求编号从 0 连续,否则拒绝重放,见 `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:433-455`。`_fetch_group` 命中 reusable group 时不访问 Tushare,而是从保存的 snapshot rows 重新执行 typed parser;未命中时才调用 source。无论重放还是新拉,snapshot 都会再次链接到新的 running publication,见同文件 `:457-496`。 + +两类失败的复用语义不同。对于完整采集后因覆盖率不足形成的 partial,`_retry_source_groups` 根据 `membership_unknown`、缺失/空 `daily`、缺失/空 `moneyflow` 精确选择需刷新的来源组,见 `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:683-704`;`finalize_publication` 在同一事务中把这些组标记为 `refresh_on_retry=TRUE` 后再结束 publication,见 `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py:562-571`。对于采集中途 hard failure,成功组已经由 `_fetch_group` 即时 checkpoint,失败组及其后的组没有记录;因此 retry 自动重放所有已完成组并从首个未完成组继续。测试证明 partial 资金缺口只再次调用 `moneyflow_dc`(`zhixing-server/tests/unit/sector_radar/test_build.py:389-406`),而 daily hard failure 后会复用此前六组,只再次调用 `daily` 和尚未执行的 `moneyflow_dc`(`:409-426`)。 + +收集完成后,所有 snapshot id 与指标版本参与 `input_hash`(`zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:706-716`);若已存在同目标日、同 input hash 的 publication,新 running publication 会被丢弃并返回 existing publication,见同文件 `:310-327`。这是 publication 级幂等复用,与 source-group 级断点重放互补。 + +## Files Found + +- `zhixing-server/src/zhixing_server/modules/sector_radar/presentation/cli.py`:生产 CLI 组合根,创建 Tushare adapter、PostgreSQL repository 与 application use case。 +- `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py`:目标日期解析、来源组采集顺序、候选集生成、publication 生命周期及重试复用核心。 +- `zhixing-server/src/zhixing_server/modules/sector_radar/domain/ports.py`:application 到 source adapter 的端口契约。 +- `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py`:七类 Tushare 接口、状态分区、行数上限与 `client.query` 边界。 +- `zhixing-server/src/zhixing_server/modules/sector_radar/domain/source.py`:raw snapshot 的上限标记、typed row 解析与 source contract errors。 +- `zhixing-server/src/zhixing_server/modules/sector_radar/domain/normalize.py`:成员候选并集之后的生命周期、停牌、行情与资金流事实归一化。 +- `zhixing-server/src/zhixing_server/modules/sector_radar/domain/persistence.py`:source checkpoint 分组和值对象契约。 +- `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py`:checkpoint 的保存、加载与 partial 刷新标记事务。 +- `zhixing-server/migrations/versions/0005_radar_daily_aggregate.py`:publication-source checkpoint 表结构及完整性约束。 +- `zhixing-server/tests/unit/sector_radar/test_build.py`:partial/failed 重试只刷新未完成来源组的可执行证据。 +- `zhixing-server/tests/unit/sector_radar/test_tushare_source.py`:五种 `stock_basic` 状态分区请求的回归证据。 + +## Code Patterns + +- Port/adapter:application 只依赖 `SectorRadarSource`,CLI 注入 `TushareSectorRadarAdapter`(`domain/ports.py:24-47`;`presentation/cli.py:62-77`)。 +- Point-in-time master data:显式拉取所有生命周期状态,再按目标日 `list_date`/`delist_date` 判定(`infrastructure/tushare.py:267-285`;`domain/normalize.py:200-210`)。 +- Fail closed on provider limit:snapshot 以 `>=` 标记触顶,不能将潜在截断当成功(`domain/source.py:158-160`;`infrastructure/tushare.py:465-468`)。 +- Immediate source checkpoint:每个来源组一成功就保存 raw snapshot 及 publication link,而不是等待整个 publication 完成(`application/build.py:486-496`)。 +- Selective retry:partial 显式标记缺口组;failed 依赖已完成组存在、未完成组缺席来恢复(`application/build.py:433-475,683-704`)。 + +## External References + +- 本次为内部调用链研究,未新增外部资料检索。既有已归档研究 `.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/research/tushare-radar-contract.md:7-15` 记录了原实现采用的 Tushare 接口边界:`stock_basic` 默认只返回 `L`,`moneyflow_dc` 单次上限 6000;上线前仍应以目标账号 capability probe 和当时官方文档为准。 + +## Related Specs + +- `.trellis/spec/backend/market-data-sync.md`:一次性 Tushare Job、可恢复 snapshot、失败保留旧发布的相邻上下文规范;sector radar 有独立 bounded context,不能直接套用选股/ST 股票池规则。 +- `.trellis/spec/backend/selection.md`:selection 的当前沪深非 ST 股票池契约不等于 radar 的 point-in-time 板块成员 universe。 +- `.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/design.md:36-44`:原 radar 设计要求全部上市状态、目标日生命周期、沪深 A 股过滤及行数触顶时不得接受截断响应。 + +## Caveats / Not Found + +- 当前 `moneyflow_dc` 没有按候选股票或代码分片的实现;达到 6000 行只会 hard fail。`dc_member` 有按板块分区补拉,可作为模式参考,但不能直接证明 Tushare `moneyflow_dc` 支持同样的参数或批量行为。 +- 当前候选集形成得晚于 `moneyflow_dc` 请求,并且成员并集与“生命周期有效股票池”是两个阶段;讨论修复时必须明确要前移哪一个集合,避免误把所有板块成员都视为当前上市股票。 +- 代码中的 `ROW_LIMITS` 是本地契约常量,不是运行时从供应商元数据发现;若要改变请求策略,需要重新核对当前 Tushare `moneyflow_dc` 的可用过滤参数、单次限制及积分权限。 diff --git a/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/research/tushare-global-start-interval.md b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/research/tushare-global-start-interval.md new file mode 100644 index 0000000..95bff71 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/research/tushare-global-start-interval.md @@ -0,0 +1,92 @@ +# Research: Tushare 两路补拉的全局请求启动间隔 + +- Query: 检索仓库现有 Tushare 限流、并发 worker、线程安全、测试夹具与 `sector_radar` source 测试模式,定位实现“2 个 worker 共享全局 0.2 秒请求启动间隔”的最小适配点。 +- Scope: internal +- Date: 2026-08-31 + +## Findings + +### 结论与最小适配面 + +最小且边界清晰的实现是扩展共享的 `RequestCoordinator`,让它可选地协调“请求启动槽”,然后仅让 `TushareSectorRadarAdapter` 启用现有的 `request_interval_seconds=0.2`。两路 `moneyflow_dc` 补拉 worker 共享同一个 adapter,而该 adapter 已经只持有一个 `_coordinator`,因此不需要新建进程级 singleton,也不需要新增环境变量。 + +具体适配点如下。 + +1. 在 `zhixing-server/src/zhixing_server/shared/request_coordinator.py:35-66` 的 `RequestCoordinator` 增加默认关闭的启动间隔参数及 `_next_request_at` 状态;继续复用现有 `threading.Condition`,在同一临界区内读取单调时钟、计算 `max(_cooldown_until, _next_request_at)`、等待并预约下一启动时刻。只有“预约”需要持锁,真实 provider 请求必须在锁外执行,才能保持两个 worker 的请求重叠能力。 +2. 在 `zhixing-server/src/zhixing_server/shared/request_coordinator.py:75-82` 的每次 attempt 开始前,把当前只等待 cooldown 的 `_wait_for_cooldown` 收敛成“等待 cooldown 并原子预约启动槽”。预约完成时令 `_next_request_at = actual_start + interval`。这样初次请求和 retry 都服从同一个启动间隔;当 403/429 创建 cooldown 后,等待中的 worker 还会在醒来时重新检查 cooldown。 +3. 新参数必须默认 `0.0`。`RequestCoordinator` 还被 market-data 使用,而且它的现有契约明确是“普通请求不串行,只共享命中限流后的 cooldown”(`zhixing-server/src/zhixing_server/shared/request_coordinator.py:35-41`;`docs/market-data-sync.md:65-69`)。默认关闭可避免顺带改变八路行情同步语义。 +4. 在 `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:92-116` 构造默认 coordinator 时,把已经存在的 `request_interval_seconds` 传入协调器;删除或停用 `_fetch_snapshot` 成功返回后的逐线程休眠(`zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:368-389`)。当前休眠发生在请求完成后,两个线程可以同时启动,不能表达“全局请求启动间隔”。 +5. `request_interval_seconds` 的配置链已经完整:`Settings.sector_radar_request_interval_seconds` 默认 0.2(`zhixing-server/src/zhixing_server/bootstrap/config.py:27-31`),CLI 将它传给 `TushareSectorRadarAdapter.from_token`(`zhixing-server/src/zhixing_server/modules/sector_radar/presentation/cli.py:62-67`)。因此不需要改 `.env`、Compose 或配置模型。 + +这里的“全局”只能可靠地解释为“同一 adapter/coordinator 实例覆盖的两个 worker”。现有 coordinator 不是模块 singleton,也不能跨进程协调;market-data job、sector-radar job 或两个独立进程各自创建 coordinator。若需求是全系统或跨进程的 5 requests/s,则本方案不满足,需要外部/分布式限流器,这会明显扩大范围。 + +### 两个 worker 的落点 + +`moneyflow_dc` 的当前全市场入口完全串行,只请求一次并校验结果(`zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:318-330`)。补拉属于 Tushare 响应分区细节,最小落点是该 infrastructure adapter 内部:先保留全市场快照,再对缺失的当前上市股票代码使用 `ThreadPoolExecutor(max_workers=2)` 发起按 `ts_code` 分区请求。worker 共享 `self._coordinator`,所以每一个 `_fetch_snapshot` 最终都经过同一个启动槽。 + +仓库已有 worker 写法可复用:`SyncMarketData` 在 `zhixing-server/src/zhixing_server/modules/market_data/application/sync.py:342-361` 使用具名的 `ThreadPoolExecutor` 和 future-to-business-key 映射;并发测试用带 `threading.Lock` 的 fake 统计 active/max-active(`zhixing-server/tests/unit/market_data/test_sync_concurrency.py:22-59`),并断言两路上限(`zhixing-server/tests/unit/market_data/test_sync_concurrency.py:156-180`)。sector-radar 不宜照搬其数据库副作用模型,只应复用“有界 executor + 主线程汇总”的形状。 + +如果补拉需要由当前上市股票池驱动,应用层已经先得到 `stock_basics`、后取 `moneyflow`(`zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:536-563`)。最小跨层契约是从 `stock_basics.rows` 中取 `list_status == "L"` 的代码并传给 `fetch_moneyflow_dc`;这会同步影响 `SectorRadarSource.fetch_moneyflow_dc`(`zhixing-server/src/zhixing_server/modules/sector_radar/domain/ports.py:24-47`)和测试 fake。不要让 adapter 缓存上一次 `fetch_stock_basics` 的结果,否则 retry/replay 和调用顺序会形成隐式状态。 + +worker 完成顺序不得直接决定 snapshot 顺序。publication checkpoint 会按 `result.snapshots` 的枚举顺序持久化(`zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:486-495`),重放又要求 `source_order` 从 0 连续并按序恢复(`zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:433-455`)。因此 future 结果应按输入股票代码或明确排序后汇总;虽然单个 snapshot 内部的 hash 已对 rows 做顺序稳定化(`zhixing-server/src/zhixing_server/modules/sector_radar/domain/source.py:134-163`),snapshot 元组自身仍需稳定。 + +固定“两路”不要求新增 `Settings`。最小做法是在 adapter 内使用命名常量或默认值为 2 的构造参数;只有产品要求运行时可调时,才需要扩展 config、CLI、`.env.example` 和 Compose。仓库现有可调 worker 的完整链路可参考 `Settings.market_data_max_workers`(`zhixing-server/src/zhixing_server/bootstrap/config.py:20-25`)和 `SyncMarketData(max_workers=...)`(`zhixing-server/src/zhixing_server/modules/market_data/application/sync.py:131-143`)。 + +### 现有限流与线程安全证据 + +共享协调器已经用 `threading.Condition` 保护 `_cooldown_until` 和 `_rate_limit_count`(`zhixing-server/src/zhixing_server/shared/request_coordinator.py:43-66`),读取、创建 cooldown 和成功后清理也都在该条件锁内(同文件 `:68-73`、`:127-152`)。403、429 和稳定中英文提示的分类位于同文件 `:13-28`、`:154-163`,cooldown 阶梯为 60/120/180 秒(`:13`)。这正是承载全 worker 启动槽的现有线程安全原语。 + +当前 `call` 明确允许普通请求并发(`zhixing-server/src/zhixing_server/shared/request_coordinator.py:35-41`),而 `_wait_for_cooldown` 在锁外调用 `wait_fn`(`:127-138`),不会把 provider 调用包在全局锁中。新增启动间隔也应保持这一点;如果把整次 `client.query` 放进锁中,虽然间隔成立,但会把两个 worker 退化为串行请求。 + +`TushareSectorRadarAdapter` 对同一个 SDK client 调用 `client.query` 或接口方法(`zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:378-385`)。仓库内没有 Tushare SDK client 线程安全保证,也没有为 `_client` 加锁。锁定版本是 Tushare 1.4.29(`zhixing-server/uv.lock:731-739`)。因此两路并发是否可共享同一 SDK client 是实现前仍需确认的风险;启动间隔只保护频率状态,不等于保证 SDK client 内部线程安全。若无法确认,选择独立 client 会需要 token/factory 生命周期改造,选择锁住整个 client 请求则无法获得网络调用并发收益。 + +### 测试模式与建议入口 + +仓库没有 `tests/**/conftest.py` 或 sector-radar pytest fixture。`zhixing-server/tests/unit/sector_radar/test_tushare_source.py:23-44` 采用文件内 `QueryClient` 和 `make_adapter`:响应按 `(api_name, ts_code/list_status)` 分区,adapter 关闭 retry 和真实 sleep,并固定 `now_fn`。`dc_member` 达上限后按分区补拉的测试(同文件 `:216-274`)是 moneyflow 缺失补拉最接近的现有测试模板;当前上市状态查询测试在 `:308-333`。 + +启动间隔的直接先例是 `zhixing-server/tests/unit/market_data/test_tushare.py:53-87`:用可注入 fake monotonic clock 和 wait 函数验证一个请求触发的 cooldown 会阻塞后续请求。新增测试应延续 fake clock,而不是用真实 `sleep(0.2)` 和宽松 wall-clock 断言,以避免并发测试抖动。 + +建议最少覆盖两层行为: + +- 在共享 coordinator 的单元测试中让两个线程共享一个 coordinator,用 `threading.Event` 保持首个 request 未完成,fake clock/wait 将第二个启动推进到 0.2;记录两个真实 request callback 的开始时刻并断言差值为 0.2。该形状能证明“请求可重叠,但启动槽不重叠”,也能避免单纯顺序调用掩盖线程竞态。 +- 在 `test_tushare_source.py` 增加 moneyflow 缺失回补测试:全市场响应遗漏若干 `list_status=L` 代码,按 `ts_code` 的 fake 分区返回补拉结果,断言 executor 最大 active 不超过 2、最终 rows 和 snapshots 顺序稳定、非上市状态不补拉。并发 fake 的 `calls`、`active` 和 `max_active` 必须用 `threading.Lock`;现有 `QueryClient.calls.append`(`:23-34`)只适合串行测试。 + +现有测试入口为: + +```bash +cd zhixing-server +uv run pytest tests/unit/market_data/test_tushare.py +uv run pytest tests/unit/sector_radar/test_tushare_source.py +uv run pytest tests/unit/sector_radar/test_build.py +uv run pytest tests/unit/sector_radar/test_cli.py +``` + +共享 coordinator 改动至少应运行前两个入口;若 `fetch_moneyflow_dc` 端口增加上市代码参数,还必须运行后两个入口以覆盖 `FakeRadarSource`、应用编排和 CLI 组合。完整后端门禁由 `.trellis/spec/backend/quality-guidelines.md:3-20` 和 `zhixing-server/pyproject.toml:40-49` 定义,包括 Ruff format/lint、Pyright strict 和完整 pytest。 + +### Files found + +- `zhixing-server/src/zhixing_server/shared/request_coordinator.py`:跨 bounded context 的 retry、限流识别和共享 cooldown 协调器,是全局启动槽的最小所有权位置。 +- `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py`:sector-radar Tushare adapter、source 分区与当前逐请求休眠位置。 +- `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py`:stock basics 到 moneyflow 的调用顺序、source checkpoint 稳定顺序契约。 +- `zhixing-server/src/zhixing_server/modules/sector_radar/domain/ports.py`:`fetch_moneyflow_dc` 的应用端口签名。 +- `zhixing-server/src/zhixing_server/modules/market_data/application/sync.py`:仓库现有有界 `ThreadPoolExecutor` 模式。 +- `zhixing-server/tests/unit/market_data/test_tushare.py`:fake clock/wait 的 coordinator 测试模式。 +- `zhixing-server/tests/unit/market_data/test_sync_concurrency.py`:两路 worker 上限与加锁 fake 的测试模式。 +- `zhixing-server/tests/unit/sector_radar/test_tushare_source.py`:source fake、分区响应、禁用真实 sleep 及 schema/limit 测试入口。 +- `zhixing-server/tests/unit/sector_radar/test_build.py`:应用端口 fake 和 source-group replay/retry 覆盖。 +- `zhixing-server/src/zhixing_server/bootstrap/config.py`、`zhixing-server/src/zhixing_server/modules/sector_radar/presentation/cli.py`:现有 0.2 秒配置传递链。 + +### Related specs + +- `.trellis/spec/backend/directory-structure.md`:无业务归属的小型跨上下文能力应放在 `shared/`;Tushare 请求启动协调符合这一边界。 +- `.trellis/spec/backend/market-data-sync.md`:Tushare client、共享限流和后端测试门禁的既有契约。 +- `.trellis/spec/backend/configuration-and-runtime.md`:运行时配置只能通过 `Settings` 注入;本最小方案复用既有配置,不新增环境读取。 +- `.trellis/spec/backend/quality-guidelines.md`:Pyright strict、pytest 严格模式和后端质量命令。 +- `.trellis/spec/guides/code-reuse-thinking-guide.md`:跨上下文且无业务所有权的原语才进入 `shared/`,并要求复用前先核对生命周期与错误语义。 + +## Caveats / Not Found + +- 未在仓库中找到 Tushare 1.4.29 对 `pro_api` client 的线程安全声明;不能仅凭 Python 对 `list.append` 或对象读取的实现细节宣称 SDK client 可安全并发。 +- 未找到 sector-radar 专用 `conftest.py`、pytest fixture 或现成的 request-start 间隔测试;需要沿用文件内 fake 和 coordinator fake clock 模式。 +- 当前 PRD 仍为 TBD,未定义“全局”是否跨 adapter/进程,也未定义单只股票补拉失败是整组失败还是保留部分回补。以上结论按“一个 sector-radar adapter 内两路 worker、任一补拉失败则 source group 失败”的最小解释给出。 +- 本次只读研究未运行 pytest;研究代理只写入本文件,未修改产品代码或测试代码。 diff --git a/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/task.json b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/task.json new file mode 100644 index 0000000..0ba22fc --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-31-sector-radar-listed-moneyflow-recovery/task.json @@ -0,0 +1,26 @@ +{ + "id": "sector-radar-listed-moneyflow-recovery", + "name": "sector-radar-listed-moneyflow-recovery", + "title": "资金雷达当前上市股票池与资金流缺口补拉", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P1", + "creator": "yuxuanhui", + "assignee": "yuxuanhui", + "createdAt": "2026-08-31", + "completedAt": "2026-08-31", + "branch": null, + "base_branch": "develop", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/workspace/yuxuanhui/index.md b/.trellis/workspace/yuxuanhui/index.md index 472d2c7..2a6c12c 100644 --- a/.trellis/workspace/yuxuanhui/index.md +++ b/.trellis/workspace/yuxuanhui/index.md @@ -8,8 +8,8 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 9 -- **Last Active**: 2026-08-12 +- **Total Sessions**: 11 +- **Last Active**: 2026-08-31 --- @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~243 | Active | +| `journal-1.md` | ~291 | Active | --- @@ -29,6 +29,8 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 11 | 2026-08-31 | 资金雷达当前上市股票资金流补拉 | `2ffd016` | `codex/sector-radar-listed-moneyflow-recovery` | +| 10 | 2026-08-29 | 完成板块资金雷达 Tushare 独立生产 MVP | `3789008`, `284c480`, `d9bae72`, `efc4c3d`, `8e96e64`, `23493fa`, `2fd16e5` | `codex/zijin` | | 9 | 2026-08-12 | 完成选股执行性能优化 | `8963c06` | `develop` | | 8 | 2026-08-11 | 完成市场数据同步与完整性检查 | `7ce1154`, `8f5f504` | `develop` | | 7 | 2026-08-10 | 完成选股执行状态抽屉与紧凑布局 | `17237e0` | `develop` | diff --git a/.trellis/workspace/yuxuanhui/journal-1.md b/.trellis/workspace/yuxuanhui/journal-1.md index b317794..90a3451 100644 --- a/.trellis/workspace/yuxuanhui/journal-1.md +++ b/.trellis/workspace/yuxuanhui/journal-1.md @@ -241,3 +241,51 @@ ### Status [OK] **Completed** + + +## Session 10: 完成板块资金雷达 Tushare 独立生产 MVP + +**Date**: 2026-08-29 +**Task**: 完成板块资金雷达 Tushare 独立生产 MVP +**Branch**: `codex/zijin` + +### Summary + +完成版本化独立指标、Tushare point-in-time 输入、可恢复构建 Job、last-good 查询 API 和前端排名工作台;补齐 membership_unknown、快照 identity、跨层百分位与完整验证契约。 + +### Git Commits + +| Hash | Message | +|------|---------| +| `3789008` | (see git log) | +| `284c480` | (see git log) | +| `d9bae72` | (see git log) | +| `efc4c3d` | (see git log) | +| `8e96e64` | (see git log) | +| `23493fa` | (see git log) | +| `2fd16e5` | (see git log) | + +### Status + +[OK] **Completed** + + +## Session 11: 资金雷达当前上市股票资金流补拉 + +**Date**: 2026-08-31 +**Task**: 资金雷达当前上市股票资金流补拉 +**Branch**: `codex/sector-radar-listed-moneyflow-recovery` + +### Summary + +资金雷达统一使用构建时当前 L 股票池,moneyflow_dc 达到单次上限后按候选缺口使用两路共享限流 worker 补拉;补充 checkpoint 重试兼容、长期 Tushare 股票范围规范及完整测试。 + +### Git Commits + +| Hash | Message | +|------|---------| +| `2ffd016` | (see git log) | + +### Status + +[OK] **Completed** diff --git a/CONTEXT.md b/CONTEXT.md index 185c62e..6f1ac9f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -50,3 +50,17 @@ _Avoid_: 用当前市值回填历史、把当前目标股票池当作无幸存 **股票更新失败**:一只股票未能在目标交易日同时具备日线行情和估值快照时的状态,并应保留可读的失败原因。 **数据覆盖率**:目标交易日内,有效选股股票数占当前目标股票池目标数的比例,用于判断选股结果是否具备足够完整性。 + +## 板块资金雷达 + +**板块资金雷达**:在交易日收盘后,分别对概念板块和行业板块的资金指标进行横截面比较、排名与历史变化分析;它不是盘中实时信号,也不构成交易指令。 + +**板块排名池**:同一交易日、同一板块类型中参加同一指标排名的板块集合;概念板块与行业板块属于不同排名池,成员数量随交易日变化。 + +**板块成员快照**:数据源在指定交易日给出的板块与股票成员关系。历史分析只使用对应交易日的快照,缺失时标记成员未知,不用当前成员替代。 + +**独立指标策略**:知行系统根据 Tushare 原始事实自行定义的资金指标算法;它必须有稳定名称和版本,不能称为外部站点未公开公式的复刻。 + +**雷达发布批次**:针对一个目标交易日完成的输入采集、质量校验、指标计算和排名发布;同一交易日可以因上游修订产生多个批次。 + +**最近有效发布**:最近一个通过完整性与质量门的雷达发布批次。新批次失败或只完成部分数据时,读取端继续使用该批次并明确显示数据已过期。 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 39adc0b..dc3cc47 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -36,6 +36,11 @@ services: ZHIXING_MARKET_DATA_RETRY_BACKOFF_SECONDS: ${ZHIXING_MARKET_DATA_RETRY_BACKOFF_SECONDS:-1.0} ZHIXING_MARKET_DATA_REQUEST_INTERVAL_SECONDS: ${ZHIXING_MARKET_DATA_REQUEST_INTERVAL_SECONDS:-0.2} ZHIXING_MARKET_DATA_ADVISORY_LOCK_KEY: ${ZHIXING_MARKET_DATA_ADVISORY_LOCK_KEY:-7380521} + ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD: ${ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD:-0.99} + ZHIXING_SECTOR_RADAR_MAX_RETRIES: ${ZHIXING_SECTOR_RADAR_MAX_RETRIES:-3} + ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS: ${ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS:-1.0} + ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS: ${ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS:-0.2} + ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY: ${ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY:-7380522} ZHIXING_SELECTION_MAX_WORKERS: ${ZHIXING_SELECTION_MAX_WORKERS:-4} ZHIXING_SELECTION_BATCH_SIZE: ${ZHIXING_SELECTION_BATCH_SIZE:-200} ZHIXING_SELECTION_PATTERN_SCORING_ENABLED: ${ZHIXING_SELECTION_PATTERN_SCORING_ENABLED:-true} @@ -113,6 +118,28 @@ services: - market-data:/app/data/market-data - server-venv:/app/.venv + sector-radar-build: + profiles: ["jobs"] + build: + context: ./zhixing-server + target: development + entrypoint: ["uv", "run", "sector-radar-build"] + command: [] + depends_on: + migrate: + condition: service_completed_successfully + environment: + ZHIXING_DATABASE_URL: ${ZHIXING_DATABASE_URL:-postgresql://zhixing:zhixing@postgres:5432/zhixing} + ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD: ${ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD:-0.99} + ZHIXING_SECTOR_RADAR_MAX_RETRIES: ${ZHIXING_SECTOR_RADAR_MAX_RETRIES:-3} + ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS: ${ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS:-1.0} + ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS: ${ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS:-0.2} + ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY: ${ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY:-7380522} + ZHIXING_TUSHARE_TOKEN: ${ZHIXING_TUSHARE_TOKEN:-} + volumes: + - ./zhixing-server:/app + - server-venv:/app/.venv + volumes: market-data: postgres-data: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ea8c31b..39f58a6 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -18,6 +18,11 @@ services: ZHIXING_MARKET_DATA_RETRY_BACKOFF_SECONDS: ${ZHIXING_MARKET_DATA_RETRY_BACKOFF_SECONDS:-1.0} ZHIXING_MARKET_DATA_REQUEST_INTERVAL_SECONDS: ${ZHIXING_MARKET_DATA_REQUEST_INTERVAL_SECONDS:-0.2} ZHIXING_MARKET_DATA_ADVISORY_LOCK_KEY: ${ZHIXING_MARKET_DATA_ADVISORY_LOCK_KEY:-7380521} + ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD: ${ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD:-0.99} + ZHIXING_SECTOR_RADAR_MAX_RETRIES: ${ZHIXING_SECTOR_RADAR_MAX_RETRIES:-3} + ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS: ${ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS:-1.0} + ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS: ${ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS:-0.2} + ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY: ${ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY:-7380522} ZHIXING_SELECTION_MAX_WORKERS: ${ZHIXING_SELECTION_MAX_WORKERS:-4} ZHIXING_SELECTION_BATCH_SIZE: ${ZHIXING_SELECTION_BATCH_SIZE:-200} ZHIXING_SELECTION_PATTERN_SCORING_ENABLED: ${ZHIXING_SELECTION_PATTERN_SCORING_ENABLED:-true} @@ -112,6 +117,28 @@ services: networks: - 1panel-network + sector-radar-build: + profiles: ["jobs"] + build: + context: ./zhixing-server + target: production + entrypoint: ["sector-radar-build"] + command: [] + depends_on: + migrate: + condition: service_completed_successfully + environment: + TZ: Asia/Shanghai + ZHIXING_DATABASE_URL: ${ZHIXING_DATABASE_URL:?Set ZHIXING_DATABASE_URL to the 1Panel PostgreSQL URL} + ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD: ${ZHIXING_SECTOR_RADAR_COVERAGE_THRESHOLD:-0.99} + ZHIXING_SECTOR_RADAR_MAX_RETRIES: ${ZHIXING_SECTOR_RADAR_MAX_RETRIES:-3} + ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS: ${ZHIXING_SECTOR_RADAR_RETRY_BACKOFF_SECONDS:-1.0} + ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS: ${ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS:-0.2} + ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY: ${ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY:-7380522} + ZHIXING_TUSHARE_TOKEN: ${ZHIXING_TUSHARE_TOKEN:-} + networks: + - 1panel-network + volumes: market-data: diff --git a/docs/market-data-sync.md b/docs/market-data-sync.md index 7cb162b..22ad10c 100644 --- a/docs/market-data-sync.md +++ b/docs/market-data-sync.md @@ -95,3 +95,31 @@ docker compose -f docker-compose.prod.yml --profile jobs config ``` 真实 PostgreSQL 迁移和批量 upsert 集成测试使用 `ZHIXING_TEST_DATABASE_URL` 显式开启;普通单元测试不会访问网络、Tushare 或数据库。 + +## 板块资金雷达 Job + +`sector-radar-build` 同样是外部调度器触发的一次性任务,FastAPI 不会在进程内启动定时器。它只读取 Tushare 的 `trade_cal`、`dc_index`、`dc_member`、`stock_basic`、`suspend_d`、`daily` 和 `moneyflow_dc`,保存 point-in-time 原始快照与规范化事实,再生成明确标注为“知行独立实现”的版本化指标。生产运行时不请求 OneChartLab。雷达股票范围固定为构建时 `stock_basic(list_status=L)` 返回的沪深 A 股与有效板块成员的交集;历史回填也采用构建时当前上市股票池,不还原目标日当时已经退市的证券。 + +开发环境没有 token 时可以检查命令契约,但不能执行真实构建: + +```bash +cd zhixing-server +uv run sector-radar-build --help +``` + +提供 `ZHIXING_TUSHARE_TOKEN` 并完成迁移后,可构建单日、按交易日顺序回填区间,或从一个 `partial`/`failed` publication 的来源检查点继续重试。失败 publication 会复用此前已成功保存的来源组;覆盖率不足的 partial 只刷新被标记为缺口的 `daily` 或 `moneyflow_dc`,不会全量重采: + +```bash +docker compose -f docker-compose.prod.yml --profile jobs run --rm sector-radar-build \ + --trade-date 2026-08-28 + +docker compose -f docker-compose.prod.yml --profile jobs run --rm sector-radar-build \ + --start-date 2026-08-18 --end-date 2026-08-28 + +docker compose -f docker-compose.prod.yml --profile jobs run --rm sector-radar-build \ + --retry-publication-id +``` + +`moneyflow_dc` 先按交易日拉取全市场快照;即使首批达到 6000 行,也会根据上述候选股票检查实际覆盖,并用固定两路 worker 逐只补拉缺失代码。全市场请求、补拉和 retry 在同一 adapter 内共享 `ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS`(默认 0.2 秒)的请求启动间隔;空分片或普通请求重试耗尽会保留为覆盖缺口并形成 `partial`,来源返回错误日期、错误代码、重复键或分片再次触顶则整次构建失败。该限流只在单进程 adapter 内生效,生产调度仍不得让 `market-data-sync` 与 `sector-radar-build` 重叠运行。 + +重复输入通过内容 hash 复用已有成功发布,不产生无意义修订;同一目标日由 PostgreSQL advisory lock 阻止并发构建。`success` 或 `unchanged` 返回 0,覆盖率不足的 `partial` 返回 2,输入、上游、锁或基础设施失败返回 1。`partial`/`failed` 会保留审计,但读取端只选择 `success` 作为 last-good。当前版本只提供手工和外部调度入口,不新增生产 Cron;待真实账号 capability、到达时点和首轮回填验证完成后再单独启用调度。 diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..d4ebfa5 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "tushare": { + "source": "waditu-tushare/skills", + "sourceType": "github", + "skillPath": "tushare/SKILL.md", + "computedHash": "3709023aa7edd791140d0baabaaa424f226a5adf0704e2f9a2066ce76b3606f0" + } + } +} diff --git a/zhixing-server/migrations/versions/0004_sector_radar.py b/zhixing-server/migrations/versions/0004_sector_radar.py new file mode 100644 index 0000000..7d0c3f3 --- /dev/null +++ b/zhixing-server/migrations/versions/0004_sector_radar.py @@ -0,0 +1,234 @@ +"""Create replayable independent sector radar tables.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +revision: str = "0004_sector_radar" +down_revision: str | None = "0003_market_integrity_checks" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create source, point-in-time fact, publication, and ranking tables.""" + + op.create_table( + "sector_radar_source_snapshot", + sa.Column("id", sa.String(64), primary_key=True), + sa.Column("api_name", sa.String(32), nullable=False), + sa.Column("normalized_params", JSONB, nullable=False), + sa.Column("target_trade_date", sa.Date()), + sa.Column("partition_key", sa.String(64)), + sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("payload", JSONB, nullable=False), + sa.Column("row_count", sa.Integer(), nullable=False), + sa.Column("returned_fields", JSONB, nullable=False), + sa.Column("content_sha256", sa.String(64), nullable=False), + sa.Column("row_limit", sa.Integer()), + sa.Column("limit_reached", sa.Boolean(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.CheckConstraint("row_count >= 0", name="ck_sector_radar_source_row_count"), + sa.CheckConstraint( + "row_limit IS NULL OR row_limit > 0", name="ck_sector_radar_source_limit" + ), + ) + op.create_index( + "ix_sector_radar_source_api_date", + "sector_radar_source_snapshot", + ["api_name", "target_trade_date", "observed_at"], + ) + + op.create_table( + "sector_radar_membership", + sa.Column( + "source_snapshot_id", + sa.String(64), + sa.ForeignKey("sector_radar_source_snapshot.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("trade_date", sa.Date(), nullable=False), + sa.Column("sector_type", sa.String(16), nullable=False), + sa.Column("sector_code", sa.String(16), nullable=False), + sa.Column("sector_name", sa.String(128), nullable=False), + sa.Column("stock_code", sa.String(12), nullable=False), + sa.Column("stock_name", sa.String(128), nullable=False), + sa.Column("membership_status", sa.String(32), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.PrimaryKeyConstraint("source_snapshot_id", "sector_code", "stock_code"), + sa.CheckConstraint( + "sector_type IN ('concept', 'industry')", + name="ck_sector_radar_membership_type", + ), + sa.CheckConstraint( + "membership_status = 'available'", + name="ck_sector_radar_membership_status", + ), + ) + op.create_index( + "ix_sector_radar_membership_date_sector", + "sector_radar_membership", + ["trade_date", "sector_type", "sector_code"], + ) + + op.create_table( + "sector_radar_stock_fact", + sa.Column("fact_revision", sa.String(64), nullable=False), + sa.Column("trade_date", sa.Date(), nullable=False), + sa.Column("ts_code", sa.String(12), nullable=False), + sa.Column("source_snapshot_ids", JSONB, nullable=False), + sa.Column("status", sa.String(32), nullable=False), + sa.Column("turnover_yuan", sa.Numeric(28, 6)), + sa.Column("net_amount_yuan", sa.Numeric(28, 6)), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.PrimaryKeyConstraint("fact_revision", "ts_code"), + sa.CheckConstraint( + "turnover_yuan IS NULL OR turnover_yuan >= 0", + name="ck_sector_radar_stock_turnover", + ), + ) + op.create_index( + "ix_sector_radar_stock_fact_date", + "sector_radar_stock_fact", + ["trade_date", "ts_code"], + ) + + op.create_table( + "sector_radar_publication", + sa.Column("id", sa.String(64), primary_key=True), + sa.Column("target_trade_date", sa.Date(), nullable=False), + sa.Column("status", sa.String(16), nullable=False), + sa.Column("source_version", sa.String(128), nullable=False), + sa.Column("universe_version", sa.String(128), nullable=False), + sa.Column("metric_versions", JSONB, nullable=False), + sa.Column("input_hash", sa.String(64)), + sa.Column("coverage", sa.Numeric(8, 6), nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("finished_at", sa.DateTime(timezone=True)), + sa.Column("error_summary", sa.String(500)), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.CheckConstraint( + "status IN ('running', 'success', 'partial', 'failed')", + name="ck_sector_radar_publication_status", + ), + sa.CheckConstraint( + "coverage >= 0 AND coverage <= 1", + name="ck_sector_radar_publication_coverage", + ), + sa.CheckConstraint( + "(status = 'running' AND finished_at IS NULL) OR " + "(status <> 'running' AND finished_at IS NOT NULL)", + name="ck_sector_radar_publication_finished", + ), + ) + op.create_index( + "ix_sector_radar_publication_status_date", + "sector_radar_publication", + ["status", "target_trade_date", "finished_at"], + ) + op.create_index( + "uq_sector_radar_publication_running_date", + "sector_radar_publication", + ["target_trade_date"], + unique=True, + postgresql_where=sa.text("status = 'running'"), + ) + + op.create_table( + "sector_radar_ranking", + sa.Column( + "publication_id", + sa.String(64), + sa.ForeignKey("sector_radar_publication.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("trade_date", sa.Date(), nullable=False), + sa.Column("sector_type", sa.String(16), nullable=False), + sa.Column("sector_code", sa.String(16), nullable=False), + sa.Column("sector_name", sa.String(128), nullable=False), + sa.Column("metric_kind", sa.String(16), nullable=False), + sa.Column("metric_version", sa.String(128), nullable=False), + sa.Column("implementation_kind", sa.String(16), nullable=False), + sa.Column("unit", sa.String(16), nullable=False), + sa.Column("metric_value", sa.Numeric(28, 12)), + sa.Column("quality", sa.String(32), nullable=False), + sa.Column("member_count", sa.Integer(), nullable=False), + sa.Column("valid_sample_count", sa.Integer(), nullable=False), + sa.Column("membership_coverage", sa.Numeric(8, 6), nullable=False), + sa.Column("moneyflow_coverage", sa.Numeric(8, 6), nullable=False), + sa.Column("rank_position", sa.Integer()), + sa.Column("rank_percentile", sa.Numeric(18, 12)), + sa.Column("rank_changes", JSONB, nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.PrimaryKeyConstraint( + "publication_id", + "sector_type", + "sector_code", + "metric_version", + ), + sa.CheckConstraint( + "sector_type IN ('concept', 'industry')", + name="ck_sector_radar_ranking_type", + ), + sa.CheckConstraint( + "implementation_kind = 'independent'", + name="ck_sector_radar_ranking_implementation", + ), + ) + op.create_index( + "ix_sector_radar_ranking_query", + "sector_radar_ranking", + ["publication_id", "sector_type", "metric_version", "rank_position"], + ) + + +def downgrade() -> None: + """Drop only sector radar tables in dependency-safe order.""" + + op.drop_index("ix_sector_radar_ranking_query", table_name="sector_radar_ranking") + op.drop_table("sector_radar_ranking") + op.drop_index( + "uq_sector_radar_publication_running_date", + table_name="sector_radar_publication", + ) + op.drop_index( + "ix_sector_radar_publication_status_date", + table_name="sector_radar_publication", + ) + op.drop_table("sector_radar_publication") + op.drop_index("ix_sector_radar_stock_fact_date", table_name="sector_radar_stock_fact") + op.drop_table("sector_radar_stock_fact") + op.drop_index( + "ix_sector_radar_membership_date_sector", + table_name="sector_radar_membership", + ) + op.drop_table("sector_radar_membership") + op.drop_index("ix_sector_radar_source_api_date", table_name="sector_radar_source_snapshot") + op.drop_table("sector_radar_source_snapshot") diff --git a/zhixing-server/migrations/versions/0005_radar_daily_aggregate.py b/zhixing-server/migrations/versions/0005_radar_daily_aggregate.py new file mode 100644 index 0000000..bca446a --- /dev/null +++ b/zhixing-server/migrations/versions/0005_radar_daily_aggregate.py @@ -0,0 +1,155 @@ +"""Persist publication-owned sector daily aggregates for exact metric replay.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0005_radar_daily_aggregate" +down_revision: str | None = "0004_sector_radar" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create source recovery links and exact multi-day metric inputs.""" + + op.create_table( + "sector_radar_publication_source", + sa.Column( + "publication_id", + sa.String(64), + sa.ForeignKey("sector_radar_publication.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("source_group", sa.String(32), nullable=False), + sa.Column("source_order", sa.Integer(), nullable=False), + sa.Column( + "refresh_on_retry", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + sa.Column( + "source_snapshot_id", + sa.String(64), + sa.ForeignKey("sector_radar_source_snapshot.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.PrimaryKeyConstraint("publication_id", "source_group", "source_order"), + sa.UniqueConstraint( + "publication_id", + "source_group", + "source_snapshot_id", + name="uq_sector_radar_publication_source_snapshot", + ), + sa.CheckConstraint( + "source_group IN ('calendar', 'concept_indices', 'industry_indices', " + "'members', 'stock_basics', 'suspensions', 'daily', 'moneyflow_dc')", + name="ck_sector_radar_publication_source_group", + ), + sa.CheckConstraint( + "source_order >= 0", + name="ck_sector_radar_publication_source_order", + ), + ) + op.create_index( + "ix_sector_radar_publication_source_snapshot", + "sector_radar_publication_source", + ["source_snapshot_id"], + ) + + op.create_unique_constraint( + "uq_sector_radar_publication_id_date", + "sector_radar_publication", + ["id", "target_trade_date"], + ) + op.create_foreign_key( + "fk_sector_radar_ranking_publication_date", + "sector_radar_ranking", + "sector_radar_publication", + ["publication_id", "trade_date"], + ["id", "target_trade_date"], + ondelete="CASCADE", + ) + + op.create_table( + "sector_radar_daily_aggregate", + sa.Column("publication_id", sa.String(64), nullable=False), + sa.Column("trade_date", sa.Date(), nullable=False), + sa.Column("sector_type", sa.String(16), nullable=False), + sa.Column("sector_code", sa.String(16), nullable=False), + sa.Column("sector_name", sa.String(128), nullable=False), + sa.Column("member_count", sa.Integer(), nullable=False), + sa.Column("valid_sample_count", sa.Integer(), nullable=False), + sa.Column("net_amount_yuan", sa.Numeric(28, 6)), + sa.Column("turnover_yuan", sa.Numeric(28, 6)), + sa.Column("membership_coverage", sa.Numeric(8, 6), nullable=False), + sa.Column("moneyflow_coverage", sa.Numeric(8, 6), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.PrimaryKeyConstraint("publication_id", "sector_type", "sector_code"), + sa.ForeignKeyConstraint( + ["publication_id", "trade_date"], + ["sector_radar_publication.id", "sector_radar_publication.target_trade_date"], + name="fk_sector_radar_daily_aggregate_publication_date", + ondelete="CASCADE", + ), + sa.CheckConstraint( + "sector_type IN ('concept', 'industry')", + name="ck_sector_radar_daily_aggregate_type", + ), + sa.CheckConstraint( + "member_count >= 0 AND valid_sample_count >= 0 AND valid_sample_count <= member_count", + name="ck_sector_radar_daily_aggregate_counts", + ), + sa.CheckConstraint( + "membership_coverage >= 0 AND membership_coverage <= 1 " + "AND moneyflow_coverage >= 0 AND moneyflow_coverage <= 1", + name="ck_sector_radar_daily_aggregate_coverage", + ), + sa.CheckConstraint( + "turnover_yuan IS NULL OR (turnover_yuan >= 0 AND " + "turnover_yuan NOT IN ('NaN'::numeric, 'Infinity'::numeric))", + name="ck_sector_radar_daily_aggregate_turnover", + ), + sa.CheckConstraint( + "net_amount_yuan IS NULL OR net_amount_yuan NOT IN " + "('NaN'::numeric, 'Infinity'::numeric, '-Infinity'::numeric)", + name="ck_sector_radar_daily_aggregate_net_amount", + ), + ) + op.create_index( + "ix_sector_radar_daily_aggregate_history", + "sector_radar_daily_aggregate", + ["trade_date", "sector_type", "sector_code"], + ) + + +def downgrade() -> None: + """Drop only the replay aggregate extension.""" + + op.drop_index( + "ix_sector_radar_daily_aggregate_history", + table_name="sector_radar_daily_aggregate", + ) + op.drop_table("sector_radar_daily_aggregate") + op.drop_constraint( + "fk_sector_radar_ranking_publication_date", + "sector_radar_ranking", + type_="foreignkey", + ) + op.drop_constraint( + "uq_sector_radar_publication_id_date", + "sector_radar_publication", + type_="unique", + ) + op.drop_index( + "ix_sector_radar_publication_source_snapshot", + table_name="sector_radar_publication_source", + ) + op.drop_table("sector_radar_publication_source") diff --git a/zhixing-server/migrations/versions/0006_membership_unknown.py b/zhixing-server/migrations/versions/0006_membership_unknown.py new file mode 100644 index 0000000..ab3e2ce --- /dev/null +++ b/zhixing-server/migrations/versions/0006_membership_unknown.py @@ -0,0 +1,103 @@ +"""Persist explicit unknown point-in-time sector membership snapshots.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0006_membership_unknown" +down_revision: str | None = "0005_radar_daily_aggregate" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Allow one null-stock marker for an explicitly empty sector partition.""" + + op.add_column( + "sector_radar_membership", + sa.Column("membership_key", sa.String(32), nullable=True), + ) + op.execute("UPDATE sector_radar_membership SET membership_key = stock_code") + op.drop_constraint( + "sector_radar_membership_pkey", + "sector_radar_membership", + type_="primary", + ) + op.drop_constraint( + "ck_sector_radar_membership_status", + "sector_radar_membership", + type_="check", + ) + op.alter_column( + "sector_radar_membership", + "membership_key", + existing_type=sa.String(32), + nullable=False, + ) + op.alter_column( + "sector_radar_membership", + "stock_code", + existing_type=sa.String(12), + nullable=True, + ) + op.alter_column( + "sector_radar_membership", + "stock_name", + existing_type=sa.String(128), + nullable=True, + ) + op.create_primary_key( + "sector_radar_membership_pkey", + "sector_radar_membership", + ["source_snapshot_id", "sector_code", "membership_key"], + ) + op.create_check_constraint( + "ck_sector_radar_membership_status", + "sector_radar_membership", + "(membership_status = 'available' " + "AND stock_code IS NOT NULL AND stock_name IS NOT NULL " + "AND membership_key = stock_code) OR " + "(membership_status = 'membership_unknown' " + "AND stock_code IS NULL AND stock_name IS NULL " + "AND membership_key = '__membership_unknown__')", + ) + + +def downgrade() -> None: + """Discard unknown markers and restore the available-member-only schema.""" + + op.execute("DELETE FROM sector_radar_membership WHERE membership_status = 'membership_unknown'") + op.drop_constraint( + "ck_sector_radar_membership_status", + "sector_radar_membership", + type_="check", + ) + op.drop_constraint( + "sector_radar_membership_pkey", + "sector_radar_membership", + type_="primary", + ) + op.alter_column( + "sector_radar_membership", + "stock_code", + existing_type=sa.String(12), + nullable=False, + ) + op.alter_column( + "sector_radar_membership", + "stock_name", + existing_type=sa.String(128), + nullable=False, + ) + op.drop_column("sector_radar_membership", "membership_key") + op.create_primary_key( + "sector_radar_membership_pkey", + "sector_radar_membership", + ["source_snapshot_id", "sector_code", "stock_code"], + ) + op.create_check_constraint( + "ck_sector_radar_membership_status", + "sector_radar_membership", + "membership_status = 'available'", + ) diff --git a/zhixing-server/pyproject.toml b/zhixing-server/pyproject.toml index 2901666..7bcfb65 100644 --- a/zhixing-server/pyproject.toml +++ b/zhixing-server/pyproject.toml @@ -36,6 +36,7 @@ packages = ["src/zhixing_server"] [project.scripts] market-data-sync = "zhixing_server.modules.market_data.presentation.cli:main" +sector-radar-build = "zhixing_server.modules.sector_radar.presentation.cli:main" [tool.pytest.ini_options] addopts = "-ra --strict-config --strict-markers" diff --git a/zhixing-server/src/zhixing_server/bootstrap/config.py b/zhixing-server/src/zhixing_server/bootstrap/config.py index e53fb0c..1a3ef6c 100644 --- a/zhixing-server/src/zhixing_server/bootstrap/config.py +++ b/zhixing-server/src/zhixing_server/bootstrap/config.py @@ -24,6 +24,11 @@ class Settings(BaseSettings): market_data_max_retries: int = 3 market_data_retry_backoff_seconds: float = 1.0 market_data_advisory_lock_key: int = 7_380_521 + sector_radar_coverage_threshold: Decimal = Decimal("0.99") + sector_radar_request_interval_seconds: float = 0.2 + sector_radar_max_retries: int = 3 + sector_radar_retry_backoff_seconds: float = 1.0 + sector_radar_advisory_lock_key: int = 7_380_522 selection_max_workers: int = Field(default=4, ge=1) selection_batch_size: int = Field(default=200, ge=1) selection_pattern_scoring_enabled: bool = True diff --git a/zhixing-server/src/zhixing_server/interfaces/http/router.py b/zhixing-server/src/zhixing_server/interfaces/http/router.py index 0e2971c..14fcdc8 100644 --- a/zhixing-server/src/zhixing_server/interfaces/http/router.py +++ b/zhixing-server/src/zhixing_server/interfaces/http/router.py @@ -5,6 +5,7 @@ from fastapi import APIRouter from zhixing_server.interfaces.http.system import operational_router, system_router from zhixing_server.modules.market_data.presentation.home import home_router from zhixing_server.modules.market_data.presentation.integrity import integrity_router +from zhixing_server.modules.sector_radar.presentation.http import sector_radar_router from zhixing_server.modules.selection.presentation.http import selection_router api_v1_router = APIRouter(prefix="/api/v1") @@ -16,5 +17,10 @@ api_v1_router.include_router( tags=["market-data"], ) api_v1_router.include_router(selection_router, prefix="/selection", tags=["selection"]) +api_v1_router.include_router( + sector_radar_router, + prefix="/sector-radar", + tags=["sector-radar"], +) __all__ = ["api_v1_router", "operational_router"] diff --git a/zhixing-server/src/zhixing_server/modules/market_data/infrastructure/tushare.py b/zhixing-server/src/zhixing_server/modules/market_data/infrastructure/tushare.py index 9ccf80e..d835904 100644 --- a/zhixing-server/src/zhixing_server/modules/market_data/infrastructure/tushare.py +++ b/zhixing-server/src/zhixing_server/modules/market_data/infrastructure/tushare.py @@ -2,186 +2,28 @@ from __future__ import annotations -import logging import random -import threading import time from collections.abc import Callable, Iterable, Mapping, Sequence from datetime import date from typing import cast +from zhixing_server.shared.request_coordinator import ( + DEFAULT_RATE_LIMIT_COOLDOWNS, + RequestCoordinator, + TushareRequestCoordinator, + TushareSourceError, +) + from ..domain.models import Bar, DailyBasic, Stock, SyncWindow, parse_date from ..domain.rules import filter_current_hs_a_stocks -logger = logging.getLogger(__name__) - -DEFAULT_RATE_LIMIT_COOLDOWNS = (60.0, 120.0, 180.0) -_RATE_LIMIT_MESSAGES = ( - "访问频繁", - "请稍后", - "超过频率", - "频率限制", - "too many requests", - "rate limit", - "rate_limit", - "http 429", - "status code: 429", - "429", - "http 403", - "status code: 403", - "403", -) - - -class TushareSourceError(RuntimeError): - """A vendor request failed after the configured retry budget.""" - - -class RequestCoordinator: - """Coordinate retry and shared rate-limit cooling for one token client. - - Normal requests are deliberately not serialized. Only a provider rate - limit creates a shared cooldown, so independent worker calls can proceed - concurrently during ordinary traffic. ``clock`` and ``wait_fn`` are - injectable to make long cooldown behavior deterministic in unit tests. - """ - - def __init__( - self, - *, - max_retries: int = 3, - backoff_seconds: float = 1.0, - cooldown_seconds: Sequence[float] = DEFAULT_RATE_LIMIT_COOLDOWNS, - random_fn: Callable[[], float] = random.random, - clock: Callable[[], float] = time.monotonic, - wait_fn: Callable[[float], None] = time.sleep, - sleep_fn: Callable[[float], None] | None = None, - ) -> None: - cooldowns = tuple(float(value) for value in cooldown_seconds) - if not cooldowns or any(value < 0 for value in cooldowns): - raise ValueError("cooldown_seconds must contain non-negative values") - self.max_retries = max(0, max_retries) - self.backoff_seconds = max(0.0, backoff_seconds) - self.cooldown_seconds = cooldowns - self.random_fn = random_fn - self.clock = clock - self.wait_fn = wait_fn - self.sleep_fn = sleep_fn or wait_fn - self._condition = threading.Condition() - self._cooldown_until = 0.0 - self._rate_limit_count = 0 - - @property - def cooldown_until(self) -> float: - """Return the current monotonic cooldown deadline.""" - - with self._condition: - return self._cooldown_until - - def call(self, method_name: str, request: Callable[[], object]) -> object: - """Execute one provider request with bounded, shared retry behavior.""" - - last_error: BaseException | None = None - for attempt in range(self.max_retries + 1): - self._wait_for_cooldown(method_name) - try: - result = request() - except Exception as exc: - last_error = exc - if self.is_rate_limited(exc): - cooldown = self._set_rate_limit_cooldown() - logger.warning( - "tushare_rate_limit method=%s attempt=%d max_attempts=%d " - "cooldown_seconds=%.1f", - method_name, - attempt + 1, - self.max_retries + 1, - cooldown, - ) - if attempt < self.max_retries: - continue - break - if not self._is_retryable(exc): - raise - if attempt == self.max_retries: - break - delay = self.backoff_seconds * (2**attempt) * (0.5 + self.random_fn()) - logger.warning( - "tushare_request_retry method=%s attempt=%d max_attempts=%d " - "backoff_seconds=%.1f", - method_name, - attempt + 1, - self.max_retries + 1, - delay, - ) - self.sleep_fn(delay) - else: - self._clear_rate_limit_after_success() - return result - logger.error( - "tushare_request_failed method=%s attempts=%d", - method_name, - self.max_retries + 1, - ) - raise TushareSourceError(f"Tushare request failed: {method_name}") from last_error - - def request(self, method_name: str, operation: Callable[[], object]) -> object: - """Alias for ``call`` for adapters that model requests as a port.""" - - return self.call(method_name, operation) - - def _wait_for_cooldown(self, method_name: str) -> None: - while True: - with self._condition: - delay = self._cooldown_until - self.clock() - if delay <= 0: - return - logger.info( - "tushare_rate_limit_wait method=%s wait_seconds=%.1f", - method_name, - delay, - ) - # A single injected wait hook makes fake-clock tests independent - # from wall time. After waiting, re-check because another worker - # may have extended the shared deadline. - self.wait_fn(delay) - - def _set_rate_limit_cooldown(self) -> float: - with self._condition: - self._rate_limit_count += 1 - index = min(self._rate_limit_count - 1, len(self.cooldown_seconds) - 1) - duration = self.cooldown_seconds[index] - self._cooldown_until = max(self._cooldown_until, self.clock() + duration) - self._condition.notify_all() - return duration - - def _clear_rate_limit_after_success(self) -> None: - with self._condition: - # A request that was already in flight when another worker hit a - # limit may succeed during the shared cooldown. Do not erase the - # escalation history until the cooldown has actually elapsed. - if self.clock() >= self._cooldown_until: - self._rate_limit_count = 0 - - @staticmethod - def is_rate_limited(error: BaseException) -> bool: - """Classify stable provider rate-limit signals without logging details.""" - - for attribute in ("status_code", "status", "code"): - value = getattr(error, attribute, None) - if str(value).strip() in {"403", "429"}: - return True - message = str(error).casefold() - return any(marker.casefold() in message for marker in _RATE_LIMIT_MESSAGES) - - @staticmethod - def _is_retryable(error: BaseException) -> bool: - return isinstance(error, (OSError, RuntimeError, TimeoutError)) - - -# The longer name is useful to callers that want to make the infrastructure -# boundary explicit, while the short name remains convenient in unit tests. -TushareRequestCoordinator = RequestCoordinator +__all__ = [ + "RequestCoordinator", + "TushareAdapter", + "TushareRequestCoordinator", + "TushareSourceError", +] class CoordinatedTushareClient: diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/__init__.py b/zhixing-server/src/zhixing_server/modules/sector_radar/__init__.py new file mode 100644 index 0000000..04a13bb --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/__init__.py @@ -0,0 +1 @@ +"""Independent post-close sector capital radar bounded context.""" diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/application/__init__.py b/zhixing-server/src/zhixing_server/modules/sector_radar/application/__init__.py new file mode 100644 index 0000000..698aef6 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/application/__init__.py @@ -0,0 +1 @@ +"""Application use cases for sector radar production and reads.""" diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py b/zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py new file mode 100644 index 0000000..a53a1ef --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py @@ -0,0 +1,765 @@ +"""One-shot, idempotent sector radar publication orchestration.""" + +from __future__ import annotations + +import hashlib +import json +import logging +from collections import defaultdict +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, replace +from datetime import UTC, date, datetime, time, timedelta +from decimal import Decimal +from typing import Literal +from uuid import uuid4 +from zoneinfo import ZoneInfo + +from ..domain.facts import aggregate_sector_snapshot +from ..domain.metrics import ( + AmountNetStrategy, + MetricStrategy, + RatioTurnoverStrategy, + SwingEqualThreeToTenStrategy, +) +from ..domain.models import ( + MembershipStatus, + MetricObservation, + PublicationStatus, + RadarPublication, + RankedMetric, + SectorDailyAggregate, + SectorMembershipSnapshot, + SectorType, + StockDailyFact, + StockFactStatus, +) +from ..domain.normalize import ( + is_current_listed_stock, + normalize_memberships, + normalize_stock_facts, +) +from ..domain.persistence import ( + DailyAggregateRecord, + MembershipRecord, + PublicationSourceGroup, + PublicationSourceRecord, + RankingRecord, + SectorRadarRepository, + StockFactRecord, +) +from ..domain.ports import SectorRadarSource +from ..domain.ranking import rank_metric_observations, with_rank_changes +from ..domain.source import ( + DailyRow, + MoneyflowDcRow, + SectorIndexRow, + SectorMemberRow, + SourceContractError, + SourceResult, + SourceScalar, + SourceSnapshot, + StockBasicRow, + SuspendRow, + TradeCalendarRow, +) + +BuildOutcomeStatus = Literal["success", "partial", "failed", "locked", "unchanged"] +SHANGHAI = ZoneInfo("Asia/Shanghai") +MARKET_DATA_READY_TIME = time(15, 30) +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class BuildSectorRadarCommand: + """Select one date, an inclusive range, or a failed publication retry.""" + + trade_date: date | None = None + start_date: date | None = None + end_date: date | None = None + retry_publication_id: str | None = None + + def __post_init__(self) -> None: + """Reject ambiguous build modes before any source or database call.""" + + has_range = self.start_date is not None or self.end_date is not None + modes = sum( + ( + self.trade_date is not None, + has_range, + self.retry_publication_id is not None, + ) + ) + if modes > 1: + raise ValueError("trade date, date range, and retry publication are mutually exclusive") + if has_range and (self.start_date is None or self.end_date is None): + raise ValueError("date range requires both start_date and end_date") + if ( + self.start_date is not None + and self.end_date is not None + and self.end_date < self.start_date + ): + raise ValueError("end_date must not precede start_date") + if self.retry_publication_id is not None and not self.retry_publication_id.strip(): + raise ValueError("retry_publication_id must not be empty") + + +@dataclass(frozen=True, slots=True) +class BuildDateOutcome: + """Redacted result for one target trade date.""" + + target_trade_date: date + status: BuildOutcomeStatus + publication_id: str | None + coverage: Decimal + sector_count: int + ranking_count: int + error_type: str | None = None + error_message: str | None = None + + def as_dict(self) -> dict[str, object]: + """Serialize without raw payloads, credentials, or provider exception text.""" + + return { + "target_trade_date": self.target_trade_date.isoformat(), + "status": self.status, + "publication_id": self.publication_id, + "coverage": str(self.coverage), + "sector_count": self.sector_count, + "ranking_count": self.ranking_count, + "error_type": self.error_type, + "error_message": self.error_message, + } + + +@dataclass(frozen=True, slots=True) +class BuildSummary: + """Cron-friendly aggregate result for one CLI invocation.""" + + outcomes: tuple[BuildDateOutcome, ...] + + @property + def status(self) -> str: + """Return the worst invocation state.""" + + if not self.outcomes or any(item.status in {"failed", "locked"} for item in self.outcomes): + return "failed" + if any(item.status == "partial" for item in self.outcomes): + return "partial" + if all(item.status == "unchanged" for item in self.outcomes): + return "unchanged" + return "success" + + @property + def exit_code(self) -> int: + """Return 0 for usable success, 2 for incomplete input, and 1 for failure.""" + + if self.status == "failed": + return 1 + if self.status == "partial": + return 2 + return 0 + + def as_dict(self) -> dict[str, object]: + """Serialize the invocation summary for external schedulers.""" + + return { + "status": self.status, + "exit_code": self.exit_code, + "outcomes": [item.as_dict() for item in self.outcomes], + } + + +class BuildSectorRadar: + """Hide target resolution, source replay, metrics, ranking, and publication switching.""" + + source_version = "tushare-pro-v1" + + def __init__( + self, + source: SectorRadarSource, + repository: SectorRadarRepository, + *, + coverage_threshold: Decimal = Decimal("0.99"), + today: date | None = None, + now_fn: Callable[[], datetime] = lambda: datetime.now(UTC), + strategies: Sequence[MetricStrategy] | None = None, + ) -> None: + if not Decimal(0) <= coverage_threshold <= Decimal(1): + raise ValueError("coverage_threshold must be between 0 and 1") + self.now_fn = now_fn + self.source = source + self.repository = repository + self.coverage_threshold = coverage_threshold + self.today = today or self.now_fn().astimezone(SHANGHAI).date() + self.strategies = tuple( + strategies + or ( + AmountNetStrategy(), + RatioTurnoverStrategy(), + SwingEqualThreeToTenStrategy(), + ) + ) + + def execute(self, command: BuildSectorRadarCommand | None = None) -> BuildSummary: + """Build each selected trade date sequentially for deterministic history.""" + + command = command or BuildSectorRadarCommand() + try: + targets = self._resolve_targets(command) + except Exception as exc: + target = command.trade_date or command.start_date or self.today + error_type, message = self._safe_failure(exc) + return BuildSummary( + ( + BuildDateOutcome( + target, + "failed", + None, + Decimal(0), + 0, + 0, + error_type, + message, + ), + ) + ) + return BuildSummary(tuple(self._build_target(target) for target in targets)) + + def _resolve_targets(self, command: BuildSectorRadarCommand) -> tuple[_BuildTarget, ...]: + if command.retry_publication_id is not None: + publication = self.repository.get_publication(command.retry_publication_id) + if publication is None: + raise ValueError("retry publication does not exist") + if publication.status not in {PublicationStatus.PARTIAL, PublicationStatus.FAILED}: + raise ValueError("only partial or failed publications can be retried") + return (_BuildTarget(publication.target_trade_date, publication.publication_id),) + + if command.trade_date is not None: + start = end = command.trade_date + elif command.start_date is not None and command.end_date is not None: + start, end = command.start_date, command.end_date + else: + end = self._default_calendar_end() + start = end - timedelta(days=14) + calendar = self.source.fetch_trade_calendar(start, end) + targets = tuple(sorted({row.cal_date for row in calendar.rows if row.is_open})) + if command.trade_date is not None and command.trade_date not in targets: + raise ValueError("target date is not an open trading day") + if not targets: + raise ValueError("no open trading date found") + selected = targets if command.start_date is not None else (targets[-1],) + return tuple(_BuildTarget(target) for target in selected) + + def _default_calendar_end(self) -> date: + """Exclude today's session until Tushare closing facts are expected to be ready.""" + + local_now = self.now_fn().astimezone(SHANGHAI) + if self.today == local_now.date() and local_now.time() < MARKET_DATA_READY_TIME: + return self.today - timedelta(days=1) + return self.today + + def _build_target(self, target: _BuildTarget) -> BuildDateOutcome: + try: + with self.repository.advisory_lock(target.trade_date) as acquired: + if not acquired: + return BuildDateOutcome( + target.trade_date, + "locked", + None, + Decimal(0), + 0, + 0, + "build_locked", + "another sector radar build is running for this date", + ) + return self._build_locked(target) + except Exception as exc: + error_type, message = self._safe_failure(exc) + return BuildDateOutcome( + target.trade_date, + "failed", + None, + Decimal(0), + 0, + 0, + error_type, + message, + ) + + def _build_locked(self, target: _BuildTarget) -> BuildDateOutcome: + started_at = self.now_fn() + publication: RadarPublication | None = None + publication_created = False + try: + self.repository.recover_running_publications( + target.trade_date, + finished_at=started_at, + ) + publication_id = self._running_id(target.trade_date) + publication = RadarPublication( + publication_id=publication_id, + target_trade_date=target.trade_date, + status=PublicationStatus.RUNNING, + source_version=self.source_version, + universe_version="pending", + metric_versions=tuple(strategy.metric_version for strategy in self.strategies), + input_hash=None, + coverage=Decimal(0), + started_at=started_at, + ) + self.repository.create_publication(publication) + publication_created = True + reusable = self._reusable_sources(target.retry_publication_id) + collected = self._collect(target.trade_date, publication_id, reusable) + input_hash = self._input_hash(collected.snapshots) + existing = self.repository.find_reusable_publication(target.trade_date, input_hash) + if existing is not None: + self.repository.discard_running_publication(publication_id) + publication_created = False + is_success = existing.status is PublicationStatus.SUCCESS + return BuildDateOutcome( + target.trade_date, + "unchanged" if is_success else "partial", + existing.publication_id, + existing.coverage, + 0, + 0, + None if is_success else "duplicate_input", + None + if is_success + else "input is unchanged from an existing partial publication", + ) + + publication = replace( + publication, + universe_version=self._universe_version(collected.membership_snapshots), + input_hash=input_hash, + ) + aggregates = self._aggregate(collected) + rankings = self._rank(target.trade_date, aggregates) + coverage = self._coverage(collected.stock_facts) + membership_complete = all( + item.status is MembershipStatus.AVAILABLE for item in collected.memberships + ) + terminal = ( + PublicationStatus.SUCCESS + if membership_complete and coverage >= self.coverage_threshold + else PublicationStatus.PARTIAL + ) + finished = RadarPublication( + publication_id=publication.publication_id, + target_trade_date=target.trade_date, + status=terminal, + source_version=publication.source_version, + universe_version=publication.universe_version, + metric_versions=publication.metric_versions, + input_hash=input_hash, + coverage=coverage, + started_at=started_at, + finished_at=self.now_fn(), + error_summary=( + None + if terminal is PublicationStatus.SUCCESS + else ( + "membership_unknown" + if not membership_complete + else "coverage_below_threshold" + ) + ), + ) + self.repository.finalize_publication( + finished, + memberships=collected.memberships, + stock_facts=collected.stock_facts, + daily_aggregates=( + DailyAggregateRecord(publication_id, aggregate) for aggregate in aggregates + ), + rankings=(RankingRecord(publication_id, ranking) for ranking in rankings), + retry_source_groups=( + self._retry_source_groups( + collected.memberships, + collected.stock_facts, + ) + if terminal is PublicationStatus.PARTIAL + else () + ), + ) + return BuildDateOutcome( + target.trade_date, + "success" if terminal is PublicationStatus.SUCCESS else "partial", + publication_id, + coverage, + len(aggregates), + len(rankings), + ) + except Exception as exc: + error_type, message = self._safe_failure(exc) + failed_id = ( + publication.publication_id + if publication is not None + else self._failure_id(target.trade_date) + ) + if isinstance(exc, SourceContractError) and exc.claim_diagnostic(): + logger.error( + "sector_radar_build_source_contract_failed " + "target_trade_date=%s publication_id=%s validation=%s", + target.trade_date.isoformat(), + failed_id, + exc.operator_message, + ) + if publication is not None and publication_created: + self.repository.finish_publication( + RadarPublication( + publication_id=failed_id, + target_trade_date=target.trade_date, + status=PublicationStatus.FAILED, + source_version=publication.source_version, + universe_version=publication.universe_version, + metric_versions=publication.metric_versions, + input_hash=publication.input_hash, + coverage=Decimal(0), + started_at=started_at, + finished_at=self.now_fn(), + error_summary=f"{error_type}:{message}", + ) + ) + return BuildDateOutcome( + target.trade_date, + "failed", + failed_id, + Decimal(0), + 0, + 0, + error_type, + message, + ) + + def _reusable_sources( + self, publication_id: str | None + ) -> dict[PublicationSourceGroup, tuple[SourceSnapshot, ...]]: + """Load successful checkpoints while forcing incomplete coverage facts to refresh.""" + + if publication_id is None: + return {} + publication = self.repository.get_publication(publication_id) + if publication is None: + raise ValueError("retry publication does not exist") + grouped: defaultdict[PublicationSourceGroup, list[PublicationSourceRecord]] = defaultdict( + list + ) + for record in self.repository.load_publication_sources(publication_id): + if not record.refresh_on_retry: + grouped[record.source_group].append(record) + result: dict[PublicationSourceGroup, tuple[SourceSnapshot, ...]] = {} + for group, records in grouped.items(): + ordered = sorted(records, key=lambda item: item.source_order) + if [item.source_order for item in ordered] != list(range(len(ordered))): + raise ValueError("publication source checkpoint order is incomplete") + result[group] = tuple(item.snapshot for item in ordered) + return result + + def _fetch_group[T]( + self, + publication_id: str, + source_group: PublicationSourceGroup, + reusable: Mapping[PublicationSourceGroup, tuple[SourceSnapshot, ...]], + fetch: Callable[[], SourceResult[T]], + parser: Callable[[Mapping[str, SourceScalar]], T], + reuse_if: Callable[[SourceResult[T]], bool] | None = None, + ) -> SourceResult[T]: + """Replay a compatible completed group or fetch and checkpoint it immediately.""" + + try: + snapshots = reusable.get(source_group) + if snapshots is None: + result = fetch() + else: + replayed = SourceResult( + snapshots=snapshots, + rows=tuple(parser(row) for snapshot in snapshots for row in snapshot.rows), + ) + result = replayed if reuse_if is None or reuse_if(replayed) else fetch() + except SourceContractError as exc: + if exc.claim_diagnostic(): + logger.error( + "sector_radar_source_group_contract_failed " + "publication_id=%s source_group=%s validation=%s", + publication_id, + source_group.value, + exc.operator_message, + ) + raise + if not result.snapshots: + raise ValueError("source group must include at least one replay snapshot") + snapshot_ids = [snapshot.snapshot_id for snapshot in result.snapshots] + if len(snapshot_ids) != len(set(snapshot_ids)): + raise ValueError("source group contains duplicate snapshots") + self.repository.save_source_snapshots(result.snapshots) + self.repository.save_publication_sources( + PublicationSourceRecord(publication_id, source_group, order, snapshot) + for order, snapshot in enumerate(result.snapshots) + ) + return result + + def _collect( + self, + target: date, + publication_id: str, + reusable: Mapping[PublicationSourceGroup, tuple[SourceSnapshot, ...]], + ) -> _CollectedInputs: + calendar = self._fetch_group( + publication_id, + PublicationSourceGroup.CALENDAR, + reusable, + lambda: self.source.fetch_trade_calendar(target, target), + TradeCalendarRow.from_mapping, + ) + if target not in {row.cal_date for row in calendar.rows if row.is_open}: + raise ValueError("target date is not an open trading day") + concepts = self._fetch_group( + publication_id, + PublicationSourceGroup.CONCEPT_INDICES, + reusable, + lambda: self.source.fetch_sector_indices(target, SectorType.CONCEPT), + lambda row: SectorIndexRow.from_mapping(row, SectorType.CONCEPT), + ) + industries = self._fetch_group( + publication_id, + PublicationSourceGroup.INDUSTRY_INDICES, + reusable, + lambda: self.source.fetch_sector_indices(target, SectorType.INDUSTRY), + lambda row: SectorIndexRow.from_mapping(row, SectorType.INDUSTRY), + ) + indices = concepts.rows + industries.rows + sector_codes = tuple(row.sector_code for row in indices) + members = self._fetch_group( + publication_id, + PublicationSourceGroup.MEMBERS, + reusable, + lambda: self.source.fetch_sector_members(target, sector_codes), + SectorMemberRow.from_mapping, + ) + stock_basics = self._fetch_group( + publication_id, + PublicationSourceGroup.STOCK_BASICS, + reusable, + self.source.fetch_stock_basics, + StockBasicRow.from_mapping, + ) + memberships = normalize_memberships(indices, members) + member_codes = tuple( + sorted( + { + item.stock_code + for item in memberships + if item.status is MembershipStatus.AVAILABLE and item.stock_code is not None + } + ) + ) + current_listed_codes = { + row.ts_code for row in stock_basics.rows if is_current_listed_stock(row, target) + } + moneyflow_candidate_codes = tuple( + code for code in member_codes if code in current_listed_codes + ) + suspensions = self._fetch_group( + publication_id, + PublicationSourceGroup.SUSPENSIONS, + reusable, + lambda: self.source.fetch_suspensions(target), + SuspendRow.from_mapping, + ) + daily = self._fetch_group( + publication_id, + PublicationSourceGroup.DAILY, + reusable, + lambda: self.source.fetch_daily(target), + DailyRow.from_mapping, + ) + moneyflow = self._fetch_group( + publication_id, + PublicationSourceGroup.MONEYFLOW_DC, + reusable, + lambda: self.source.fetch_moneyflow_dc(target, moneyflow_candidate_codes), + MoneyflowDcRow.from_mapping, + reuse_if=lambda result: set(moneyflow_candidate_codes).issubset( + {row.ts_code for row in result.rows} + ), + ) + + stock_facts = normalize_stock_facts( + target_trade_date=target, + candidate_codes=member_codes, + stock_basics=stock_basics, + suspensions=suspensions, + daily=daily, + moneyflow=moneyflow, + ) + snapshots = ( + calendar.snapshots + + concepts.snapshots + + industries.snapshots + + members.snapshots + + stock_basics.snapshots + + suspensions.snapshots + + daily.snapshots + + moneyflow.snapshots + ) + return _CollectedInputs( + target_trade_date=target, + snapshots=snapshots, + membership_snapshots=members.snapshots, + memberships=memberships, + stock_facts=stock_facts, + ) + + def _aggregate(self, inputs: _CollectedInputs) -> tuple[SectorDailyAggregate, ...]: + facts = tuple( + StockDailyFact( + trade_date=item.trade_date, + ts_code=item.ts_code, + status=item.status, + turnover_yuan=item.turnover_yuan, + net_amount_yuan=item.net_amount_yuan, + ) + for item in inputs.stock_facts + ) + grouped: defaultdict[tuple[SectorType, str, str], list[str]] = defaultdict(list) + unknown: set[tuple[SectorType, str, str]] = set() + for member in inputs.memberships: + key = (member.sector_type, member.sector_code, member.sector_name) + if member.status is MembershipStatus.UNKNOWN: + unknown.add(key) + continue + if member.stock_code is None: + raise ValueError("available membership requires a stock code") + grouped[key].append(member.stock_code) + if unknown & set(grouped): + raise ValueError("sector cannot have both available and unknown membership") + sector_keys = set(grouped) | unknown + aggregates = tuple( + aggregate_sector_snapshot( + SectorMembershipSnapshot( + trade_date=inputs.target_trade_date, + sector_type=sector_type, + sector_code=sector_code, + sector_name=sector_name, + member_codes=tuple(sorted(grouped.get(key, ()))), + status=( + MembershipStatus.UNKNOWN if key in unknown else MembershipStatus.AVAILABLE + ), + source_version=self._universe_version(inputs.membership_snapshots), + ), + facts, + ) + for key in sorted(sector_keys, key=lambda item: (str(item[0]), item[1])) + for sector_type, sector_code, sector_name in (key,) + ) + if not aggregates: + raise ValueError("sector universe produced no aggregates") + return aggregates + + def _rank( + self, target: date, aggregates: Sequence[SectorDailyAggregate] + ) -> tuple[RankedMetric, ...]: + history = tuple(self.repository.load_daily_aggregate_history(target, limit_dates=9)) + observations: list[MetricObservation] = [] + for current in aggregates: + sector_history = tuple( + item + for item in history + if (item.sector_type, item.sector_code) + == (current.sector_type, current.sector_code) + ) + (current,) + observations.extend( + strategy.evaluate(sector_history, target) for strategy in self.strategies + ) + current_rankings = rank_metric_observations(observations) + previous = self.repository.load_previous_rankings(target, limit_dates=5) + history_by_days = {days: rankings for days, (_, rankings) in enumerate(previous, start=1)} + return with_rank_changes(current_rankings, history_by_days) + + @staticmethod + def _coverage(stock_facts: Sequence[StockFactRecord]) -> Decimal: + expected_statuses = { + "available", + "missing", + "missing_daily", + "missing_moneyflow", + "null_daily_amount", + "null_moneyflow", + "low_liquidity", + } + expected = sum(item.status.value in expected_statuses for item in stock_facts) + covered_statuses = {StockFactStatus.AVAILABLE, StockFactStatus.LOW_LIQUIDITY} + covered = sum(item.status in covered_statuses for item in stock_facts) + return Decimal(covered) / Decimal(expected) if expected else Decimal(0) + + @staticmethod + def _retry_source_groups( + memberships: Sequence[MembershipRecord], + stock_facts: Sequence[StockFactRecord], + ) -> tuple[PublicationSourceGroup, ...]: + groups: list[PublicationSourceGroup] = [] + if any(item.status is MembershipStatus.UNKNOWN for item in memberships): + groups.append(PublicationSourceGroup.MEMBERS) + statuses = {item.status for item in stock_facts} + if statuses & { + StockFactStatus.MISSING, + StockFactStatus.MISSING_DAILY, + StockFactStatus.NULL_DAILY_AMOUNT, + }: + groups.append(PublicationSourceGroup.DAILY) + if statuses & { + StockFactStatus.MISSING, + StockFactStatus.MISSING_MONEYFLOW, + StockFactStatus.NULL_MONEYFLOW, + }: + groups.append(PublicationSourceGroup.MONEYFLOW_DC) + return tuple(groups) + + def _input_hash(self, snapshots: Sequence[SourceSnapshot]) -> str: + payload = json.dumps( + { + "snapshot_ids": sorted(snapshot.snapshot_id for snapshot in snapshots), + "metric_versions": sorted(strategy.metric_version for strategy in self.strategies), + "normalizer": "zhixing_stock_fact_v1", + }, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(payload.encode()).hexdigest() + + @staticmethod + def _universe_version(snapshots: Sequence[SourceSnapshot]) -> str: + payload = "\n".join(sorted(snapshot.snapshot_id for snapshot in snapshots)) + return f"eastmoney-dc-{hashlib.sha256(payload.encode()).hexdigest()[:32]}" + + @staticmethod + def _failure_id(target: date) -> str: + return f"radar-{target:%Y%m%d}-failed-{uuid4().hex[:24]}" + + @staticmethod + def _running_id(target: date) -> str: + return f"radar-{target:%Y%m%d}-running-{uuid4().hex[:23]}" + + @staticmethod + def _safe_failure(error: BaseException) -> tuple[str, str]: + if isinstance(error, ValueError): + return type(error).__name__, "input or source contract validation failed" + return type(error).__name__, "sector radar build failed" + + +@dataclass(frozen=True, slots=True) +class _CollectedInputs: + target_trade_date: date + snapshots: tuple[SourceSnapshot, ...] + membership_snapshots: tuple[SourceSnapshot, ...] + memberships: tuple[MembershipRecord, ...] + stock_facts: tuple[StockFactRecord, ...] + + +@dataclass(frozen=True, slots=True) +class _BuildTarget: + trade_date: date + retry_publication_id: str | None = None diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/application/read.py b/zhixing-server/src/zhixing_server/modules/sector_radar/application/read.py new file mode 100644 index 0000000..0313502 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/application/read.py @@ -0,0 +1,223 @@ +"""Stable read model for persisted sector radar publications.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from enum import StrEnum +from typing import Literal + +from ..domain.metrics import ( + AmountNetStrategy, + RatioTurnoverStrategy, + SwingEqualThreeToTenStrategy, +) +from ..domain.models import ( + MetricKind, + MetricUnit, + RadarPublication, + RankedMetric, + RankSide, + SectorType, +) +from ..domain.persistence import SectorRadarRepository +from ..domain.ranking import select_percentile_side, select_rank_change_side + +ReadStatus = Literal["success", "no_data"] + + +class RadarView(StrEnum): + """Supported ranking projections at the HTTP boundary.""" + + AMOUNT = "amount" + RATIO = "ratio" + SWING = "swing" + RANK_CHANGE = "rank_change" + + +@dataclass(frozen=True, slots=True) +class RadarMetricDefinition: + """Public definition of one explicitly independent metric implementation.""" + + metric_kind: MetricKind + metric_version: str + label: str + unit: MetricUnit + implementation_kind: Literal["independent"] = "independent" + disclaimer: str = "知行独立实现,非 OneChartLab 原站公式" + + +@dataclass(frozen=True, slots=True) +class RadarQuery: + """Validated application query for one ranking page.""" + + trade_date: date | None = None + sector_type: SectorType = SectorType.CONCEPT + view: RadarView = RadarView.AMOUNT + rank_change_metric: MetricKind = MetricKind.AMOUNT + rank_change_days: int = 1 + side: RankSide = RankSide.ALL + search: str | None = None + page: int = 1 + page_size: int = 20 + + def __post_init__(self) -> None: + """Reject invalid pagination and rank-history offsets outside HTTP usage.""" + + if not 1 <= self.rank_change_days <= 5: + raise ValueError("rank_change_days must be between 1 and 5") + if self.page < 1: + raise ValueError("page must be positive") + if not 1 <= self.page_size <= 100: + raise ValueError("page_size must be between 1 and 100") + if self.search is not None and len(self.search) > 100: + raise ValueError("search must not exceed 100 characters") + + +@dataclass(frozen=True, slots=True) +class RadarDateIndex: + """Available successful dates plus the newest attempt and strict last-good.""" + + available_dates: tuple[date, ...] + current_attempt: RadarPublication | None + last_good: RadarPublication | None + + @property + def status(self) -> ReadStatus: + """Return no_data until at least one successful publication exists.""" + + return "success" if self.last_good is not None else "no_data" + + +@dataclass(frozen=True, slots=True) +class RankingPage: + """One filtered page without losing publication or metric provenance.""" + + status: ReadStatus + query: RadarQuery + publication: RadarPublication | None + definition: RadarMetricDefinition + rows: tuple[RankedMetric, ...] + total: int + + +_METRIC_DEFINITIONS = { + MetricKind.AMOUNT: RadarMetricDefinition( + metric_kind=MetricKind.AMOUNT, + metric_version=AmountNetStrategy.metric_version, + label="主力净流入(知行独立实现)", + unit=MetricUnit.CNY_100M, + ), + MetricKind.RATIO: RadarMetricDefinition( + metric_kind=MetricKind.RATIO, + metric_version=RatioTurnoverStrategy.metric_version, + label="主力净流入/成交额(知行独立实现)", + unit=MetricUnit.RATIO, + ), + MetricKind.SWING: RadarMetricDefinition( + metric_kind=MetricKind.SWING, + metric_version=SwingEqualThreeToTenStrategy.metric_version, + label="3—10 日等权资金率(知行独立实现)", + unit=MetricUnit.RATIO, + ), +} + + +class ReadSectorRadar: + """Hide last-good selection, ranking filters, search, and pagination.""" + + def __init__(self, repository: SectorRadarRepository) -> None: + self.repository = repository + + def list_dates(self) -> RadarDateIndex: + """Return successful dates without promoting partial or failed attempts.""" + + return RadarDateIndex( + available_dates=tuple(self.repository.list_successful_dates()), + current_attempt=self.repository.get_latest_publication(), + last_good=self.repository.get_last_good_publication(), + ) + + def query(self, query: RadarQuery) -> RankingPage: + """Return one deterministic page for ordinary or rank-change views.""" + + metric_kind = ( + query.rank_change_metric + if query.view is RadarView.RANK_CHANGE + else MetricKind(query.view.value) + ) + definition = _METRIC_DEFINITIONS[metric_kind] + publication = ( + self.repository.get_successful_publication(query.trade_date) + if query.trade_date is not None + else self.repository.get_last_good_publication() + ) + if publication is None: + return RankingPage("no_data", query, None, definition, (), 0) + + metric_rows = tuple( + row + for row in self.repository.load_rankings(publication.publication_id) + if row.observation.sector_type is query.sector_type + and row.observation.metric_kind is metric_kind + and row.observation.metric_version == definition.metric_version + ) + if query.view is RadarView.RANK_CHANGE: + if query.side is RankSide.ALL: + selected = tuple( + sorted( + metric_rows, + key=lambda row: ( + row.rank_change(query.rank_change_days) is None, + -(row.rank_change(query.rank_change_days) or 0), + row.observation.sector_code, + ), + ) + ) + else: + selected = select_rank_change_side( + metric_rows, + days=query.rank_change_days, + side=query.side, + ) + else: + selected = select_percentile_side(metric_rows, query.side) + if query.side is not RankSide.BOTTOM: + selected = tuple( + sorted( + selected, + key=lambda row: ( + row.rank_position is None, + row.rank_position or 0, + row.observation.sector_code, + ), + ) + ) + + search = query.search.strip().casefold() if query.search else "" + searched = tuple( + row + for row in selected + if not search + or search in row.observation.sector_code.casefold() + or search in row.observation.sector_name.casefold() + ) + start = (query.page - 1) * query.page_size + return RankingPage( + status="success", + query=query, + publication=publication, + definition=definition, + rows=searched[start : start + query.page_size], + total=len(searched), + ) + + +__all__ = [ + "RadarDateIndex", + "RadarMetricDefinition", + "RadarQuery", + "RadarView", + "RankingPage", + "ReadSectorRadar", +] diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/__init__.py b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/__init__.py new file mode 100644 index 0000000..2a78e0a --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/__init__.py @@ -0,0 +1 @@ +"""Storage-independent sector radar models and calculation rules.""" diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/facts.py b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/facts.py new file mode 100644 index 0000000..0b1f300 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/facts.py @@ -0,0 +1,104 @@ +"""Point-in-time stock fact aggregation for sector radar metrics.""" + +from __future__ import annotations + +from collections.abc import Iterable +from decimal import Decimal + +from .models import ( + MembershipStatus, + SectorDailyAggregate, + SectorMembershipSnapshot, + StockDailyFact, + StockFactStatus, +) + + +def aggregate_sector_snapshot( + snapshot: SectorMembershipSnapshot, + stock_facts: Iterable[StockDailyFact], +) -> SectorDailyAggregate: + """Aggregate only the members recorded in one dated membership snapshot. + + Unknown membership returns an unavailable aggregate and deliberately + ignores any supplied stock facts. For known membership, suspended and + lifecycle-invalid members are excluded from the expected moneyflow + denominator; missing facts remain expected and reduce coverage. + + Args: + snapshot: Dated sector identity and point-in-time member codes. + stock_facts: Normalized facts that may contain records outside the sector. + + Returns: + A yuan-denominated aggregate with explicit membership and moneyflow coverage. + + Raises: + ValueError: If member facts have a date mismatch or duplicate stock code. + """ + + if snapshot.status is MembershipStatus.UNKNOWN: + return SectorDailyAggregate( + trade_date=snapshot.trade_date, + sector_type=snapshot.sector_type, + sector_code=snapshot.sector_code, + sector_name=snapshot.sector_name, + member_count=0, + valid_sample_count=0, + net_amount_yuan=None, + turnover_yuan=None, + membership_coverage=Decimal(0), + moneyflow_coverage=Decimal(0), + ) + + members = set(snapshot.member_codes) + facts_by_code: dict[str, StockDailyFact] = {} + for fact in stock_facts: + if fact.ts_code not in members: + continue + if fact.trade_date != snapshot.trade_date: + raise ValueError("member stock facts must match the snapshot trade_date") + if fact.ts_code in facts_by_code: + raise ValueError("member stock facts must have unique ts_code values") + facts_by_code[fact.ts_code] = fact + + net_amount_total = Decimal(0) + turnover_total = Decimal(0) + valid_count = 0 + expected_count = 0 + for member_code in snapshot.member_codes: + fact = facts_by_code.get(member_code) + if fact is None or fact.status in { + StockFactStatus.MISSING, + StockFactStatus.MISSING_DAILY, + StockFactStatus.MISSING_MONEYFLOW, + StockFactStatus.NULL_DAILY_AMOUNT, + StockFactStatus.NULL_MONEYFLOW, + StockFactStatus.LOW_LIQUIDITY, + }: + expected_count += 1 + elif fact.status is StockFactStatus.AVAILABLE: + expected_count += 1 + net_amount = fact.net_amount_yuan + turnover = fact.turnover_yuan + if net_amount is None or turnover is None: + raise ValueError("available stock facts require both amounts") + net_amount_total += net_amount + turnover_total += turnover + valid_count += 1 + + moneyflow_coverage = ( + Decimal(valid_count) / Decimal(expected_count) if expected_count else Decimal(1) + ) + + return SectorDailyAggregate( + trade_date=snapshot.trade_date, + sector_type=snapshot.sector_type, + sector_code=snapshot.sector_code, + sector_name=snapshot.sector_name, + member_count=len(snapshot.member_codes), + valid_sample_count=valid_count, + net_amount_yuan=net_amount_total if valid_count else None, + turnover_yuan=turnover_total if valid_count else None, + membership_coverage=Decimal(1), + moneyflow_coverage=moneyflow_coverage, + ) diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/metrics.py b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/metrics.py new file mode 100644 index 0000000..c665322 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/metrics.py @@ -0,0 +1,204 @@ +"""Transparent, versioned metric strategies for the independent radar.""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import date +from decimal import Decimal +from typing import Protocol + +from .models import ( + MetricKind, + MetricObservation, + MetricQuality, + MetricUnit, + SectorDailyAggregate, +) + + +class MetricStrategy(Protocol): + """Calculate one named metric from a sector's point-in-time daily history.""" + + metric_kind: MetricKind + metric_version: str + unit: MetricUnit + + def evaluate( + self, + history: Iterable[SectorDailyAggregate], + target_trade_date: date, + ) -> MetricObservation: + """Return the target date observation without inventing missing inputs.""" + + ... + + +def _target_aggregate( + history: Iterable[SectorDailyAggregate], target_trade_date: date +) -> SectorDailyAggregate: + matches = tuple(row for row in history if row.trade_date == target_trade_date) + if len(matches) != 1: + raise ValueError("history must contain exactly one target-date aggregate") + return matches[0] + + +def _quality(row: SectorDailyAggregate) -> MetricQuality: + if row.valid_sample_count < 5 or row.membership_coverage < 1 or row.moneyflow_coverage < 1: + return MetricQuality.AVAILABLE_LIMITED_SAMPLE + return MetricQuality.AVAILABLE + + +def _observation( + row: SectorDailyAggregate, + *, + metric_kind: MetricKind, + metric_version: str, + unit: MetricUnit, + value: Decimal | None, + quality: MetricQuality | None = None, +) -> MetricObservation: + return MetricObservation( + trade_date=row.trade_date, + sector_type=row.sector_type, + sector_code=row.sector_code, + sector_name=row.sector_name, + metric_kind=metric_kind, + metric_version=metric_version, + implementation_kind="independent", + unit=unit, + value=value, + quality=( + MetricQuality.UNAVAILABLE + if value is None + else quality + if quality is not None + else _quality(row) + ), + member_count=row.member_count, + valid_sample_count=row.valid_sample_count, + membership_coverage=row.membership_coverage, + moneyflow_coverage=row.moneyflow_coverage, + ) + + +class AmountNetStrategy: + """Aggregate main net amount and expose it in hundred-million yuan.""" + + metric_kind = MetricKind.AMOUNT + metric_version = "zhixing_amount_net_bn_v1" + unit = MetricUnit.CNY_100M + + def evaluate( + self, + history: Iterable[SectorDailyAggregate], + target_trade_date: date, + ) -> MetricObservation: + """Return the target net amount; missing moneyflow remains unavailable.""" + + row = _target_aggregate(history, target_trade_date) + value = None if row.net_amount_yuan is None else row.net_amount_yuan / Decimal("100000000") + return _observation( + row, + metric_kind=self.metric_kind, + metric_version=self.metric_version, + unit=self.unit, + value=value, + ) + + +class RatioTurnoverStrategy: + """Divide aggregated main net amount by aggregated daily turnover.""" + + metric_kind = MetricKind.RATIO + metric_version = "zhixing_ratio_turnover_v1" + unit = MetricUnit.RATIO + + def evaluate( + self, + history: Iterable[SectorDailyAggregate], + target_trade_date: date, + ) -> MetricObservation: + """Return a ratio only when numerator and positive denominator exist.""" + + row = _target_aggregate(history, target_trade_date) + value = None + if ( + row.net_amount_yuan is not None + and row.turnover_yuan is not None + and row.turnover_yuan > 0 + ): + value = row.net_amount_yuan / row.turnover_yuan + return _observation( + row, + metric_kind=self.metric_kind, + metric_version=self.metric_version, + unit=self.unit, + value=value, + ) + + +class SwingEqualThreeToTenStrategy: + """Average transparent 3-to-10-day aggregate turnover ratios equally. + + This strategy is deliberately named as a Zhixing implementation. It does + not reproduce or imply OneChartLab's unpublished window weights or score. + """ + + metric_kind = MetricKind.SWING + metric_version = "zhixing_swing_equal_3_10_v1" + unit = MetricUnit.RATIO + + def evaluate( + self, + history: Iterable[SectorDailyAggregate], + target_trade_date: date, + ) -> MetricObservation: + """Calculate eight complete trading-day windows ending at the target.""" + + rows = tuple(sorted(history, key=lambda row: row.trade_date)) + target = _target_aggregate(rows, target_trade_date) + eligible = tuple(row for row in rows if row.trade_date <= target_trade_date) + if any( + (row.sector_type, row.sector_code) != (target.sector_type, target.sector_code) + for row in eligible + ): + raise ValueError("history must contain exactly one sector identity") + if len({row.trade_date for row in eligible}) != len(eligible): + raise ValueError("history must not contain duplicate trade dates") + + value: Decimal | None = None + quality: MetricQuality | None = None + if len(eligible) >= 10: + latest = eligible[-10:] + window_ratios: list[Decimal] = [] + for window_size in range(3, 11): + window = latest[-window_size:] + net_amount = Decimal(0) + turnover = Decimal(0) + for row in window: + if row.net_amount_yuan is None or row.turnover_yuan is None: + break + net_amount += row.net_amount_yuan + turnover += row.turnover_yuan + else: + if turnover <= 0: + break + window_ratios.append(net_amount / turnover) + continue + break + if len(window_ratios) == 8: + value = sum(window_ratios, start=Decimal(0)) / Decimal(8) + quality = ( + MetricQuality.AVAILABLE_LIMITED_SAMPLE + if any(_quality(row) is not MetricQuality.AVAILABLE for row in latest) + else MetricQuality.AVAILABLE + ) + + return _observation( + target, + metric_kind=self.metric_kind, + metric_version=self.metric_version, + unit=self.unit, + value=value, + quality=quality, + ) diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/models.py b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/models.py new file mode 100644 index 0000000..2944604 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/models.py @@ -0,0 +1,319 @@ +"""Stable domain values for independently produced sector radar metrics.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal +from enum import StrEnum +from typing import Literal + + +class SectorType(StrEnum): + """Independent ranking pools supported by the first radar release.""" + + CONCEPT = "concept" + INDUSTRY = "industry" + + +class MembershipStatus(StrEnum): + """Availability of a point-in-time sector membership snapshot.""" + + AVAILABLE = "available" + UNKNOWN = "membership_unknown" + + +class StockFactStatus(StrEnum): + """Why one member does or does not contribute to a daily aggregate.""" + + AVAILABLE = "available" + SUSPENDED = "suspended" + MISSING = "missing" + MISSING_DAILY = "missing_daily" + MISSING_MONEYFLOW = "missing_moneyflow" + NULL_DAILY_AMOUNT = "null_daily_amount" + NULL_MONEYFLOW = "null_moneyflow" + LIFECYCLE_INVALID = "lifecycle_invalid" + LOW_LIQUIDITY = "low_liquidity" + + +class PublicationStatus(StrEnum): + """Immutable build states retained for audit and last-good selection.""" + + RUNNING = "running" + SUCCESS = "success" + PARTIAL = "partial" + FAILED = "failed" + + +class MetricKind(StrEnum): + """User-facing metric families without borrowing private score names.""" + + AMOUNT = "amount" + RATIO = "ratio" + SWING = "swing" + + +class MetricQuality(StrEnum): + """Whether a metric is usable and whether its sample needs a warning.""" + + AVAILABLE = "available" + AVAILABLE_LIMITED_SAMPLE = "available_limited_sample" + UNAVAILABLE = "unavailable" + + +class MetricUnit(StrEnum): + """Units exposed by independent metric strategies.""" + + CNY_100M = "CNY_100M" + RATIO = "ratio" + + +class RankSide(StrEnum): + """Ordinary percentile views exposed by the ranking read model.""" + + TOP = "top" + BOTTOM = "bottom" + ALL = "all" + + +def _validate_finite_decimal(value: Decimal | None, field_name: str) -> None: + """Reject non-finite domain values while preserving missing values.""" + + if value is not None and not value.is_finite(): + raise ValueError(f"{field_name} must be finite or None") + + +def _validate_coverage(value: Decimal, field_name: str) -> None: + """Require a finite fraction in the inclusive zero-to-one range.""" + + _validate_finite_decimal(value, field_name) + if value < 0 or value > 1: + raise ValueError(f"{field_name} must be between 0 and 1") + + +@dataclass(frozen=True, slots=True) +class SectorMembershipSnapshot: + """One sector's membership as observed for exactly one trade date. + + ``UNKNOWN`` is an explicit fact: callers must not substitute a current + member list when the historical snapshot is unavailable. + """ + + trade_date: date + sector_type: SectorType + sector_code: str + sector_name: str + member_codes: tuple[str, ...] + status: MembershipStatus + source_version: str + + def __post_init__(self) -> None: + """Validate identity, deterministic membership, and unknown semantics.""" + + if not self.sector_code.strip(): + raise ValueError("sector_code must not be empty") + if not self.sector_name.strip(): + raise ValueError("sector_name must not be empty") + if not self.source_version.strip(): + raise ValueError("source_version must not be empty") + if any(not code.strip() for code in self.member_codes): + raise ValueError("member_codes must not contain empty values") + if len(self.member_codes) != len(set(self.member_codes)): + raise ValueError("member_codes must be unique") + if self.status is MembershipStatus.UNKNOWN and self.member_codes: + raise ValueError("unknown membership must not expose member_codes") + + +@dataclass(frozen=True, slots=True) +class StockDailyFact: + """Normalized daily turnover and moneyflow for one member. + + Amounts are expressed in yuan. Available facts require both source + values, including an observed zero. Non-available statuses cannot carry + amounts because doing so would blur missing, suspended, and lifecycle + semantics at the metric boundary. + """ + + trade_date: date + ts_code: str + status: StockFactStatus + turnover_yuan: Decimal | None = None + net_amount_yuan: Decimal | None = None + + def __post_init__(self) -> None: + """Reject incomplete available facts and hidden non-finite values.""" + + if not self.ts_code.strip(): + raise ValueError("ts_code must not be empty") + _validate_finite_decimal(self.turnover_yuan, "turnover_yuan") + _validate_finite_decimal(self.net_amount_yuan, "net_amount_yuan") + if self.status is StockFactStatus.AVAILABLE: + if self.turnover_yuan is None or self.net_amount_yuan is None: + raise ValueError("available stock facts require both amounts") + if self.turnover_yuan < 0: + raise ValueError("turnover_yuan must not be negative") + elif self.turnover_yuan is not None or self.net_amount_yuan is not None: + raise ValueError("non-available stock facts must not expose amounts") + + +@dataclass(frozen=True, slots=True) +class RadarPublication: + """Traceable identity and lifecycle of one immutable radar build revision.""" + + publication_id: str + target_trade_date: date + status: PublicationStatus + source_version: str + universe_version: str + metric_versions: tuple[str, ...] + input_hash: str | None + coverage: Decimal + started_at: datetime + finished_at: datetime | None = None + error_summary: str | None = None + + def __post_init__(self) -> None: + """Keep running and terminal lifecycle timestamps internally consistent.""" + + if not self.publication_id.strip(): + raise ValueError("publication_id must not be empty") + if not self.source_version.strip() or not self.universe_version.strip(): + raise ValueError("publication source versions must not be empty") + if not self.metric_versions or any(not value.strip() for value in self.metric_versions): + raise ValueError("metric_versions must contain named strategies") + if len(self.metric_versions) != len(set(self.metric_versions)): + raise ValueError("metric_versions must be unique") + _validate_coverage(self.coverage, "coverage") + if self.started_at.tzinfo is None: + raise ValueError("started_at must be timezone-aware") + is_running = self.status is PublicationStatus.RUNNING + if is_running != (self.finished_at is None): + raise ValueError("finished_at must be absent only while publication is running") + if self.finished_at is not None: + if self.finished_at.tzinfo is None: + raise ValueError("finished_at must be timezone-aware") + if self.finished_at < self.started_at: + raise ValueError("finished_at must not precede started_at") + if self.status is PublicationStatus.SUCCESS and self.input_hash is None: + raise ValueError("successful publication requires input_hash") + if self.input_hash is not None and ( + len(self.input_hash) != 64 + or any(character not in "0123456789abcdef" for character in self.input_hash) + ): + raise ValueError("input_hash must be a lowercase SHA-256 hex digest") + + +@dataclass(frozen=True, slots=True) +class SectorDailyAggregate: + """One sector's point-in-time daily facts after source normalization. + + Amounts use yuan so strategies cannot accidentally mix Tushare's + ``moneyflow_dc.net_amount`` (ten-thousand yuan) with ``daily.amount`` + (thousand yuan). ``None`` means missing source data; zero remains an + observed value. + """ + + trade_date: date + sector_type: SectorType + sector_code: str + sector_name: str + member_count: int + valid_sample_count: int + net_amount_yuan: Decimal | None + turnover_yuan: Decimal | None + membership_coverage: Decimal + moneyflow_coverage: Decimal + + def __post_init__(self) -> None: + """Validate counts, coverage, and finite normalized values.""" + + if not self.sector_code.strip(): + raise ValueError("sector_code must not be empty") + if not self.sector_name.strip(): + raise ValueError("sector_name must not be empty") + if self.member_count < 0: + raise ValueError("member_count must not be negative") + if not 0 <= self.valid_sample_count <= self.member_count: + raise ValueError("valid_sample_count must be within member_count") + _validate_finite_decimal(self.net_amount_yuan, "net_amount_yuan") + _validate_finite_decimal(self.turnover_yuan, "turnover_yuan") + _validate_coverage(self.membership_coverage, "membership_coverage") + _validate_coverage(self.moneyflow_coverage, "moneyflow_coverage") + + +@dataclass(frozen=True, slots=True) +class MetricObservation: + """One versioned independent metric value ready for cross-sectional ranking.""" + + trade_date: date + sector_type: SectorType + sector_code: str + sector_name: str + metric_kind: MetricKind + metric_version: str + implementation_kind: Literal["independent"] + unit: MetricUnit + value: Decimal | None + quality: MetricQuality + member_count: int + valid_sample_count: int + membership_coverage: Decimal + moneyflow_coverage: Decimal + + def __post_init__(self) -> None: + """Keep unavailable and finite-value states internally consistent.""" + + _validate_finite_decimal(self.value, "value") + if self.value is None and self.quality is not MetricQuality.UNAVAILABLE: + raise ValueError("a missing metric value must be unavailable") + if self.value is not None and self.quality is MetricQuality.UNAVAILABLE: + raise ValueError("an unavailable metric must not expose a value") + + +@dataclass(frozen=True, slots=True) +class RankChange: + """One previous-publication rank delta using past minus current rank.""" + + days: int + value: int | None + + def __post_init__(self) -> None: + """Limit the public comparison window to one through five days.""" + + if not 1 <= self.days <= 5: + raise ValueError("rank change days must be between 1 and 5") + + +@dataclass(frozen=True, slots=True) +class RankedMetric: + """A metric observation with its position inside one independent pool.""" + + observation: MetricObservation + rank_position: int | None + rank_percentile: Decimal | None + rank_changes: tuple[RankChange, ...] = () + + def __post_init__(self) -> None: + """Require rank position and percentile to be present or absent together.""" + + if (self.rank_position is None) != (self.rank_percentile is None): + raise ValueError("rank_position and rank_percentile must be paired") + if self.rank_position is not None and self.rank_position < 1: + raise ValueError("rank_position must be positive") + _validate_finite_decimal(self.rank_percentile, "rank_percentile") + if self.rank_percentile is not None and not 0 < self.rank_percentile <= 100: + raise ValueError("rank_percentile must be within (0, 100]") + days = [change.days for change in self.rank_changes] + if len(days) != len(set(days)): + raise ValueError("rank change days must be unique") + + def rank_change(self, days: int) -> int | None: + """Return one configured rank delta, or ``None`` when history is absent.""" + + if not 1 <= days <= 5: + raise ValueError("rank change days must be between 1 and 5") + return next( + (change.value for change in self.rank_changes if change.days == days), + None, + ) diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/normalize.py b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/normalize.py new file mode 100644 index 0000000..69a16d7 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/normalize.py @@ -0,0 +1,245 @@ +"""Normalize typed Tushare rows into point-in-time persisted radar facts.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Callable, Sequence +from datetime import date + +from .models import MembershipStatus, StockFactStatus +from .persistence import MembershipRecord, StockFactRecord +from .source import ( + DailyRow, + MoneyflowDcRow, + SectorIndexRow, + SectorMemberRow, + SourceContractError, + SourceResult, + StockBasicRow, + SuspendRow, +) + + +def normalize_memberships( + indices: Sequence[SectorIndexRow], + members: SourceResult[SectorMemberRow], +) -> tuple[MembershipRecord, ...]: + """Attach each dated member to its sector identity and raw source partition. + + Args: + indices: The complete concept or industry universe for one date. + members: Validated membership rows plus all raw request snapshots. + + Returns: + Deterministically ordered, source-traceable membership records. + + Raises: + SourceContractError: If a member references an unknown sector or lacks a snapshot. + """ + + index_by_code = {row.sector_code: row for row in indices} + if len(index_by_code) != len(indices): + raise SourceContractError("sector indices contain duplicate codes") + partition_ids = { + snapshot.partition_key: snapshot.snapshot_id + for snapshot in members.snapshots + if snapshot.partition_key not in {None, "all"} + } + all_snapshot_id = next( + ( + snapshot.snapshot_id + for snapshot in members.snapshots + if snapshot.partition_key in {None, "all"} + ), + None, + ) + members_by_sector: dict[str, list[SectorMemberRow]] = {code: [] for code in index_by_code} + for member in members.rows: + index = index_by_code.get(member.sector_code) + if index is None: + raise SourceContractError("dc_member references a sector outside dc_index") + members_by_sector[member.sector_code].append(member) + + records: list[MembershipRecord] = [] + for sector_code in sorted(index_by_code): + index = index_by_code[sector_code] + sector_members = members_by_sector[sector_code] + snapshot_id = partition_ids.get(sector_code, all_snapshot_id) + if snapshot_id is None: + raise SourceContractError("sector membership has no source snapshot") + if not sector_members: + explicit_partition_id = partition_ids.get(sector_code) + if explicit_partition_id is None: + raise SourceContractError( + "missing sector membership requires an explicit empty partition" + ) + records.append( + MembershipRecord( + source_snapshot_id=explicit_partition_id, + trade_date=index.trade_date, + sector_type=index.sector_type, + sector_code=index.sector_code, + sector_name=index.name, + stock_code=None, + stock_name=None, + status=MembershipStatus.UNKNOWN, + ) + ) + continue + for member in sector_members: + records.append( + MembershipRecord( + source_snapshot_id=snapshot_id, + trade_date=member.trade_date, + sector_type=index.sector_type, + sector_code=index.sector_code, + sector_name=index.name, + stock_code=member.stock_code, + stock_name=member.stock_name, + status=MembershipStatus.AVAILABLE, + ) + ) + return tuple(sorted(records, key=lambda item: (item.sector_code, item.membership_key))) + + +def normalize_stock_facts( + *, + target_trade_date: date, + candidate_codes: Sequence[str], + stock_basics: SourceResult[StockBasicRow], + suspensions: SourceResult[SuspendRow], + daily: SourceResult[DailyRow], + moneyflow: SourceResult[MoneyflowDcRow], +) -> tuple[StockFactRecord, ...]: + """Build normalized yuan facts without collapsing missing states into zero. + + Args: + target_trade_date: Date whose point-in-time lifecycle is evaluated. + candidate_codes: Union of stocks in that date's sector memberships. + stock_basics: Current ``list_status=L`` Tushare listings. + suspensions: Same-date suspend/resume events. + daily: Same-date stock turnover rows in source units. + moneyflow: Same-date DC main-moneyflow rows in source units. + + Returns: + One deterministic fact per candidate code under a content-derived revision. + """ + + if len(candidate_codes) != len(set(candidate_codes)): + raise ValueError("candidate_codes must be unique") + basic_by_code = _unique_index(stock_basics.rows, lambda row: row.ts_code, "stock_basic") + daily_by_code = _unique_index(daily.rows, lambda row: row.ts_code, "daily") + moneyflow_by_code = _unique_index(moneyflow.rows, lambda row: row.ts_code, "moneyflow_dc") + suspended_codes = { + row.ts_code + for row in suspensions.rows + if row.trade_date == target_trade_date and _is_suspend_event(row.suspend_type) + } + source_snapshot_ids = tuple( + sorted( + { + snapshot.snapshot_id + for result in (stock_basics, suspensions, daily, moneyflow) + for snapshot in result.snapshots + } + ) + ) + revision_payload = json.dumps( + { + "target_trade_date": target_trade_date.isoformat(), + "source_snapshot_ids": source_snapshot_ids, + "normalizer": "zhixing_stock_fact_v1", + }, + sort_keys=True, + separators=(",", ":"), + ) + fact_revision = hashlib.sha256(revision_payload.encode()).hexdigest() + + records: list[StockFactRecord] = [] + for ts_code in sorted(candidate_codes): + basic = basic_by_code.get(ts_code) + daily_row = daily_by_code.get(ts_code) + moneyflow_row = moneyflow_by_code.get(ts_code) + status = StockFactStatus.AVAILABLE + turnover_yuan = None + net_amount_yuan = None + + if basic is None or not is_current_listed_stock(basic, target_trade_date): + status = StockFactStatus.LIFECYCLE_INVALID + elif ts_code in suspended_codes and daily_row is None: + status = StockFactStatus.SUSPENDED + elif daily_row is None: + status = StockFactStatus.MISSING_DAILY + elif daily_row.amount_thousand_yuan is None: + status = StockFactStatus.NULL_DAILY_AMOUNT + elif moneyflow_row is None: + status = StockFactStatus.MISSING_MONEYFLOW + elif moneyflow_row.net_amount_ten_thousand_yuan is None: + status = StockFactStatus.NULL_MONEYFLOW + elif daily_row.turnover_yuan == 0: + status = StockFactStatus.LOW_LIQUIDITY + else: + turnover_yuan = daily_row.turnover_yuan + net_amount_yuan = moneyflow_row.net_amount_yuan + + records.append( + StockFactRecord( + fact_revision=fact_revision, + source_snapshot_ids=source_snapshot_ids, + trade_date=target_trade_date, + ts_code=ts_code, + status=status, + turnover_yuan=turnover_yuan, + net_amount_yuan=net_amount_yuan, + ) + ) + return tuple(records) + + +def is_current_listed_stock(stock: StockBasicRow, target: date) -> bool: + """Return whether one current ``L`` row is an eligible radar security. + + The radar intentionally uses the listings observed at build time rather than + reconstructing historical delistings. Code, market, and list-date checks keep + the existing Shanghai/Shenzhen A-share boundary intact. + + Args: + stock: One validated ``stock_basic`` row. + target: Radar date whose list date must already have arrived. + + Returns: + Whether the security belongs to the build-time radar universe. + """ + + if stock.list_status != "L": + return False + if not stock.ts_code.endswith((".SH", ".SZ")): + return False + if stock.symbol.startswith(("200", "900")): + return False + market = stock.market or "" + if "北交" in market or "B股" in market.upper(): + return False + return stock.list_date is not None and stock.list_date <= target + + +def _is_suspend_event(value: str) -> bool: + normalized = value.strip().casefold() + return normalized in {"s", "suspend", "停牌"} or ( + "停牌" in normalized and "复牌" not in normalized + ) + + +def _unique_index[T, K]( + rows: Sequence[T], + key: Callable[[T], K], + source_name: str, +) -> dict[K, T]: + result: dict[K, T] = {} + for row in rows: + item_key = key(row) + if item_key in result: + raise SourceContractError(f"{source_name} contains duplicate business keys") + result[item_key] = row + return result diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/persistence.py b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/persistence.py new file mode 100644 index 0000000..1c83b82 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/persistence.py @@ -0,0 +1,251 @@ +"""Persistence records and repository port for replayable radar revisions.""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from contextlib import AbstractContextManager +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal +from enum import StrEnum +from typing import Protocol + +from .models import ( + MembershipStatus, + RadarPublication, + RankedMetric, + SectorDailyAggregate, + SectorType, + StockFactStatus, +) +from .source import SourceSnapshot + + +def _validate_digest(value: str, field_name: str) -> None: + if len(value) != 64 or any(character not in "0123456789abcdef" for character in value): + raise ValueError(f"{field_name} must be a lowercase SHA-256 digest") + + +def _validate_optional_decimal(value: Decimal | None, field_name: str) -> None: + if value is not None and not value.is_finite(): + raise ValueError(f"{field_name} must be finite or None") + + +@dataclass(frozen=True, slots=True) +class MembershipRecord: + """One persisted point-in-time member or explicit unknown snapshot.""" + + source_snapshot_id: str + trade_date: date + sector_type: SectorType + sector_code: str + sector_name: str + stock_code: str | None + stock_name: str | None + status: MembershipStatus = MembershipStatus.AVAILABLE + + def __post_init__(self) -> None: + """Validate available and unknown membership null semantics.""" + + _validate_digest(self.source_snapshot_id, "source_snapshot_id") + if not self.sector_code.strip() or not self.sector_name.strip(): + raise ValueError("membership sector identity fields must not be empty") + if self.status is MembershipStatus.AVAILABLE: + if self.stock_code is None or self.stock_name is None: + raise ValueError("available membership requires stock identity") + if not self.stock_code.strip() or not self.stock_name.strip(): + raise ValueError("available membership stock identity must not be empty") + elif self.stock_code is not None or self.stock_name is not None: + raise ValueError("unknown membership must not expose stock identity") + + @property + def membership_key(self) -> str: + """Return a non-null persistence key without inventing a stock code.""" + + return self.stock_code if self.stock_code is not None else "__membership_unknown__" + + +@dataclass(frozen=True, slots=True) +class StockFactRecord: + """One normalized stock fact revision with all contributing raw snapshots.""" + + fact_revision: str + source_snapshot_ids: tuple[str, ...] + trade_date: date + ts_code: str + status: StockFactStatus + turnover_yuan: Decimal | None = None + net_amount_yuan: Decimal | None = None + + def __post_init__(self) -> None: + """Preserve source traceability and stock fact null semantics.""" + + _validate_digest(self.fact_revision, "fact_revision") + if not self.source_snapshot_ids or len(self.source_snapshot_ids) != len( + set(self.source_snapshot_ids) + ): + raise ValueError("source_snapshot_ids must be non-empty and unique") + for value in self.source_snapshot_ids: + _validate_digest(value, "source_snapshot_id") + if not self.ts_code.strip(): + raise ValueError("ts_code must not be empty") + _validate_optional_decimal(self.turnover_yuan, "turnover_yuan") + _validate_optional_decimal(self.net_amount_yuan, "net_amount_yuan") + if self.status is StockFactStatus.AVAILABLE: + if self.turnover_yuan is None or self.net_amount_yuan is None: + raise ValueError("available stock facts require both amounts") + if self.turnover_yuan < 0: + raise ValueError("turnover_yuan must not be negative") + elif self.turnover_yuan is not None or self.net_amount_yuan is not None: + raise ValueError("non-available stock facts must not expose amounts") + + +@dataclass(frozen=True, slots=True) +class RankingRecord: + """One ranked metric attached to an immutable publication identity.""" + + publication_id: str + ranking: RankedMetric + + def __post_init__(self) -> None: + """Validate the publication foreign identity.""" + + if not self.publication_id.strip(): + raise ValueError("publication_id must not be empty") + + +@dataclass(frozen=True, slots=True) +class DailyAggregateRecord: + """One exact daily strategy input owned by a publication revision.""" + + publication_id: str + aggregate: SectorDailyAggregate + + def __post_init__(self) -> None: + """Validate the publication foreign identity.""" + + if not self.publication_id.strip(): + raise ValueError("publication_id must not be empty") + + +class PublicationSourceGroup(StrEnum): + """Stable source checkpoints that can be retried independently.""" + + CALENDAR = "calendar" + CONCEPT_INDICES = "concept_indices" + INDUSTRY_INDICES = "industry_indices" + MEMBERS = "members" + STOCK_BASICS = "stock_basics" + SUSPENSIONS = "suspensions" + DAILY = "daily" + MONEYFLOW_DC = "moneyflow_dc" + + +@dataclass(frozen=True, slots=True) +class PublicationSourceRecord: + """One ordered raw snapshot checkpoint attached to a build attempt.""" + + publication_id: str + source_group: PublicationSourceGroup + source_order: int + snapshot: SourceSnapshot + refresh_on_retry: bool = False + + def __post_init__(self) -> None: + """Validate the publication identity and deterministic group ordering.""" + + if not self.publication_id.strip(): + raise ValueError("publication_id must not be empty") + if self.source_order < 0: + raise ValueError("source_order must not be negative") + + +@dataclass(frozen=True, slots=True) +class WriteCounts: + """Idempotent persistence outcome.""" + + inserted: int + unchanged: int + + def __post_init__(self) -> None: + """Reject impossible write counts.""" + + if self.inserted < 0 or self.unchanged < 0: + raise ValueError("write counts must not be negative") + + +class SectorRadarRepository(Protocol): + """Persist source revisions, normalized facts, and published rankings.""" + + def advisory_lock(self, target_trade_date: date) -> AbstractContextManager[bool]: ... + + def save_source_snapshots(self, snapshots: Iterable[SourceSnapshot]) -> WriteCounts: ... + + def save_publication_sources( + self, records: Iterable[PublicationSourceRecord] + ) -> WriteCounts: ... + + def load_publication_sources( + self, publication_id: str + ) -> Sequence[PublicationSourceRecord]: ... + + def mark_publication_sources_for_retry( + self, + publication_id: str, + source_groups: Sequence[PublicationSourceGroup], + ) -> None: ... + + def save_memberships(self, records: Iterable[MembershipRecord]) -> WriteCounts: ... + + def save_stock_facts(self, records: Iterable[StockFactRecord]) -> WriteCounts: ... + + def save_daily_aggregates(self, records: Iterable[DailyAggregateRecord]) -> WriteCounts: ... + + def create_publication(self, publication: RadarPublication) -> WriteCounts: ... + + def finish_publication(self, publication: RadarPublication) -> None: ... + + def finalize_publication( + self, + publication: RadarPublication, + *, + memberships: Iterable[MembershipRecord], + stock_facts: Iterable[StockFactRecord], + daily_aggregates: Iterable[DailyAggregateRecord], + rankings: Iterable[RankingRecord], + retry_source_groups: Sequence[PublicationSourceGroup] = (), + ) -> None: ... + + def recover_running_publications( + self, target_trade_date: date, *, finished_at: datetime + ) -> Sequence[str]: ... + + def discard_running_publication(self, publication_id: str) -> None: ... + + def save_rankings(self, records: Iterable[RankingRecord]) -> WriteCounts: ... + + def find_reusable_publication( + self, target_trade_date: date, input_hash: str + ) -> RadarPublication | None: ... + + def get_publication(self, publication_id: str) -> RadarPublication | None: ... + + def get_last_good_publication( + self, target_trade_date: date | None = None + ) -> RadarPublication | None: ... + + def get_successful_publication(self, target_trade_date: date) -> RadarPublication | None: ... + + def get_latest_publication(self) -> RadarPublication | None: ... + + def list_successful_dates(self) -> Sequence[date]: ... + + def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]: ... + + def load_daily_aggregate_history( + self, target_trade_date: date, *, limit_dates: int + ) -> Sequence[SectorDailyAggregate]: ... + + def load_previous_rankings( + self, target_trade_date: date, *, limit_dates: int + ) -> Sequence[tuple[date, Sequence[RankedMetric]]]: ... diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/ports.py b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/ports.py new file mode 100644 index 0000000..9206963 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/ports.py @@ -0,0 +1,57 @@ +"""Application-facing ports for independent sector radar production.""" + +from __future__ import annotations + +from collections.abc import Sequence +from contextlib import AbstractContextManager +from datetime import date +from typing import Protocol + +from .models import SectorType +from .source import ( + CapabilityProbeResult, + DailyRow, + MoneyflowDcRow, + SectorIndexRow, + SectorMemberRow, + SourceResult, + StockBasicRow, + SuspendRow, + TradeCalendarRow, +) + + +class SectorRadarSource(Protocol): + """Fetch the minimum replayable Tushare facts needed by the MVP.""" + + def fetch_trade_calendar(self, start: date, end: date) -> SourceResult[TradeCalendarRow]: ... + + def fetch_sector_indices( + self, trade_date: date, sector_type: SectorType + ) -> SourceResult[SectorIndexRow]: ... + + def fetch_sector_members( + self, + trade_date: date, + sector_codes: Sequence[str], + ) -> SourceResult[SectorMemberRow]: ... + + def fetch_stock_basics(self) -> SourceResult[StockBasicRow]: ... + + def fetch_suspensions(self, trade_date: date) -> SourceResult[SuspendRow]: ... + + def fetch_daily(self, trade_date: date) -> SourceResult[DailyRow]: ... + + def fetch_moneyflow_dc( + self, + trade_date: date, + candidate_codes: Sequence[str], + ) -> SourceResult[MoneyflowDcRow]: ... + + def probe(self, trade_date: date) -> CapabilityProbeResult: ... + + +class SectorRadarLock(Protocol): + """Repository seam for a target-date advisory lock.""" + + def advisory_lock(self, target_trade_date: date) -> AbstractContextManager[bool]: ... diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/ranking.py b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/ranking.py new file mode 100644 index 0000000..d39bdc6 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/ranking.py @@ -0,0 +1,216 @@ +"""Deterministic cross-sectional ranking for independent sector pools.""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Iterable, Mapping +from dataclasses import replace +from datetime import date +from decimal import Decimal + +from .models import ( + MetricKind, + MetricObservation, + RankChange, + RankedMetric, + RankSide, + SectorType, +) + +PoolKey = tuple[date, SectorType, MetricKind, str] +SectorMetricKey = tuple[SectorType, str, MetricKind, str] + + +def _pool_key(observation: MetricObservation) -> PoolKey: + return ( + observation.trade_date, + observation.sector_type, + observation.metric_kind, + observation.metric_version, + ) + + +def _sector_metric_key(observation: MetricObservation) -> SectorMetricKey: + return ( + observation.sector_type, + observation.sector_code, + observation.metric_kind, + observation.metric_version, + ) + + +def _available_sort_key(observation: MetricObservation) -> tuple[Decimal, str]: + if observation.value is None: + raise ValueError("unavailable observations cannot use the ranking sort key") + return (-observation.value, observation.sector_code) + + +def rank_metric_observations( + observations: Iterable[MetricObservation], +) -> tuple[RankedMetric, ...]: + """Rank observations by value within date, type, metric, and version. + + Concept and industry observations never share a pool. Equal metric values + use ascending sector code as the documented Zhixing tie-breaker. Missing + values remain visible but do not consume a rank. + """ + + pools: defaultdict[PoolKey, list[MetricObservation]] = defaultdict(list) + for observation in observations: + pools[_pool_key(observation)].append(observation) + + result: list[RankedMetric] = [] + for pool_key in sorted( + pools, + key=lambda key: (key[0], key[1].value, key[2].value, key[3]), + ): + pool = pools[pool_key] + codes = [observation.sector_code for observation in pool] + if len(codes) != len(set(codes)): + raise ValueError("a ranking pool must not contain duplicate sector codes") + + available = sorted( + (observation for observation in pool if observation.value is not None), + key=_available_sort_key, + ) + pool_size = len(available) + for rank_position, observation in enumerate(available, start=1): + rank_percentile = ( + Decimal(100) * Decimal(pool_size - rank_position + 1) / Decimal(pool_size) + ) + result.append( + RankedMetric( + observation=observation, + rank_position=rank_position, + rank_percentile=rank_percentile, + ) + ) + + result.extend( + RankedMetric( + observation=observation, + rank_position=None, + rank_percentile=None, + ) + for observation in sorted( + (observation for observation in pool if observation.value is None), + key=lambda observation: observation.sector_code, + ) + ) + return tuple(result) + + +def select_percentile_side( + rankings: Iterable[RankedMetric], side: RankSide +) -> tuple[RankedMetric, ...]: + """Select confirmed inclusive percentile sides without fixed row counts.""" + + rows = tuple(rankings) + if side is RankSide.ALL: + return rows + + threshold_rows = tuple( + row + for row in rows + if row.rank_percentile is not None + and ( + row.rank_percentile >= Decimal(90) + if side is RankSide.TOP + else row.rank_percentile <= Decimal(10) + ) + ) + if side is RankSide.TOP: + return threshold_rows + + pools: defaultdict[PoolKey, list[RankedMetric]] = defaultdict(list) + for row in threshold_rows: + pools[_pool_key(row.observation)].append(row) + result: list[RankedMetric] = [] + for pool_key in sorted( + pools, + key=lambda key: (key[0], key[1].value, key[2].value, key[3]), + ): + result.extend( + sorted( + pools[pool_key], + key=lambda row: ( + row.observation.value if row.observation.value is not None else Decimal(0), + row.observation.sector_code, + ), + ) + ) + return tuple(result) + + +def with_rank_changes( + current_rankings: Iterable[RankedMetric], + history_by_days: Mapping[int, Iterable[RankedMetric]], +) -> tuple[RankedMetric, ...]: + """Attach 1-to-5-day deltas without turning missing history into zero.""" + + history_indexes: dict[int, dict[SectorMetricKey, int | None]] = {} + for days, historical_rankings in history_by_days.items(): + if not 1 <= days <= 5: + raise ValueError("rank change days must be between 1 and 5") + index: dict[SectorMetricKey, int | None] = {} + for row in historical_rankings: + key = _sector_metric_key(row.observation) + if key in index: + raise ValueError("historical rankings must have unique sector metrics") + index[key] = row.rank_position + history_indexes[days] = index + + result: list[RankedMetric] = [] + for row in current_rankings: + key = _sector_metric_key(row.observation) + changes: list[RankChange] = [] + for days in sorted(history_indexes): + past_rank = history_indexes[days].get(key) + value = ( + past_rank - row.rank_position + if past_rank is not None and row.rank_position is not None + else None + ) + changes.append(RankChange(days=days, value=value)) + result.append(replace(row, rank_changes=tuple(changes))) + return tuple(result) + + +def select_rank_change_side( + rankings: Iterable[RankedMetric], + *, + days: int, + side: RankSide, +) -> tuple[RankedMetric, ...]: + """Select the strongest or weakest ceiling-ten-percent rank changes per pool.""" + + if not 1 <= days <= 5: + raise ValueError("rank change days must be between 1 and 5") + pools: defaultdict[PoolKey, list[RankedMetric]] = defaultdict(list) + for row in rankings: + pools[_pool_key(row.observation)].append(row) + + result: list[RankedMetric] = [] + for pool_key in sorted( + pools, + key=lambda key: (key[0], key[1].value, key[2].value, key[3]), + ): + pool = pools[pool_key] + pool_size = sum(row.rank_position is not None for row in pool) + take_count = max(1, (pool_size + 9) // 10) if pool_size else 0 + candidates = tuple( + (change, row) for row in pool if (change := row.rank_change(days)) is not None + ) + if side is RankSide.BOTTOM: + ordered = sorted( + candidates, + key=lambda item: (item[0], item[1].observation.sector_code), + ) + else: + ordered = sorted( + candidates, + key=lambda item: (-item[0], item[1].observation.sector_code), + ) + selected = ordered if side is RankSide.ALL else ordered[:take_count] + result.extend(row for _, row in selected) + return tuple(result) diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/source.py b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/source.py new file mode 100644 index 0000000..ea8d379 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/source.py @@ -0,0 +1,503 @@ +"""Typed Tushare input contracts and replayable source snapshot values.""" + +from __future__ import annotations + +import hashlib +import json +import math +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, date, datetime +from decimal import Decimal, InvalidOperation +from enum import StrEnum +from typing import TypeVar + +from .models import SectorType + +SourceScalar = str | int | float | bool | None +T = TypeVar("T") + + +class SourceContractError(ValueError): + """A provider response violates the replayable input contract. + + Messages must remain operator-safe contract descriptions. They may name a + field or validation rule, but must never interpolate provider values, + credentials, request parameters, or raw payloads because production build + diagnostics record this message. + """ + + def __init__(self, message: str) -> None: + """Create one violation whose diagnostic can be claimed by the nearest boundary.""" + + super().__init__(message) + self._diagnostic_claimed = False + + @property + def operator_message(self) -> str: + """Return a single-line, bounded diagnostic suitable for production logs.""" + + return " ".join(str(self).split())[:200] + + def claim_diagnostic(self) -> bool: + """Return whether this boundary should emit the exception's single diagnostic log.""" + + if self._diagnostic_claimed: + return False + self._diagnostic_claimed = True + return True + + +class SourceTruncatedError(SourceContractError): + """A provider response reached its row limit without safe partitioning.""" + + +def normalize_source_scalar(value: object) -> SourceScalar: + """Normalize flat Tushare cells while distinguishing missing from infinity.""" + + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, int): + return value + if isinstance(value, float): + if math.isnan(value): + return None + if not math.isfinite(value): + raise SourceContractError("source numeric values must be finite") + return value + if isinstance(value, Decimal): + if value.is_nan(): + return None + if not value.is_finite(): + raise SourceContractError("source numeric values must be finite") + return str(value) + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, str): + stripped = value.strip() + if not stripped or stripped.casefold() == "nan": + return None + return stripped + raise SourceContractError(f"unsupported source cell type: {type(value).__name__}") + + +def normalize_source_rows( + rows: Sequence[Mapping[str, object]], +) -> tuple[dict[str, SourceScalar], ...]: + """Return safe flat rows with deterministic key order.""" + + return tuple({key: normalize_source_scalar(row[key]) for key in sorted(row)} for row in rows) + + +@dataclass(frozen=True, slots=True) +class SourceSnapshot: + """One raw, sanitized provider response identified by safe content hash.""" + + snapshot_id: str + api_name: str + normalized_params: tuple[tuple[str, str], ...] + target_trade_date: date | None + partition_key: str | None + observed_at: datetime + rows: tuple[dict[str, SourceScalar], ...] + row_count: int + returned_fields: tuple[str, ...] + content_sha256: str + row_limit: int | None + limit_reached: bool + + def __post_init__(self) -> None: + """Validate replay identity and row metadata.""" + + for field_name, value in ( + ("snapshot_id", self.snapshot_id), + ("content_sha256", self.content_sha256), + ): + if len(value) != 64 or any(character not in "0123456789abcdef" for character in value): + raise ValueError(f"{field_name} must be a lowercase SHA-256 digest") + if not self.api_name.strip(): + raise ValueError("api_name must not be empty") + if self.observed_at.tzinfo is None: + raise ValueError("observed_at must be timezone-aware") + if self.row_count != len(self.rows): + raise ValueError("row_count must match rows") + if self.row_limit is not None and self.row_limit < 1: + raise ValueError("row_limit must be positive") + if self.limit_reached != (self.row_limit is not None and self.row_count >= self.row_limit): + raise ValueError("limit_reached must match row_count and row_limit") + + +def build_source_snapshot( + *, + api_name: str, + params: Mapping[str, object], + rows: Sequence[Mapping[str, object]], + target_trade_date: date | None, + partition_key: str | None = None, + observed_at: datetime | None = None, + row_limit: int | None = None, + returned_fields: Sequence[str] | None = None, +) -> SourceSnapshot: + """Build an order-stable, token-free raw response snapshot.""" + + normalized_rows = normalize_source_rows(rows) + normalized_params = tuple( + sorted((key, str(value)) for key, value in params.items() if key != "token") + ) + fields = tuple( + sorted( + set(returned_fields) + if returned_fields is not None + else {key for row in normalized_rows for key in row} + ) + ) + row_count = len(normalized_rows) + limit_reached = row_limit is not None and row_count >= row_limit + canonical_rows = sorted( + json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + for row in normalized_rows + ) + canonical_content = json.dumps( + { + "rows": canonical_rows, + "returned_fields": fields, + "row_limit": row_limit, + "limit_reached": limit_reached, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + content_sha256 = hashlib.sha256(canonical_content.encode()).hexdigest() + identity = json.dumps( + { + "api_name": api_name, + "params": normalized_params, + "partition_key": partition_key, + "target_trade_date": ( + target_trade_date.isoformat() if target_trade_date is not None else None + ), + "content_sha256": content_sha256, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + snapshot_id = hashlib.sha256(identity.encode()).hexdigest() + return SourceSnapshot( + snapshot_id=snapshot_id, + api_name=api_name, + normalized_params=normalized_params, + target_trade_date=target_trade_date, + partition_key=partition_key, + observed_at=observed_at or datetime.now(UTC), + rows=normalized_rows, + row_count=row_count, + returned_fields=fields, + content_sha256=content_sha256, + row_limit=row_limit, + limit_reached=limit_reached, + ) + + +@dataclass(frozen=True, slots=True) +class SourceResult[T]: + """Typed rows accompanied by every raw request needed to produce them.""" + + snapshots: tuple[SourceSnapshot, ...] + rows: tuple[T, ...] + + +def _required_text(row: Mapping[str, SourceScalar], key: str) -> str: + value = row.get(key) + if not isinstance(value, str) or not value.strip(): + raise SourceContractError(f"{key} must be a non-empty string") + return value.strip() + + +def _optional_text(row: Mapping[str, SourceScalar], key: str) -> str | None: + value = row.get(key) + if value is None: + return None + return str(value).strip() or None + + +def _source_date( + row: Mapping[str, SourceScalar], key: str, *, required: bool = True +) -> date | None: + value = row.get(key) + if value is None: + if required: + raise SourceContractError(f"{key} is required") + return None + text = str(value).strip().replace("-", "") + try: + return datetime.strptime(text, "%Y%m%d").date() + except ValueError as exc: + raise SourceContractError(f"{key} must use YYYYMMDD") from exc + + +def _decimal(row: Mapping[str, SourceScalar], key: str) -> Decimal | None: + value = row.get(key) + if value is None: + return None + try: + result = Decimal(str(value)) + except InvalidOperation as exc: + raise SourceContractError(f"{key} must be numeric or missing") from exc + if result.is_nan(): + return None + if not result.is_finite(): + raise SourceContractError(f"{key} must be finite") + return result + + +@dataclass(frozen=True, slots=True) +class TradeCalendarRow: + """One exchange calendar observation.""" + + exchange: str + cal_date: date + is_open: bool + pretrade_date: date | None + + @classmethod + def from_mapping(cls, row: Mapping[str, SourceScalar]) -> TradeCalendarRow: + """Parse one Tushare ``trade_cal`` row.""" + + cal_date = _source_date(row, "cal_date") + assert cal_date is not None + return cls( + exchange=_optional_text(row, "exchange") or "", + cal_date=cal_date, + is_open=str(row.get("is_open")).strip().casefold() in {"1", "true"}, + pretrade_date=_source_date(row, "pretrade_date", required=False), + ) + + +@dataclass(frozen=True, slots=True) +class SectorIndexRow: + """One Eastmoney concept or industry identity on a trade date.""" + + trade_date: date + sector_type: SectorType + sector_code: str + name: str + level: str | None + pct_change: Decimal | None + leading_code: str | None + + @classmethod + def from_mapping( + cls, + row: Mapping[str, SourceScalar], + sector_type: SectorType, + ) -> SectorIndexRow: + """Parse and validate one ``dc_index`` row.""" + + trade_date = _source_date(row, "trade_date") + assert trade_date is not None + return cls( + trade_date=trade_date, + sector_type=sector_type, + sector_code=_required_text(row, "ts_code"), + name=_required_text(row, "name"), + level=_optional_text(row, "level"), + pct_change=_decimal(row, "pct_change"), + leading_code=_optional_text(row, "leading_code"), + ) + + +@dataclass(frozen=True, slots=True) +class SectorMemberRow: + """One point-in-time sector member returned by ``dc_member``.""" + + trade_date: date + sector_code: str + stock_code: str + stock_name: str + + @classmethod + def from_mapping(cls, row: Mapping[str, SourceScalar]) -> SectorMemberRow: + """Parse one dated membership row.""" + + trade_date = _source_date(row, "trade_date") + assert trade_date is not None + return cls( + trade_date=trade_date, + sector_code=_required_text(row, "ts_code"), + stock_code=_required_text(row, "con_code"), + stock_name=_required_text(row, "name"), + ) + + +@dataclass(frozen=True, slots=True) +class StockBasicRow: + """Lifecycle and market identity from one explicit listing-status query.""" + + ts_code: str + symbol: str + name: str + market: str | None + exchange: str + list_status: str + list_date: date | None + delist_date: date | None + + @classmethod + def from_mapping(cls, row: Mapping[str, SourceScalar]) -> StockBasicRow: + """Parse one ``stock_basic`` row without applying ST filtering.""" + + return cls( + ts_code=_required_text(row, "ts_code"), + symbol=_required_text(row, "symbol"), + name=_required_text(row, "name"), + market=_optional_text(row, "market"), + exchange=_required_text(row, "exchange"), + list_status=_required_text(row, "list_status"), + list_date=_source_date(row, "list_date", required=False), + delist_date=_source_date(row, "delist_date", required=False), + ) + + +@dataclass(frozen=True, slots=True) +class SuspendRow: + """One daily suspend/resume event.""" + + ts_code: str + trade_date: date + suspend_timing: str | None + suspend_type: str + + @classmethod + def from_mapping(cls, row: Mapping[str, SourceScalar]) -> SuspendRow: + """Parse one ``suspend_d`` row.""" + + trade_date = _source_date(row, "trade_date") + assert trade_date is not None + return cls( + ts_code=_required_text(row, "ts_code"), + trade_date=trade_date, + suspend_timing=_optional_text(row, "suspend_timing"), + suspend_type=_required_text(row, "suspend_type"), + ) + + +@dataclass(frozen=True, slots=True) +class DailyRow: + """One stock daily row retaining Tushare's thousand-yuan amount.""" + + ts_code: str + trade_date: date + close: Decimal | None + pre_close: Decimal | None + pct_chg: Decimal | None + volume: Decimal | None + amount_thousand_yuan: Decimal | None + + @property + def turnover_yuan(self) -> Decimal | None: + """Convert observed turnover to yuan without inventing missing values.""" + + return ( + None if self.amount_thousand_yuan is None else self.amount_thousand_yuan * Decimal(1000) + ) + + @classmethod + def from_mapping(cls, row: Mapping[str, SourceScalar]) -> DailyRow: + """Parse one ``daily`` row.""" + + trade_date = _source_date(row, "trade_date") + assert trade_date is not None + return cls( + ts_code=_required_text(row, "ts_code"), + trade_date=trade_date, + close=_decimal(row, "close"), + pre_close=_decimal(row, "pre_close"), + pct_chg=_decimal(row, "pct_chg"), + volume=_decimal(row, "vol"), + amount_thousand_yuan=_decimal(row, "amount"), + ) + + +@dataclass(frozen=True, slots=True) +class MoneyflowDcRow: + """One stock main-moneyflow row retaining Tushare's ten-thousand-yuan amount.""" + + trade_date: date + ts_code: str + name: str + net_amount_ten_thousand_yuan: Decimal | None + net_amount_rate: Decimal | None + pct_change: Decimal | None + close: Decimal | None + + @property + def net_amount_yuan(self) -> Decimal | None: + """Convert observed main net amount to yuan without filling NULL as zero.""" + + return ( + None + if self.net_amount_ten_thousand_yuan is None + else self.net_amount_ten_thousand_yuan * Decimal(10_000) + ) + + @classmethod + def from_mapping(cls, row: Mapping[str, SourceScalar]) -> MoneyflowDcRow: + """Parse one ``moneyflow_dc`` row.""" + + trade_date = _source_date(row, "trade_date") + assert trade_date is not None + return cls( + trade_date=trade_date, + ts_code=_required_text(row, "ts_code"), + name=_required_text(row, "name"), + net_amount_ten_thousand_yuan=_decimal(row, "net_amount"), + net_amount_rate=_decimal(row, "net_amount_rate"), + pct_change=_decimal(row, "pct_change"), + close=_decimal(row, "close"), + ) + + +class CapabilityStatus(StrEnum): + """Safe capability outcomes that never expose provider error text.""" + + OK = "ok" + FORBIDDEN = "forbidden" + RATE_LIMITED = "rate_limited" + SERVER_ERROR = "server_error" + SCHEMA_ERROR = "schema_error" + TRUNCATED = "truncated" + + +@dataclass(frozen=True, slots=True) +class CapabilityInterfaceResult: + """Safe, credential-free observation for one required interface.""" + + api_name: str + requested_fields: tuple[str, ...] + returned_fields: tuple[str, ...] + status: CapabilityStatus + row_count: int + row_limit: int | None + retryable: bool + + +@dataclass(frozen=True, slots=True) +class CapabilityProbeResult: + """Read-only account capability report for the seven MVP interfaces.""" + + observed_at: datetime + interfaces: tuple[CapabilityInterfaceResult, ...] + + @property + def succeeded(self) -> bool: + """Return whether every required interface passed its probe.""" + + return bool(self.interfaces) and all( + result.status is CapabilityStatus.OK for result in self.interfaces + ) diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/__init__.py b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/__init__.py new file mode 100644 index 0000000..7b7497f --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Infrastructure adapters for the sector radar bounded context.""" diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/memory.py b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/memory.py new file mode 100644 index 0000000..903f21e --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/memory.py @@ -0,0 +1,460 @@ +"""Deterministic in-memory repository used by application and contract tests.""" + +from __future__ import annotations + +from collections.abc import Callable, Generator, Iterable, Sequence +from contextlib import contextmanager +from dataclasses import replace +from datetime import date, datetime + +from ..domain.models import PublicationStatus, RadarPublication, RankedMetric, SectorDailyAggregate +from ..domain.persistence import ( + DailyAggregateRecord, + MembershipRecord, + PublicationSourceGroup, + PublicationSourceRecord, + RankingRecord, + StockFactRecord, + WriteCounts, +) +from ..domain.source import SourceSnapshot + + +class InMemorySectorRadarRepository: + """Keep immutable radar revisions in dictionaries without hiding overwrites.""" + + def __init__(self) -> None: + self.source_snapshots: dict[str, SourceSnapshot] = {} + self.publication_sources: dict[tuple[str, str, int], PublicationSourceRecord] = {} + self.memberships: dict[tuple[str, str, str], MembershipRecord] = {} + self.stock_facts: dict[tuple[str, str], StockFactRecord] = {} + self.publications: dict[str, RadarPublication] = {} + self.daily_aggregates: dict[tuple[str, str, str], DailyAggregateRecord] = {} + self.rankings: dict[tuple[str, str, str, str], RankingRecord] = {} + self.lock_available = True + + @contextmanager + def advisory_lock(self, target_trade_date: date) -> Generator[bool]: + """Expose a controllable lock result for build orchestration tests.""" + + del target_trade_date + yield self.lock_available + + def save_source_snapshots(self, snapshots: Iterable[SourceSnapshot]) -> WriteCounts: + """Insert new content-addressed snapshots and count identical replays.""" + + inserted = 0 + unchanged = 0 + seen: set[str] = set() + for snapshot in snapshots: + if snapshot.snapshot_id in seen: + raise ValueError("one write batch must not contain duplicate business keys") + seen.add(snapshot.snapshot_id) + if snapshot.snapshot_id in self.source_snapshots: + unchanged += 1 + else: + self.source_snapshots[snapshot.snapshot_id] = snapshot + inserted += 1 + return WriteCounts(inserted, unchanged) + + def save_publication_sources(self, records: Iterable[PublicationSourceRecord]) -> WriteCounts: + """Checkpoint completed source groups under one publication attempt.""" + + items = tuple(records) + for item in items: + if item.publication_id not in self.publications: + raise ValueError("publication source publication does not exist") + if item.snapshot.snapshot_id not in self.source_snapshots: + raise ValueError("publication source snapshot does not exist") + return self._insert_immutable( + self.publication_sources, + items, + key=lambda item: ( + item.publication_id, + item.source_group.value, + item.source_order, + ), + ) + + def load_publication_sources(self, publication_id: str) -> Sequence[PublicationSourceRecord]: + """Load source checkpoints in stable group and request order.""" + + return tuple( + sorted( + ( + item + for item in self.publication_sources.values() + if item.publication_id == publication_id + ), + key=lambda item: (item.source_group.value, item.source_order), + ) + ) + + def mark_publication_sources_for_retry( + self, + publication_id: str, + source_groups: Sequence[PublicationSourceGroup], + ) -> None: + """Mark only incomplete source groups for a future partial retry.""" + + requested = set(source_groups) + for key, record in tuple(self.publication_sources.items()): + if record.publication_id == publication_id and record.source_group in requested: + self.publication_sources[key] = replace(record, refresh_on_retry=True) + + def save_memberships(self, records: Iterable[MembershipRecord]) -> WriteCounts: + """Insert membership rows without overwriting an earlier source revision.""" + + return self._insert_immutable( + self.memberships, + records, + key=lambda item: ( + item.source_snapshot_id, + item.sector_code, + item.membership_key, + ), + ) + + def save_stock_facts(self, records: Iterable[StockFactRecord]) -> WriteCounts: + """Insert normalized fact revisions idempotently.""" + + return self._insert_immutable( + self.stock_facts, + records, + key=lambda item: (item.fact_revision, item.ts_code), + ) + + def save_daily_aggregates(self, records: Iterable[DailyAggregateRecord]) -> WriteCounts: + """Insert publication-owned exact strategy inputs idempotently.""" + + items = tuple(records) + for item in items: + if item.publication_id not in self.publications: + raise ValueError("daily aggregate publication does not exist") + return self._insert_immutable( + self.daily_aggregates, + items, + key=lambda item: ( + item.publication_id, + item.aggregate.sector_type.value, + item.aggregate.sector_code, + ), + ) + + def create_publication(self, publication: RadarPublication) -> WriteCounts: + """Create one running publication without replacing an existing identity.""" + + if publication.status is not PublicationStatus.RUNNING: + raise ValueError("new publications must start in running status") + if any( + item.status is PublicationStatus.RUNNING + and item.target_trade_date == publication.target_trade_date + and item.publication_id != publication.publication_id + for item in self.publications.values() + ): + raise ValueError("target date already has a running publication") + return self._insert_immutable( + self.publications, + (publication,), + key=lambda item: item.publication_id, + ) + + def finish_publication(self, publication: RadarPublication) -> None: + """Apply the sole allowed mutation: running to one terminal audit state.""" + + if publication.status is PublicationStatus.RUNNING: + raise ValueError("finished publication must use a terminal status") + current = self.publications.get(publication.publication_id) + if current is None or current.status is not PublicationStatus.RUNNING: + raise ValueError("publication must exist in running status") + if current.target_trade_date != publication.target_trade_date: + raise ValueError("publication target_trade_date cannot change") + self.publications[publication.publication_id] = publication + + def finalize_publication( + self, + publication: RadarPublication, + *, + memberships: Iterable[MembershipRecord], + stock_facts: Iterable[StockFactRecord], + daily_aggregates: Iterable[DailyAggregateRecord], + rankings: Iterable[RankingRecord], + retry_source_groups: Sequence[PublicationSourceGroup] = (), + ) -> None: + """Atomically expose all derived rows and the terminal publication in tests.""" + + previous = ( + self.memberships.copy(), + self.stock_facts.copy(), + self.daily_aggregates.copy(), + self.rankings.copy(), + self.publication_sources.copy(), + self.publications.copy(), + ) + try: + self.save_memberships(memberships) + self.save_stock_facts(stock_facts) + self.save_daily_aggregates(daily_aggregates) + self.save_rankings(rankings) + self.mark_publication_sources_for_retry( + publication.publication_id, + retry_source_groups, + ) + self.finish_publication(publication) + except Exception: + ( + self.memberships, + self.stock_facts, + self.daily_aggregates, + self.rankings, + self.publication_sources, + self.publications, + ) = previous + raise + + def recover_running_publications( + self, target_trade_date: date, *, finished_at: datetime + ) -> Sequence[str]: + """Fail orphaned attempts after the caller has acquired the date lock.""" + + recovered: list[str] = [] + for publication_id, publication in tuple(self.publications.items()): + if ( + publication.target_trade_date == target_trade_date + and publication.status is PublicationStatus.RUNNING + ): + self.publications[publication_id] = replace( + publication, + status=PublicationStatus.FAILED, + finished_at=finished_at, + error_summary="recovered_stale_running", + ) + recovered.append(publication_id) + return tuple(sorted(recovered)) + + def discard_running_publication(self, publication_id: str) -> None: + """Remove only a provisional duplicate attempt and its owned projections.""" + + publication = self.publications.get(publication_id) + if publication is None or publication.status is not PublicationStatus.RUNNING: + raise ValueError("discarded publication must exist in running status") + del self.publications[publication_id] + self.publication_sources = { + key: item + for key, item in self.publication_sources.items() + if item.publication_id != publication_id + } + self.daily_aggregates = { + key: item + for key, item in self.daily_aggregates.items() + if item.publication_id != publication_id + } + self.rankings = { + key: item + for key, item in self.rankings.items() + if item.publication_id != publication_id + } + + def save_rankings(self, records: Iterable[RankingRecord]) -> WriteCounts: + """Insert publication-owned rankings idempotently.""" + + items = tuple(records) + for item in items: + if item.publication_id not in self.publications: + raise ValueError("ranking publication does not exist") + return self._insert_immutable( + self.rankings, + items, + key=lambda item: ( + item.publication_id, + item.ranking.observation.sector_type.value, + item.ranking.observation.sector_code, + item.ranking.observation.metric_version, + ), + ) + + def get_publication(self, publication_id: str) -> RadarPublication | None: + """Return one publication revision by identity.""" + + return self.publications.get(publication_id) + + def find_reusable_publication( + self, target_trade_date: date, input_hash: str + ) -> RadarPublication | None: + """Find an identical success or partial revision without hiding failures.""" + + return max( + ( + item + for item in self.publications.values() + if item.status in {PublicationStatus.SUCCESS, PublicationStatus.PARTIAL} + and item.target_trade_date == target_trade_date + and item.input_hash == input_hash + ), + key=lambda item: (item.finished_at or item.started_at, item.publication_id), + default=None, + ) + + def get_last_good_publication( + self, target_trade_date: date | None = None + ) -> RadarPublication | None: + """Return only a successful publication; partial and failed never qualify.""" + + candidates = tuple( + publication + for publication in self.publications.values() + if publication.status is PublicationStatus.SUCCESS + and (target_trade_date is None or publication.target_trade_date <= target_trade_date) + ) + return max( + candidates, + key=lambda item: ( + item.target_trade_date, + item.finished_at or item.started_at, + item.publication_id, + ), + default=None, + ) + + def get_successful_publication(self, target_trade_date: date) -> RadarPublication | None: + """Return the latest successful revision for exactly one date.""" + + candidates = tuple( + publication + for publication in self.publications.values() + if publication.status is PublicationStatus.SUCCESS + and publication.target_trade_date == target_trade_date + ) + return max( + candidates, + key=lambda item: (item.finished_at or item.started_at, item.publication_id), + default=None, + ) + + def get_latest_publication(self) -> RadarPublication | None: + """Return the newest build attempt regardless of terminal status.""" + + return max( + self.publications.values(), + key=lambda item: (item.target_trade_date, item.started_at, item.publication_id), + default=None, + ) + + def list_successful_dates(self) -> Sequence[date]: + """Return distinct successful dates newest first.""" + + return tuple( + sorted( + { + item.target_trade_date + for item in self.publications.values() + if item.status is PublicationStatus.SUCCESS + }, + reverse=True, + ) + ) + + def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]: + """Load every ranking projection owned by one publication.""" + + return tuple( + sorted( + ( + record.ranking + for record in self.rankings.values() + if record.publication_id == publication_id + ), + key=lambda row: ( + row.observation.sector_type.value, + row.observation.metric_version, + row.rank_position is None, + row.rank_position or 0, + row.observation.sector_code, + ), + ) + ) + + def load_daily_aggregate_history( + self, target_trade_date: date, *, limit_dates: int + ) -> Sequence[SectorDailyAggregate]: + """Load aggregates from the latest successful revision of prior dates.""" + + dates = self._previous_successful_dates(target_trade_date, limit_dates) + selected_publications = { + self._latest_success_for_date(item).publication_id for item in dates + } + return tuple( + record.aggregate + for record in self.daily_aggregates.values() + if record.publication_id in selected_publications + ) + + def load_previous_rankings( + self, target_trade_date: date, *, limit_dates: int + ) -> Sequence[tuple[date, Sequence[RankedMetric]]]: + """Load prior successful rankings newest first for rank-change attachment.""" + + result: list[tuple[date, Sequence[RankedMetric]]] = [] + for trade_date in self._previous_successful_dates(target_trade_date, limit_dates): + publication_id = self._latest_success_for_date(trade_date).publication_id + result.append( + ( + trade_date, + tuple( + record.ranking + for record in self.rankings.values() + if record.publication_id == publication_id + ), + ) + ) + return tuple(result) + + def _previous_successful_dates(self, target_trade_date: date, limit: int) -> tuple[date, ...]: + if limit < 1: + raise ValueError("limit_dates must be positive") + return tuple( + sorted( + { + item.target_trade_date + for item in self.publications.values() + if item.status is PublicationStatus.SUCCESS + and item.target_trade_date < target_trade_date + }, + reverse=True, + )[:limit] + ) + + def _latest_success_for_date(self, trade_date: date) -> RadarPublication: + return max( + ( + item + for item in self.publications.values() + if item.status is PublicationStatus.SUCCESS and item.target_trade_date == trade_date + ), + key=lambda item: (item.finished_at or item.started_at, item.publication_id), + ) + + @staticmethod + def _insert_immutable[K, V]( + target: dict[K, V], + values: Iterable[V], + *, + key: Callable[[V], K], + ) -> WriteCounts: + inserted = 0 + unchanged = 0 + seen: set[K] = set() + for value in values: + item_key = key(value) + if item_key in seen: + raise ValueError("one write batch must not contain duplicate business keys") + seen.add(item_key) + existing = target.get(item_key) + if existing is None: + target[item_key] = value + inserted += 1 + elif existing == value: + unchanged += 1 + else: + raise ValueError("immutable revision identity cannot change content") + return WriteCounts(inserted=inserted, unchanged=unchanged) diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py new file mode 100644 index 0000000..1b5be9e --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py @@ -0,0 +1,1042 @@ +"""Psycopg repository for replayable sector radar inputs and publications.""" + +from __future__ import annotations + +import threading +from collections.abc import Callable, Generator, Iterable, Sequence +from contextlib import contextmanager +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from psycopg.types.json import Jsonb +from psycopg_pool import ConnectionPool + +from ..domain.models import ( + MetricKind, + MetricObservation, + MetricQuality, + MetricUnit, + PublicationStatus, + RadarPublication, + RankChange, + RankedMetric, + SectorDailyAggregate, + SectorType, +) +from ..domain.persistence import ( + DailyAggregateRecord, + MembershipRecord, + PublicationSourceGroup, + PublicationSourceRecord, + RankingRecord, + StockFactRecord, + WriteCounts, +) +from ..domain.source import SourceSnapshot + + +class SectorRadarRepositoryError(RuntimeError): + """PostgreSQL could not complete a radar repository operation safely.""" + + +class PostgresSectorRadarRepository: + """Persist immutable input revisions with COPY staging and strict last-good reads.""" + + def __init__( + self, + database_url: str, + *, + advisory_lock_key: int = 7_380_522, + max_connections: int = 4, + pool: ConnectionPool[Any] | None = None, + ) -> None: + if max_connections < 1: + raise ValueError("max_connections must be at least 1") + self.database_url = database_url + self.advisory_lock_key = advisory_lock_key + self.pool = pool or ConnectionPool( + conninfo=database_url, + min_size=1, + max_size=max_connections, + open=False, + ) + self._owns_pool = pool is None + self._pool_open = False + self._pool_state_lock = threading.Lock() + + def open(self) -> None: + """Open the owned or injected pool exactly once.""" + + with self._pool_state_lock: + if self._pool_open: + return + if bool(getattr(self.pool, "_opened", False)): + self._pool_open = True + return + self.pool.open(wait=True) + self._pool_open = True + + def close(self) -> None: + """Close only a pool owned by this repository.""" + + with self._pool_state_lock: + if self._owns_pool and (self._pool_open or bool(getattr(self.pool, "_opened", False))): + self.pool.close() + self._pool_open = False + + def __enter__(self) -> PostgresSectorRadarRepository: + self.open() + return self + + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + self.close() + + @contextmanager + def advisory_lock(self, target_trade_date: date) -> Generator[bool]: + """Hold a session advisory lock for one target date and build lifetime.""" + + lock_name = f"sector-radar:{self.advisory_lock_key}:{target_trade_date.isoformat()}" + with self._connection() as connection: + row = connection.execute( + "SELECT pg_try_advisory_lock(hashtext(%s))", + (lock_name,), + ).fetchone() + acquired = bool(row[0]) if row is not None else False + if not acquired: + yield False + return + try: + yield True + finally: + connection.execute( + "SELECT pg_advisory_unlock(hashtext(%s))", + (lock_name,), + ) + + def save_source_snapshots(self, snapshots: Iterable[SourceSnapshot]) -> WriteCounts: + """Insert content-addressed raw snapshots without replacing old payloads.""" + + items = tuple(snapshots) + self._require_unique(items, lambda item: item.snapshot_id) + if not items: + return WriteCounts(0, 0) + with self._connection() as connection, connection.transaction(): + existing = { + str(row[0]) + for row in connection.execute( + "SELECT id FROM sector_radar_source_snapshot WHERE id = ANY(%s)", + ([item.snapshot_id for item in items],), + ).fetchall() + } + connection.cursor().executemany( + """ + INSERT INTO sector_radar_source_snapshot ( + id, api_name, normalized_params, target_trade_date, partition_key, + observed_at, payload, row_count, returned_fields, content_sha256, + row_limit, limit_reached + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (id) DO NOTHING + """, + tuple( + ( + item.snapshot_id, + item.api_name, + Jsonb(dict(item.normalized_params)), + item.target_trade_date, + item.partition_key, + item.observed_at, + Jsonb(list(item.rows)), + item.row_count, + Jsonb(list(item.returned_fields)), + item.content_sha256, + item.row_limit, + item.limit_reached, + ) + for item in items + ), + ) + unchanged = len(existing) + return WriteCounts(inserted=len(items) - unchanged, unchanged=unchanged) + + def save_publication_sources(self, records: Iterable[PublicationSourceRecord]) -> WriteCounts: + """Checkpoint ordered source snapshots under a running publication.""" + + items = tuple(records) + self._require_unique( + items, + lambda item: (item.publication_id, item.source_group, item.source_order), + ) + return self._copy_immutable( + "sector_radar_publication_source", + ( + "publication_id", + "source_group", + "source_order", + "source_snapshot_id", + "refresh_on_retry", + ), + ("publication_id", "source_group", "source_order"), + tuple( + ( + item.publication_id, + item.source_group.value, + item.source_order, + item.snapshot.snapshot_id, + item.refresh_on_retry, + ) + for item in items + ), + ) + + def load_publication_sources(self, publication_id: str) -> Sequence[PublicationSourceRecord]: + """Load source checkpoints with their sanitized raw snapshots.""" + + with self._connection() as connection: + rows = connection.execute( + """ + SELECT link.publication_id, link.source_group, link.source_order, + snapshot.id, snapshot.api_name, snapshot.normalized_params, + snapshot.target_trade_date, snapshot.partition_key, + snapshot.observed_at, snapshot.payload, snapshot.row_count, + snapshot.returned_fields, snapshot.content_sha256, + snapshot.row_limit, snapshot.limit_reached, + link.refresh_on_retry + FROM sector_radar_publication_source AS link + JOIN sector_radar_source_snapshot AS snapshot + ON snapshot.id = link.source_snapshot_id + WHERE link.publication_id = %s + ORDER BY link.source_group, link.source_order + """, + (publication_id,), + ).fetchall() + return tuple( + PublicationSourceRecord( + publication_id=str(row[0]), + source_group=PublicationSourceGroup(str(row[1])), + source_order=int(row[2]), + snapshot=self._snapshot_from_row(row[3:]), + refresh_on_retry=bool(row[15]), + ) + for row in rows + ) + + def mark_publication_sources_for_retry( + self, + publication_id: str, + source_groups: Sequence[PublicationSourceGroup], + ) -> None: + """Mark only incomplete source groups for a future partial retry.""" + + if not source_groups: + return + with self._connection() as connection, connection.transaction(): + connection.execute( + """ + UPDATE sector_radar_publication_source + SET refresh_on_retry = TRUE + WHERE publication_id = %s AND source_group = ANY(%s) + """, + (publication_id, [group.value for group in source_groups]), + ) + + def save_memberships(self, records: Iterable[MembershipRecord]) -> WriteCounts: + """COPY point-in-time members into an immutable revision key.""" + + items = tuple(records) + self._require_unique( + items, + lambda item: ( + item.source_snapshot_id, + item.sector_code, + item.membership_key, + ), + ) + rows = tuple( + ( + item.source_snapshot_id, + item.trade_date, + item.sector_type.value, + item.sector_code, + item.sector_name, + item.membership_key, + item.stock_code, + item.stock_name, + item.status.value, + ) + for item in items + ) + return self._copy_immutable( + "sector_radar_membership", + ( + "source_snapshot_id", + "trade_date", + "sector_type", + "sector_code", + "sector_name", + "membership_key", + "stock_code", + "stock_name", + "membership_status", + ), + ("source_snapshot_id", "sector_code", "membership_key"), + rows, + ) + + def save_stock_facts(self, records: Iterable[StockFactRecord]) -> WriteCounts: + """COPY normalized stock facts while preserving contributing source ids.""" + + items = tuple(records) + self._require_unique(items, lambda item: (item.fact_revision, item.ts_code)) + rows = tuple( + ( + item.fact_revision, + item.trade_date, + item.ts_code, + Jsonb(list(item.source_snapshot_ids)), + item.status.value, + item.turnover_yuan, + item.net_amount_yuan, + ) + for item in items + ) + return self._copy_immutable( + "sector_radar_stock_fact", + ( + "fact_revision", + "trade_date", + "ts_code", + "source_snapshot_ids", + "status", + "turnover_yuan", + "net_amount_yuan", + ), + ("fact_revision", "ts_code"), + rows, + ) + + def save_daily_aggregates(self, records: Iterable[DailyAggregateRecord]) -> WriteCounts: + """COPY exact daily metric inputs under their publication revision.""" + + items = tuple(records) + self._require_unique( + items, + lambda item: ( + item.publication_id, + item.aggregate.sector_type, + item.aggregate.sector_code, + ), + ) + rows = tuple( + ( + item.publication_id, + item.aggregate.trade_date, + item.aggregate.sector_type.value, + item.aggregate.sector_code, + item.aggregate.sector_name, + item.aggregate.member_count, + item.aggregate.valid_sample_count, + item.aggregate.net_amount_yuan, + item.aggregate.turnover_yuan, + item.aggregate.membership_coverage, + item.aggregate.moneyflow_coverage, + ) + for item in items + ) + return self._copy_immutable( + "sector_radar_daily_aggregate", + ( + "publication_id", + "trade_date", + "sector_type", + "sector_code", + "sector_name", + "member_count", + "valid_sample_count", + "net_amount_yuan", + "turnover_yuan", + "membership_coverage", + "moneyflow_coverage", + ), + ("publication_id", "sector_type", "sector_code"), + rows, + ) + + def create_publication(self, publication: RadarPublication) -> WriteCounts: + """Insert a new running publication identity idempotently.""" + + if publication.status is not PublicationStatus.RUNNING: + raise ValueError("new publications must start in running status") + with self._connection() as connection, connection.transaction(): + existing = connection.execute( + "SELECT status, target_trade_date FROM sector_radar_publication WHERE id = %s", + (publication.publication_id,), + ).fetchone() + if existing is not None: + if ( + str(existing[0]) != publication.status.value + or existing[1] != publication.target_trade_date + ): + raise SectorRadarRepositoryError("publication identity has conflicting content") + return WriteCounts(0, 1) + connection.execute( + """ + INSERT INTO sector_radar_publication ( + id, target_trade_date, status, source_version, universe_version, + metric_versions, input_hash, coverage, started_at, finished_at, error_summary + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + self._publication_values(publication), + ) + return WriteCounts(1, 0) + + def finish_publication(self, publication: RadarPublication) -> None: + """Transition one running publication to a terminal audit state.""" + + if publication.status is PublicationStatus.RUNNING: + raise ValueError("finished publication must use a terminal status") + with self._connection() as connection, connection.transaction(): + self._finish_publication_on_connection(connection, publication) + + def finalize_publication( + self, + publication: RadarPublication, + *, + memberships: Iterable[MembershipRecord], + stock_facts: Iterable[StockFactRecord], + daily_aggregates: Iterable[DailyAggregateRecord], + rankings: Iterable[RankingRecord], + retry_source_groups: Sequence[PublicationSourceGroup] = (), + ) -> None: + """Commit all derived projections and the terminal state in one transaction.""" + + if publication.status is PublicationStatus.RUNNING: + raise ValueError("finalized publication must use a terminal status") + membership_items = tuple(memberships) + stock_items = tuple(stock_facts) + aggregate_items = tuple(daily_aggregates) + ranking_items = tuple(rankings) + self._require_unique( + membership_items, + lambda item: ( + item.source_snapshot_id, + item.sector_code, + item.membership_key, + ), + ) + self._require_unique(stock_items, lambda item: (item.fact_revision, item.ts_code)) + self._require_unique( + aggregate_items, + lambda item: ( + item.publication_id, + item.aggregate.sector_type, + item.aggregate.sector_code, + ), + ) + self._require_unique( + ranking_items, + lambda item: ( + item.publication_id, + item.ranking.observation.sector_type, + item.ranking.observation.sector_code, + item.ranking.observation.metric_version, + ), + ) + with self._connection() as connection, connection.transaction(): + self._copy_immutable_on_connection( + connection, + "sector_radar_membership", + ( + "source_snapshot_id", + "trade_date", + "sector_type", + "sector_code", + "sector_name", + "membership_key", + "stock_code", + "stock_name", + "membership_status", + ), + ("source_snapshot_id", "sector_code", "membership_key"), + tuple( + ( + item.source_snapshot_id, + item.trade_date, + item.sector_type.value, + item.sector_code, + item.sector_name, + item.membership_key, + item.stock_code, + item.stock_name, + item.status.value, + ) + for item in membership_items + ), + ) + self._copy_immutable_on_connection( + connection, + "sector_radar_stock_fact", + ( + "fact_revision", + "trade_date", + "ts_code", + "source_snapshot_ids", + "status", + "turnover_yuan", + "net_amount_yuan", + ), + ("fact_revision", "ts_code"), + tuple( + ( + item.fact_revision, + item.trade_date, + item.ts_code, + Jsonb(list(item.source_snapshot_ids)), + item.status.value, + item.turnover_yuan, + item.net_amount_yuan, + ) + for item in stock_items + ), + ) + self._copy_immutable_on_connection( + connection, + "sector_radar_daily_aggregate", + ( + "publication_id", + "trade_date", + "sector_type", + "sector_code", + "sector_name", + "member_count", + "valid_sample_count", + "net_amount_yuan", + "turnover_yuan", + "membership_coverage", + "moneyflow_coverage", + ), + ("publication_id", "sector_type", "sector_code"), + tuple( + ( + item.publication_id, + item.aggregate.trade_date, + item.aggregate.sector_type.value, + item.aggregate.sector_code, + item.aggregate.sector_name, + item.aggregate.member_count, + item.aggregate.valid_sample_count, + item.aggregate.net_amount_yuan, + item.aggregate.turnover_yuan, + item.aggregate.membership_coverage, + item.aggregate.moneyflow_coverage, + ) + for item in aggregate_items + ), + ) + self._copy_immutable_on_connection( + connection, + "sector_radar_ranking", + ( + "publication_id", + "trade_date", + "sector_type", + "sector_code", + "sector_name", + "metric_kind", + "metric_version", + "implementation_kind", + "unit", + "metric_value", + "quality", + "member_count", + "valid_sample_count", + "membership_coverage", + "moneyflow_coverage", + "rank_position", + "rank_percentile", + "rank_changes", + ), + ("publication_id", "sector_type", "sector_code", "metric_version"), + self._ranking_rows(ranking_items), + ) + if retry_source_groups: + connection.execute( + "UPDATE sector_radar_publication_source SET refresh_on_retry = TRUE " + "WHERE publication_id = %s AND source_group = ANY(%s)", + ( + publication.publication_id, + [group.value for group in retry_source_groups], + ), + ) + self._finish_publication_on_connection(connection, publication) + + def recover_running_publications( + self, target_trade_date: date, *, finished_at: datetime + ) -> Sequence[str]: + """Fail orphaned attempts after the caller has acquired the date lock.""" + + with self._connection() as connection, connection.transaction(): + rows = connection.execute( + """ + UPDATE sector_radar_publication + SET status = 'failed', finished_at = %s, + error_summary = 'recovered_stale_running' + WHERE target_trade_date = %s AND status = 'running' + RETURNING id + """, + (finished_at, target_trade_date), + ).fetchall() + return tuple(sorted(str(row[0]) for row in rows)) + + def discard_running_publication(self, publication_id: str) -> None: + """Delete only a provisional duplicate publication and owned projections.""" + + with self._connection() as connection, connection.transaction(): + result = connection.execute( + "DELETE FROM sector_radar_publication WHERE id = %s AND status = 'running'", + (publication_id,), + ) + if result.rowcount != 1: + raise SectorRadarRepositoryError("publication is not in running status") + + def save_rankings(self, records: Iterable[RankingRecord]) -> WriteCounts: + """COPY versioned ranking projections under one publication revision.""" + + items = tuple(records) + self._require_unique( + items, + lambda item: ( + item.publication_id, + item.ranking.observation.sector_type, + item.ranking.observation.sector_code, + item.ranking.observation.metric_version, + ), + ) + return self._copy_immutable( + "sector_radar_ranking", + ( + "publication_id", + "trade_date", + "sector_type", + "sector_code", + "sector_name", + "metric_kind", + "metric_version", + "implementation_kind", + "unit", + "metric_value", + "quality", + "member_count", + "valid_sample_count", + "membership_coverage", + "moneyflow_coverage", + "rank_position", + "rank_percentile", + "rank_changes", + ), + ("publication_id", "sector_type", "sector_code", "metric_version"), + self._ranking_rows(items), + ) + + def get_publication(self, publication_id: str) -> RadarPublication | None: + """Read one publication by immutable identity.""" + + with self._connection() as connection: + row = connection.execute( + self._publication_select() + " WHERE id = %s", + (publication_id,), + ).fetchone() + return None if row is None else self._publication_from_row(row) + + def find_reusable_publication( + self, target_trade_date: date, input_hash: str + ) -> RadarPublication | None: + """Find an identical success or partial input revision for idempotent reruns.""" + + with self._connection() as connection: + row = connection.execute( + self._publication_select() + " WHERE target_trade_date = %s AND input_hash = %s " + "AND status IN ('success', 'partial') " + "ORDER BY finished_at DESC, id DESC LIMIT 1", + (target_trade_date, input_hash), + ).fetchone() + return None if row is None else self._publication_from_row(row) + + def get_last_good_publication( + self, target_trade_date: date | None = None + ) -> RadarPublication | None: + """Read only status=success, optionally bounded by a requested date.""" + + where = " WHERE status = 'success'" + parameters: tuple[object, ...] = () + if target_trade_date is not None: + where += " AND target_trade_date <= %s" + parameters = (target_trade_date,) + query = ( + self._publication_select() + + where + + " ORDER BY target_trade_date DESC, finished_at DESC, id DESC LIMIT 1" + ) + with self._connection() as connection: + row = connection.execute(query, parameters).fetchone() + return None if row is None else self._publication_from_row(row) + + def get_successful_publication(self, target_trade_date: date) -> RadarPublication | None: + """Read the latest successful revision for exactly one target date.""" + + with self._connection() as connection: + row = connection.execute( + self._publication_select() + " WHERE status = 'success' AND target_trade_date = %s " + "ORDER BY finished_at DESC, id DESC LIMIT 1", + (target_trade_date,), + ).fetchone() + return None if row is None else self._publication_from_row(row) + + def get_latest_publication(self) -> RadarPublication | None: + """Read the newest build attempt regardless of terminal status.""" + + with self._connection() as connection: + row = connection.execute( + self._publication_select() + + " ORDER BY target_trade_date DESC, started_at DESC, id DESC LIMIT 1" + ).fetchone() + return None if row is None else self._publication_from_row(row) + + def list_successful_dates(self) -> Sequence[date]: + """List distinct successful target dates newest first.""" + + with self._connection() as connection: + rows = connection.execute( + """ + SELECT DISTINCT target_trade_date + FROM sector_radar_publication + WHERE status = 'success' + ORDER BY target_trade_date DESC + """ + ).fetchall() + return tuple(row[0] for row in rows) + + def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]: + """Load all ranking projections for one publication in deterministic order.""" + + with self._connection() as connection: + rows = connection.execute( + """ + SELECT trade_date, sector_type, sector_code, sector_name, metric_kind, + metric_version, implementation_kind, unit, metric_value, quality, + member_count, valid_sample_count, membership_coverage, + moneyflow_coverage, rank_position, rank_percentile, rank_changes + FROM sector_radar_ranking + WHERE publication_id = %s + ORDER BY sector_type, metric_version, rank_position NULLS LAST, sector_code + """, + (publication_id,), + ).fetchall() + return tuple(self._ranking_from_row(row) for row in rows) + + def load_daily_aggregate_history( + self, target_trade_date: date, *, limit_dates: int + ) -> Sequence[SectorDailyAggregate]: + """Load prior aggregates from the latest successful revision per date.""" + + if limit_dates < 1: + raise ValueError("limit_dates must be positive") + with self._connection() as connection: + rows = connection.execute( + """ + WITH selected AS ( + SELECT DISTINCT ON (target_trade_date) id, target_trade_date + FROM sector_radar_publication + WHERE status = 'success' AND target_trade_date < %s + ORDER BY target_trade_date DESC, finished_at DESC, id DESC + LIMIT %s + ) + SELECT aggregate.trade_date, aggregate.sector_type, aggregate.sector_code, + aggregate.sector_name, aggregate.member_count, + aggregate.valid_sample_count, aggregate.net_amount_yuan, + aggregate.turnover_yuan, aggregate.membership_coverage, + aggregate.moneyflow_coverage + FROM sector_radar_daily_aggregate AS aggregate + JOIN selected ON selected.id = aggregate.publication_id + ORDER BY aggregate.trade_date, aggregate.sector_type, aggregate.sector_code + """, + (target_trade_date, limit_dates), + ).fetchall() + return tuple(self._aggregate_from_row(row) for row in rows) + + def load_previous_rankings( + self, target_trade_date: date, *, limit_dates: int + ) -> Sequence[tuple[date, Sequence[RankedMetric]]]: + """Load prior successful rankings newest-first for rank-change calculation.""" + + if limit_dates < 1: + raise ValueError("limit_dates must be positive") + with self._connection() as connection: + rows = connection.execute( + """ + WITH selected AS ( + SELECT DISTINCT ON (target_trade_date) id, target_trade_date + FROM sector_radar_publication + WHERE status = 'success' AND target_trade_date < %s + ORDER BY target_trade_date DESC, finished_at DESC, id DESC + LIMIT %s + ) + SELECT selected.target_trade_date, ranking.trade_date, ranking.sector_type, + ranking.sector_code, ranking.sector_name, ranking.metric_kind, + ranking.metric_version, ranking.implementation_kind, ranking.unit, + ranking.metric_value, ranking.quality, ranking.member_count, + ranking.valid_sample_count, ranking.membership_coverage, + ranking.moneyflow_coverage, ranking.rank_position, + ranking.rank_percentile, ranking.rank_changes + FROM sector_radar_ranking AS ranking + JOIN selected ON selected.id = ranking.publication_id + ORDER BY selected.target_trade_date DESC, ranking.sector_type, + ranking.metric_version, ranking.rank_position NULLS LAST, + ranking.sector_code + """, + (target_trade_date, limit_dates), + ).fetchall() + grouped: dict[date, list[RankedMetric]] = {} + for row in rows: + grouped.setdefault(row[0], []).append(self._ranking_from_row(row[1:])) + return tuple( + (trade_date, tuple(grouped[trade_date])) for trade_date in sorted(grouped, reverse=True) + ) + + def _copy_immutable( + self, + table: str, + columns: tuple[str, ...], + conflict_columns: tuple[str, ...], + rows: tuple[tuple[object, ...], ...], + ) -> WriteCounts: + if not rows: + return WriteCounts(0, 0) + if table not in { + "sector_radar_publication_source", + "sector_radar_membership", + "sector_radar_stock_fact", + "sector_radar_daily_aggregate", + "sector_radar_ranking", + }: + raise ValueError("unsupported radar staging table") + with self._connection() as connection, connection.transaction(): + return self._copy_immutable_on_connection( + connection, + table, + columns, + conflict_columns, + rows, + ) + + @staticmethod + def _copy_immutable_on_connection( + connection: Any, + table: str, + columns: tuple[str, ...], + conflict_columns: tuple[str, ...], + rows: tuple[tuple[object, ...], ...], + ) -> WriteCounts: + if not rows: + return WriteCounts(0, 0) + stage = f"{table}_stage" + column_sql = ", ".join(columns) + conflict_sql = ", ".join(conflict_columns) + cursor = connection.cursor() + cursor.execute( + f"CREATE TEMP TABLE {stage} (LIKE {table} INCLUDING DEFAULTS) ON COMMIT DROP" + ) + with cursor.copy(f"COPY {stage} ({column_sql}) FROM STDIN") as copy: + for row in rows: + copy.write_row(row) + inserted_rows = cursor.execute( + f"INSERT INTO {table} ({column_sql}) SELECT {column_sql} FROM {stage} " + f"ON CONFLICT ({conflict_sql}) DO NOTHING RETURNING 1" + ).fetchall() + inserted = len(inserted_rows) + return WriteCounts(inserted=inserted, unchanged=len(rows) - inserted) + + @contextmanager + def _connection(self) -> Generator[Any]: + try: + self.open() + with self.pool.connection() as connection: + yield connection + except SectorRadarRepositoryError: + raise + except Exception as exc: # noqa: BLE001 - database errors are redacted at this boundary + raise SectorRadarRepositoryError("sector radar database operation failed") from exc + + @staticmethod + def _require_unique[T, K](items: Sequence[T], key: Callable[[T], K]) -> None: + keys = [key(item) for item in items] + if len(keys) != len(set(keys)): + raise ValueError("one write batch must not contain duplicate business keys") + + @staticmethod + def _publication_values(publication: RadarPublication) -> tuple[object, ...]: + return ( + publication.publication_id, + publication.target_trade_date, + publication.status.value, + publication.source_version, + publication.universe_version, + Jsonb(list(publication.metric_versions)), + publication.input_hash, + publication.coverage, + publication.started_at, + publication.finished_at, + PostgresSectorRadarRepository._safe_error(publication.error_summary), + ) + + @staticmethod + def _publication_select() -> str: + return ( + "SELECT id, target_trade_date, status, source_version, universe_version, " + "metric_versions, input_hash, coverage, started_at, finished_at, error_summary " + "FROM sector_radar_publication" + ) + + @staticmethod + def _publication_from_row(row: tuple[Any, ...]) -> RadarPublication: + return RadarPublication( + publication_id=str(row[0]), + target_trade_date=row[1], + status=PublicationStatus(str(row[2])), + source_version=str(row[3]), + universe_version=str(row[4]), + metric_versions=tuple(str(value) for value in row[5]), + input_hash=None if row[6] is None else str(row[6]), + coverage=Decimal(str(row[7])), + started_at=row[8], + finished_at=row[9], + error_summary=None if row[10] is None else str(row[10]), + ) + + @staticmethod + def _ranking_rows(items: Sequence[RankingRecord]) -> tuple[tuple[object, ...], ...]: + rows: list[tuple[object, ...]] = [] + for item in items: + ranking = item.ranking + observation = ranking.observation + rows.append( + ( + item.publication_id, + observation.trade_date, + observation.sector_type.value, + observation.sector_code, + observation.sector_name, + observation.metric_kind.value, + observation.metric_version, + observation.implementation_kind, + observation.unit.value, + observation.value, + observation.quality.value, + observation.member_count, + observation.valid_sample_count, + observation.membership_coverage, + observation.moneyflow_coverage, + ranking.rank_position, + ranking.rank_percentile, + Jsonb({str(change.days): change.value for change in ranking.rank_changes}), + ) + ) + return tuple(rows) + + @staticmethod + def _finish_publication_on_connection( + connection: Any, + publication: RadarPublication, + ) -> None: + result = connection.execute( + """ + UPDATE sector_radar_publication + SET status = %s, source_version = %s, universe_version = %s, + metric_versions = %s, input_hash = %s, coverage = %s, + finished_at = %s, error_summary = %s + WHERE id = %s AND target_trade_date = %s AND status = 'running' + """, + ( + publication.status.value, + publication.source_version, + publication.universe_version, + Jsonb(list(publication.metric_versions)), + publication.input_hash, + publication.coverage, + publication.finished_at, + PostgresSectorRadarRepository._safe_error(publication.error_summary), + publication.publication_id, + publication.target_trade_date, + ), + ) + if result.rowcount != 1: + raise SectorRadarRepositoryError("publication is not in running status") + + @staticmethod + def _snapshot_from_row(row: tuple[Any, ...]) -> SourceSnapshot: + normalized_params = row[2] + return SourceSnapshot( + snapshot_id=str(row[0]), + api_name=str(row[1]), + normalized_params=tuple( + sorted((str(key), str(value)) for key, value in normalized_params.items()) + ), + target_trade_date=row[3], + partition_key=None if row[4] is None else str(row[4]), + observed_at=row[5], + rows=tuple(dict(item) for item in row[6]), + row_count=int(row[7]), + returned_fields=tuple(str(value) for value in row[8]), + content_sha256=str(row[9]), + row_limit=None if row[10] is None else int(row[10]), + limit_reached=bool(row[11]), + ) + + @staticmethod + def _aggregate_from_row(row: tuple[Any, ...]) -> SectorDailyAggregate: + return SectorDailyAggregate( + trade_date=row[0], + sector_type=SectorType(str(row[1])), + sector_code=str(row[2]), + sector_name=str(row[3]), + member_count=int(row[4]), + valid_sample_count=int(row[5]), + net_amount_yuan=None if row[6] is None else Decimal(str(row[6])), + turnover_yuan=None if row[7] is None else Decimal(str(row[7])), + membership_coverage=Decimal(str(row[8])), + moneyflow_coverage=Decimal(str(row[9])), + ) + + @staticmethod + def _ranking_from_row(row: tuple[Any, ...]) -> RankedMetric: + raw_changes = row[16] + changes = tuple( + RankChange(days=int(days), value=None if value is None else int(value)) + for days, value in sorted(raw_changes.items(), key=lambda item: int(item[0])) + ) + observation = MetricObservation( + trade_date=row[0], + sector_type=SectorType(str(row[1])), + sector_code=str(row[2]), + sector_name=str(row[3]), + metric_kind=MetricKind(str(row[4])), + metric_version=str(row[5]), + implementation_kind="independent", + unit=MetricUnit(str(row[7])), + value=None if row[8] is None else Decimal(str(row[8])), + quality=MetricQuality(str(row[9])), + member_count=int(row[10]), + valid_sample_count=int(row[11]), + membership_coverage=Decimal(str(row[12])), + moneyflow_coverage=Decimal(str(row[13])), + ) + return RankedMetric( + observation=observation, + rank_position=None if row[14] is None else int(row[14]), + rank_percentile=None if row[15] is None else Decimal(str(row[15])), + rank_changes=changes, + ) + + @staticmethod + def _safe_error(message: str | None) -> str | None: + return None if message is None else " ".join(message.split())[:500] diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py new file mode 100644 index 0000000..c2a8dc3 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py @@ -0,0 +1,636 @@ +"""Tushare adapter for replayable sector radar source facts.""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Callable, Iterable, Mapping, Sequence +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, date, datetime +from typing import TypeVar, cast + +from zhixing_server.shared.request_coordinator import ( + DEFAULT_RATE_LIMIT_COOLDOWNS, + RequestCoordinator, + TushareSourceError, +) + +from ..domain.models import SectorType +from ..domain.source import ( + CapabilityInterfaceResult, + CapabilityProbeResult, + CapabilityStatus, + DailyRow, + MoneyflowDcRow, + SectorIndexRow, + SectorMemberRow, + SourceContractError, + SourceResult, + SourceSnapshot, + SourceTruncatedError, + StockBasicRow, + SuspendRow, + TradeCalendarRow, + build_source_snapshot, +) + +T = TypeVar("T") +logger = logging.getLogger(__name__) + +FIELDS: dict[str, tuple[str, ...]] = { + "trade_cal": ("exchange", "cal_date", "is_open", "pretrade_date"), + "dc_index": ( + "ts_code", + "trade_date", + "name", + "idx_type", + "level", + "pct_change", + "leading_code", + ), + "dc_member": ("trade_date", "ts_code", "con_code", "name"), + "stock_basic": ( + "ts_code", + "symbol", + "name", + "market", + "exchange", + "list_status", + "list_date", + "delist_date", + ), + "suspend_d": ("ts_code", "trade_date", "suspend_timing", "suspend_type"), + "daily": ("ts_code", "trade_date", "close", "pre_close", "pct_chg", "vol", "amount"), + "moneyflow_dc": ( + "trade_date", + "ts_code", + "name", + "net_amount", + "net_amount_rate", + "pct_change", + "close", + ), +} + +ROW_LIMITS: dict[str, int | None] = { + "trade_cal": None, + "dc_index": 5_000, + "dc_member": 5_000, + "stock_basic": None, + "suspend_d": None, + "daily": 6_000, + "moneyflow_dc": 6_000, +} + +_SECTOR_TYPE_PARAM = { + SectorType.CONCEPT: "概念板块", + SectorType.INDUSTRY: "行业板块", +} +_MONEYFLOW_WORKERS = 2 + + +class TushareSectorRadarAdapter: + """Fetch seven Tushare interfaces with schema, limit, and replay metadata.""" + + def __init__( + self, + client: object, + *, + request_coordinator: RequestCoordinator | None = None, + max_retries: int = 3, + backoff_seconds: float = 1.0, + request_interval_seconds: float = 0.2, + cooldown_seconds: Sequence[float] = DEFAULT_RATE_LIMIT_COOLDOWNS, + sleep_fn: Callable[[float], None] = time.sleep, + now_fn: Callable[[], datetime] = lambda: datetime.now(UTC), + ) -> None: + """Create an adapter around one already-authenticated SDK client.""" + + self._client = client + self._now_fn = now_fn + self._coordinator = request_coordinator or RequestCoordinator( + max_retries=max_retries, + backoff_seconds=backoff_seconds, + request_interval_seconds=request_interval_seconds, + cooldown_seconds=cooldown_seconds, + wait_fn=sleep_fn, + sleep_fn=sleep_fn, + ) + + @classmethod + def from_token( + cls, + token: str, + *, + max_retries: int = 3, + backoff_seconds: float = 1.0, + request_interval_seconds: float = 0.2, + cooldown_seconds: Sequence[float] = DEFAULT_RATE_LIMIT_COOLDOWNS, + ) -> TushareSectorRadarAdapter: + """Create a production client without calling ``set_token`` or retaining the token.""" + + if not token.strip(): + raise ValueError("ZHIXING_TUSHARE_TOKEN is required for sector radar") + import tushare as ts # pyright: ignore[reportMissingTypeStubs] + + return cls( + cast(object, ts.pro_api(token)), + max_retries=max_retries, + backoff_seconds=backoff_seconds, + request_interval_seconds=request_interval_seconds, + cooldown_seconds=cooldown_seconds, + ) + + def fetch_trade_calendar(self, start: date, end: date) -> SourceResult[TradeCalendarRow]: + """Fetch and validate an inclusive exchange calendar range.""" + + if end < start: + raise ValueError("end must not precede start") + snapshot = self._fetch_snapshot( + "trade_cal", + { + "exchange": "", + "start_date": start.strftime("%Y%m%d"), + "end_date": end.strftime("%Y%m%d"), + }, + target_trade_date=end, + ) + rows = tuple(TradeCalendarRow.from_mapping(row) for row in snapshot.rows) + self._require_unique( + rows, key=lambda row: (row.exchange, row.cal_date), api_name="trade_cal" + ) + return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.cal_date))) + + def fetch_sector_indices( + self, + trade_date: date, + sector_type: SectorType, + ) -> SourceResult[SectorIndexRow]: + """Fetch one independent concept or industry universe.""" + + idx_type = _SECTOR_TYPE_PARAM[sector_type] + snapshot = self._fetch_snapshot( + "dc_index", + {"trade_date": trade_date.strftime("%Y%m%d"), "idx_type": idx_type}, + target_trade_date=trade_date, + partition_key=sector_type.value, + ) + if any(str(row.get("idx_type")) != idx_type for row in snapshot.rows): + raise SourceContractError("dc_index returned a different idx_type") + self._reject_limit(snapshot) + rows = tuple(SectorIndexRow.from_mapping(row, sector_type) for row in snapshot.rows) + self._require_target_date(rows, trade_date, "dc_index") + self._require_unique(rows, key=lambda row: row.sector_code, api_name="dc_index") + return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.sector_code))) + + def fetch_sector_members( + self, + trade_date: date, + sector_codes: Sequence[str], + ) -> SourceResult[SectorMemberRow]: + """Fetch dated members and partition when the all-market result is incomplete.""" + + expected_codes = tuple(sorted(set(sector_codes))) + if len(expected_codes) != len(sector_codes) or any( + not code.strip() for code in expected_codes + ): + raise ValueError("sector_codes must contain unique non-empty values") + initial = self._fetch_snapshot( + "dc_member", + {"trade_date": trade_date.strftime("%Y%m%d")}, + target_trade_date=trade_date, + partition_key="all", + ) + try: + initial_rows = tuple(SectorMemberRow.from_mapping(row) for row in initial.rows) + self._require_target_date(initial_rows, trade_date, "dc_member") + except SourceContractError as exc: + self._log_contract_failure("dc_member", "all", exc) + raise + returned_codes = {row.sector_code for row in initial_rows} + missing_codes = tuple(code for code in expected_codes if code not in returned_codes) + + if initial.limit_reached: + partition_codes = expected_codes + merged_rows: list[SectorMemberRow] = [] + snapshots: list[SourceSnapshot] = [initial] + else: + partition_codes = missing_codes + merged_rows = list(initial_rows) + snapshots = [initial] + + if initial.limit_reached and not partition_codes: + raise SourceTruncatedError("dc_member reached its limit without sector partitions") + + for sector_code in partition_codes: + snapshot = self._fetch_snapshot( + "dc_member", + { + "trade_date": trade_date.strftime("%Y%m%d"), + "ts_code": sector_code, + }, + target_trade_date=trade_date, + partition_key=sector_code, + ) + try: + self._reject_limit(snapshot) + partition_rows = tuple(SectorMemberRow.from_mapping(row) for row in snapshot.rows) + self._require_target_date(partition_rows, trade_date, "dc_member") + if any(row.sector_code != sector_code for row in partition_rows): + raise SourceContractError("dc_member partition returned a different sector") + except SourceContractError as exc: + self._log_contract_failure("dc_member", sector_code, exc) + raise + snapshots.append(snapshot) + merged_rows.extend(partition_rows) + + try: + self._require_unique( + merged_rows, + key=lambda row: (row.trade_date, row.sector_code, row.stock_code), + api_name="dc_member", + ) + final_codes = {row.sector_code for row in merged_rows} + explicitly_observed_codes = { + snapshot.partition_key + for snapshot in snapshots + if snapshot.partition_key not in {None, "all"} + } + if set(expected_codes) - final_codes - explicitly_observed_codes: + raise SourceContractError("dc_member response is missing expected sectors") + except SourceContractError as exc: + self._log_contract_failure("dc_member", "merged", exc) + raise + return SourceResult( + tuple(snapshots), + tuple(sorted(merged_rows, key=lambda row: (row.sector_code, row.stock_code))), + ) + + def fetch_stock_basics(self) -> SourceResult[StockBasicRow]: + """Fetch the build-time current ``L`` listings in one explicit partition.""" + + snapshot = self._fetch_snapshot( + "stock_basic", + {"exchange": "", "list_status": "L"}, + target_trade_date=None, + partition_key="L", + ) + rows = tuple(StockBasicRow.from_mapping(row) for row in snapshot.rows) + if any(row.list_status != "L" for row in rows): + raise SourceContractError("stock_basic returned an unexpected list_status") + self._require_unique(rows, key=lambda row: row.ts_code, api_name="stock_basic") + return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.ts_code))) + + def fetch_suspensions(self, trade_date: date) -> SourceResult[SuspendRow]: + """Fetch explicit suspend/resume events for one date.""" + + snapshot = self._fetch_snapshot( + "suspend_d", + {"trade_date": trade_date.strftime("%Y%m%d")}, + target_trade_date=trade_date, + ) + rows = tuple(SuspendRow.from_mapping(row) for row in snapshot.rows) + self._require_target_date(rows, trade_date, "suspend_d") + self._require_unique( + rows, + key=lambda row: (row.ts_code, row.trade_date, row.suspend_type, row.suspend_timing), + api_name="suspend_d", + ) + return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.ts_code))) + + def fetch_daily(self, trade_date: date) -> SourceResult[DailyRow]: + """Fetch a full-market daily snapshot in its documented source unit.""" + + snapshot = self._fetch_snapshot( + "daily", + {"trade_date": trade_date.strftime("%Y%m%d")}, + target_trade_date=trade_date, + ) + self._reject_limit(snapshot) + rows = tuple(DailyRow.from_mapping(row) for row in snapshot.rows) + self._require_target_date(rows, trade_date, "daily") + self._require_unique(rows, key=lambda row: row.ts_code, api_name="daily") + return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.ts_code))) + + def fetch_moneyflow_dc( + self, + trade_date: date, + candidate_codes: Sequence[str], + ) -> SourceResult[MoneyflowDcRow]: + """Fetch full-market moneyflow and refill uncovered current candidates.""" + + expected_codes = tuple(sorted(set(candidate_codes))) + if tuple(candidate_codes) != expected_codes or any( + not code.strip() for code in expected_codes + ): + raise ValueError("candidate_codes must be sorted unique non-empty values") + initial = self._fetch_snapshot( + "moneyflow_dc", + {"trade_date": trade_date.strftime("%Y%m%d")}, + target_trade_date=trade_date, + partition_key="all", + ) + try: + initial_rows = tuple(MoneyflowDcRow.from_mapping(row) for row in initial.rows) + self._require_target_date(initial_rows, trade_date, "moneyflow_dc") + self._require_unique( + initial_rows, + key=lambda row: (row.trade_date, row.ts_code), + api_name="moneyflow_dc", + ) + except SourceContractError as exc: + self._log_contract_failure("moneyflow_dc", "all", exc) + raise + + returned_codes = {row.ts_code for row in initial_rows} + missing_codes = tuple(code for code in expected_codes if code not in returned_codes) + if not missing_codes: + return SourceResult( + (initial,), + tuple(sorted(initial_rows, key=lambda row: row.ts_code)), + ) + + with ThreadPoolExecutor( + max_workers=_MONEYFLOW_WORKERS, + thread_name_prefix="sector-radar-moneyflow", + ) as executor: + futures = { + code: executor.submit(self._fetch_moneyflow_partition, trade_date, code) + for code in missing_codes + } + partition_results = tuple(futures[code].result() for code in missing_codes) + + snapshots = [initial] + merged_rows = list(initial_rows) + for result in partition_results: + if result is None: + continue + snapshot, rows = result + snapshots.append(snapshot) + merged_rows.extend(rows) + try: + self._require_unique( + merged_rows, + key=lambda row: (row.trade_date, row.ts_code), + api_name="moneyflow_dc", + ) + except SourceContractError as exc: + self._log_contract_failure("moneyflow_dc", "merged", exc) + raise + return SourceResult( + tuple(snapshots), + tuple(sorted(merged_rows, key=lambda row: row.ts_code)), + ) + + def _fetch_moneyflow_partition( + self, + trade_date: date, + ts_code: str, + ) -> tuple[SourceSnapshot, tuple[MoneyflowDcRow, ...]] | None: + """Return one validated refill partition or preserve an ordinary gap.""" + + try: + snapshot = self._fetch_snapshot( + "moneyflow_dc", + { + "trade_date": trade_date.strftime("%Y%m%d"), + "ts_code": ts_code, + }, + target_trade_date=trade_date, + partition_key=ts_code, + ) + except TushareSourceError: + logger.warning( + "sector_radar_moneyflow_partition_failed partition_key=%s error_type=%s", + self._safe_partition_key(ts_code), + TushareSourceError.__name__, + ) + return None + if not snapshot.rows: + logger.warning( + "sector_radar_moneyflow_partition_empty partition_key=%s", + self._safe_partition_key(ts_code), + ) + return None + try: + self._reject_limit(snapshot) + rows = tuple(MoneyflowDcRow.from_mapping(row) for row in snapshot.rows) + self._require_target_date(rows, trade_date, "moneyflow_dc") + self._require_unique( + rows, + key=lambda row: (row.trade_date, row.ts_code), + api_name="moneyflow_dc", + ) + if any(row.ts_code != ts_code for row in rows): + raise SourceContractError("moneyflow_dc partition returned a different ts_code") + except SourceContractError as exc: + self._log_contract_failure("moneyflow_dc", ts_code, exc) + raise + return snapshot, rows + + def probe(self, trade_date: date) -> CapabilityProbeResult: + """Probe required interfaces while returning only safe classifications.""" + + results: list[CapabilityInterfaceResult] = [] + concept_codes: tuple[str, ...] = () + + calendar = self._probe_call( + "trade_cal", lambda: self.fetch_trade_calendar(trade_date, trade_date) + ) + results.append(calendar[0]) + + try: + concept = self.fetch_sector_indices(trade_date, SectorType.CONCEPT) + industry = self.fetch_sector_indices(trade_date, SectorType.INDUSTRY) + combined = SourceResult( + concept.snapshots + industry.snapshots, + concept.rows + industry.rows, + ) + concept_codes = tuple(row.sector_code for row in combined.rows) + results.append(self._capability_success("dc_index", combined.snapshots)) + except Exception as exc: + results.append(self._capability_failure("dc_index", exc)) + + member = self._probe_call( + "dc_member", lambda: self.fetch_sector_members(trade_date, concept_codes) + ) + results.append(member[0]) + for api_name, operation in ( + ("stock_basic", self.fetch_stock_basics), + ("suspend_d", lambda: self.fetch_suspensions(trade_date)), + ("daily", lambda: self.fetch_daily(trade_date)), + ("moneyflow_dc", lambda: self.fetch_moneyflow_dc(trade_date, ())), + ): + results.append(self._probe_call(api_name, operation)[0]) + return CapabilityProbeResult(observed_at=self._now_fn(), interfaces=tuple(results)) + + def _fetch_snapshot( + self, + api_name: str, + params: Mapping[str, object], + *, + target_trade_date: date | None, + partition_key: str | None = None, + ) -> SourceSnapshot: + fields = ",".join(FIELDS[api_name]) + + def request() -> object: + query = getattr(self._client, "query", None) + if callable(query): + return query(api_name, fields=fields, **params) + method = getattr(self._client, api_name, None) + if not callable(method): + raise TypeError(f"Tushare client has no callable {api_name}") + return method(fields=fields, **params) + + result = self._coordinator.call(api_name, request) + try: + columns = getattr(result, "columns", None) + returned_fields = ( + tuple(str(column) for column in cast(Iterable[object], columns)) + if isinstance(columns, Iterable) and not isinstance(columns, (str, bytes)) + else None + ) + rows = self._as_records(result) + snapshot = build_source_snapshot( + api_name=api_name, + params={**params, "fields": fields}, + rows=rows, + target_trade_date=target_trade_date, + partition_key=partition_key, + observed_at=self._now_fn(), + row_limit=ROW_LIMITS[api_name], + returned_fields=returned_fields, + ) + missing_fields = set(FIELDS[api_name]) - set(snapshot.returned_fields) + if snapshot.returned_fields and missing_fields: + missing = ",".join(sorted(missing_fields)) + raise SourceContractError( + f"{api_name} response is missing requested fields: {missing}" + ) + return snapshot + except SourceContractError as exc: + self._log_contract_failure(api_name, partition_key, exc) + raise + + @staticmethod + def _log_contract_failure( + api_name: str, + partition_key: str | None, + error: SourceContractError, + ) -> None: + """Record only operator-safe contract context, never provider payloads.""" + + if not error.claim_diagnostic(): + return + logger.error( + "sector_radar_source_contract_failed api_name=%s partition_key=%s validation=%s", + api_name, + TushareSectorRadarAdapter._safe_partition_key(partition_key), + error.operator_message, + ) + + @staticmethod + def _safe_partition_key(partition_key: str | None) -> str: + """Keep expected identifiers readable while preventing log-control injection.""" + + if partition_key is None: + return "all" + sanitized = "".join( + character if character.isalnum() or character in {".", "_", "-"} else "_" + for character in partition_key + ) + return sanitized[:64] or "unknown" + + @staticmethod + def _as_records(result: object) -> tuple[Mapping[str, object], ...]: + if result is None: + return () + to_dict = getattr(result, "to_dict", None) + if callable(to_dict): + result = to_dict("records") + if isinstance(result, Mapping): + return (cast(Mapping[str, object], result),) + if isinstance(result, Iterable) and not isinstance(result, (str, bytes)): + records: list[Mapping[str, object]] = [] + for row in cast(Iterable[object], result): + if not isinstance(row, Mapping): + raise SourceContractError("Tushare rows must be mappings") + records.append(cast(Mapping[str, object], row)) + return tuple(records) + raise SourceContractError("unsupported Tushare tabular response") + + @staticmethod + def _reject_limit(snapshot: SourceSnapshot) -> None: + if snapshot.limit_reached: + raise SourceTruncatedError(f"{snapshot.api_name} reached its provider row limit") + + @staticmethod + def _require_target_date(rows: Sequence[object], target: date, api_name: str) -> None: + if any(getattr(row, "trade_date", None) != target for row in rows): + raise SourceContractError(f"{api_name} returned a different trade_date") + + @staticmethod + def _require_unique( + rows: Sequence[T], + *, + key: Callable[[T], object], + api_name: str, + ) -> None: + keys = [key(row) for row in rows] + if len(keys) != len(set(keys)): + raise SourceContractError(f"{api_name} returned duplicate business keys") + + def _probe_call( + self, + api_name: str, + operation: Callable[[], SourceResult[object]], + ) -> tuple[CapabilityInterfaceResult, SourceResult[object] | None]: + try: + result = operation() + except Exception as exc: + return self._capability_failure(api_name, exc), None + return self._capability_success(api_name, result.snapshots), result + + @staticmethod + def _capability_success( + api_name: str, + snapshots: Sequence[SourceSnapshot], + ) -> CapabilityInterfaceResult: + return CapabilityInterfaceResult( + api_name=api_name, + requested_fields=FIELDS[api_name], + returned_fields=tuple( + sorted({field for item in snapshots for field in item.returned_fields}) + ), + status=CapabilityStatus.OK, + row_count=sum(item.row_count for item in snapshots), + row_limit=ROW_LIMITS[api_name], + retryable=False, + ) + + @staticmethod + def _capability_failure(api_name: str, error: BaseException) -> CapabilityInterfaceResult: + classified_error = error.__cause__ if error.__cause__ is not None else error + message = str(classified_error).casefold() + if isinstance(error, SourceTruncatedError): + status = CapabilityStatus.TRUNCATED + elif RequestCoordinator.is_rate_limited(error) or RequestCoordinator.is_rate_limited( + classified_error + ): + status = CapabilityStatus.RATE_LIMITED + elif "权限" in message or "forbidden" in message or "permission" in message: + status = CapabilityStatus.FORBIDDEN + elif isinstance(error, (SourceContractError, ValueError, TypeError)): + status = CapabilityStatus.SCHEMA_ERROR + else: + status = CapabilityStatus.SERVER_ERROR + return CapabilityInterfaceResult( + api_name=api_name, + requested_fields=FIELDS[api_name], + returned_fields=(), + status=status, + row_count=0, + row_limit=ROW_LIMITS[api_name], + retryable=status in {CapabilityStatus.RATE_LIMITED, CapabilityStatus.SERVER_ERROR}, + ) diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/__init__.py b/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/__init__.py new file mode 100644 index 0000000..69da197 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/__init__.py @@ -0,0 +1 @@ +"""Delivery adapters for sector radar build and query use cases.""" diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/cli.py b/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/cli.py new file mode 100644 index 0000000..4a8c3a0 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/cli.py @@ -0,0 +1,108 @@ +"""One-shot ``sector-radar-build`` command for external schedulers.""" + +from __future__ import annotations + +import argparse +import json +import logging +from collections.abc import Sequence +from datetime import date + +from ....bootstrap.config import get_settings +from ..application.build import BuildSectorRadar, BuildSectorRadarCommand +from ..infrastructure.postgres import PostgresSectorRadarRepository +from ..infrastructure.tushare import TushareSectorRadarAdapter + +logger = logging.getLogger(__name__) + + +def build_parser() -> argparse.ArgumentParser: + """Build mutually exclusive single-date, range, and retry modes.""" + + parser = argparse.ArgumentParser( + description="Build independent Tushare sector radar publications" + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--trade-date", type=_parse_date, help="target date in YYYY-MM-DD") + mode.add_argument( + "--retry-publication-id", + help="resume failed source groups from a partial or failed publication", + ) + mode.add_argument("--start-date", type=_parse_date, help="inclusive backfill start date") + parser.add_argument("--end-date", type=_parse_date, help="inclusive backfill end date") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Execute one build invocation and print a redacted JSON summary.""" + + args = build_parser().parse_args(argv) + if (args.start_date is None) != (args.end_date is None): + raise SystemExit("--start-date and --end-date must be provided together") + command = BuildSectorRadarCommand( + trade_date=args.trade_date, + start_date=args.start_date, + end_date=args.end_date, + retry_publication_id=args.retry_publication_id, + ) + try: + settings = get_settings() + logging.basicConfig( + level=settings.log_level.upper(), + format="%(asctime)s %(levelname)s %(name)s %(message)s", + force=True, + ) + logger.info( + "sector_radar_build_cli trade_date=%s start_date=%s end_date=%s retry=%s", + command.trade_date or "auto", + command.start_date or "none", + command.end_date or "none", + bool(command.retry_publication_id), + ) + source = TushareSectorRadarAdapter.from_token( + settings.tushare_token, + max_retries=settings.sector_radar_max_retries, + backoff_seconds=settings.sector_radar_retry_backoff_seconds, + request_interval_seconds=settings.sector_radar_request_interval_seconds, + ) + repository = PostgresSectorRadarRepository( + settings.database_url, + advisory_lock_key=settings.sector_radar_advisory_lock_key, + ) + try: + summary = BuildSectorRadar( + source, + repository, + coverage_threshold=settings.sector_radar_coverage_threshold, + ).execute(command) + finally: + repository.close() + except Exception as exc: # noqa: BLE001 - CLI boundary returns a redacted scheduler result + logger.error("sector_radar_build_initialization_failed error_type=%s", type(exc).__name__) + print( + json.dumps( + { + "status": "failed", + "exit_code": 1, + "outcomes": [], + "error_type": type(exc).__name__, + "error_message": "sector radar build initialization failed", + }, + ensure_ascii=False, + sort_keys=True, + ) + ) + return 1 + print(json.dumps(summary.as_dict(), ensure_ascii=False, sort_keys=True)) + return summary.exit_code + + +def _parse_date(value: str) -> date: + try: + return date.fromisoformat(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("date must use YYYY-MM-DD") from exc + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/http.py b/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/http.py new file mode 100644 index 0000000..10c9892 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/http.py @@ -0,0 +1,302 @@ +"""HTTP presentation for persisted sector radar rankings.""" + +from __future__ import annotations + +import atexit +import threading +from datetime import date, datetime +from decimal import Decimal +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from ....bootstrap.config import Settings, get_settings +from ..application.read import ( + RadarDateIndex, + RadarMetricDefinition, + RadarQuery, + RadarView, + RankingPage, + ReadSectorRadar, +) +from ..domain.models import ( + MetricKind, + MetricQuality, + MetricUnit, + PublicationStatus, + RadarPublication, + RankedMetric, + RankSide, + SectorType, +) +from ..infrastructure.postgres import ( + PostgresSectorRadarRepository, + SectorRadarRepositoryError, +) + +sector_radar_router = APIRouter() +_REPOSITORY_CACHE_LOCK = threading.Lock() +_REPOSITORY_CACHE: dict[tuple[str, int], PostgresSectorRadarRepository] = {} + + +class RadarPublicationResponse(BaseModel): + """Safe publication provenance and quality metadata.""" + + publication_id: str + target_trade_date: date + status: PublicationStatus + source_version: str + universe_version: str + metric_versions: list[str] + input_hash: str | None + coverage: Decimal = Field(ge=0, le=1) + started_at: datetime + finished_at: datetime | None + error_summary: str | None + + +class RadarDatesResponse(BaseModel): + """Successful dates plus newest attempt and strict last-good metadata.""" + + status: Literal["success", "no_data"] + available_dates: list[date] + current_attempt: RadarPublicationResponse | None + last_good: RadarPublicationResponse | None + + +class RadarMetricDefinitionResponse(BaseModel): + """Version and labeling for one independent metric implementation.""" + + metric_kind: MetricKind + metric_version: str + label: str + unit: MetricUnit + implementation_kind: Literal["independent"] + disclaimer: str + + +class RadarRankingRowResponse(BaseModel): + """One sector ranking row with explicit null and unit semantics.""" + + trade_date: date + sector_type: SectorType + sector_code: str + sector_name: str + metric_kind: MetricKind + metric_version: str + implementation_kind: Literal["independent"] + unit: MetricUnit + metric_value: Decimal | None + quality: MetricQuality + member_count: int = Field(ge=0) + valid_sample_count: int = Field(ge=0) + membership_coverage: Decimal = Field(ge=0, le=1) + moneyflow_coverage: Decimal = Field(ge=0, le=1) + rank_position: int | None = Field(default=None, ge=1) + rank_percentile: Decimal | None = Field(default=None, gt=0, le=100) + rank_change_days: int = Field(ge=1, le=5) + rank_change: int | None + + +def _empty_ranking_rows() -> list[RadarRankingRowResponse]: + return [] + + +class RadarRankingsResponse(BaseModel): + """One persisted, filtered ranking page.""" + + status: Literal["success", "no_data"] + requested_trade_date: date | None + sector_type: SectorType + view: RadarView + rank_change_metric: MetricKind + rank_change_days: int = Field(ge=1, le=5) + side: RankSide + search: str | None + publication: RadarPublicationResponse | None + definition: RadarMetricDefinitionResponse + page: int = Field(ge=1) + page_size: int = Field(ge=1, le=100) + total: int = Field(ge=0) + rows: list[RadarRankingRowResponse] = Field(default_factory=_empty_ranking_rows) + + +def get_sector_radar_reader( + settings: Annotated[Settings, Depends(get_settings)], +) -> ReadSectorRadar: + """Return a reader backed by one process-cached PostgreSQL repository.""" + + key = (settings.database_url, settings.sector_radar_advisory_lock_key) + with _REPOSITORY_CACHE_LOCK: + repository = _REPOSITORY_CACHE.get(key) + if repository is None: + repository = PostgresSectorRadarRepository( + settings.database_url, + advisory_lock_key=settings.sector_radar_advisory_lock_key, + ) + _REPOSITORY_CACHE[key] = repository + return ReadSectorRadar(repository) + + +def _close_cached_repositories() -> None: + """Close process-owned radar pools during interpreter shutdown.""" + + with _REPOSITORY_CACHE_LOCK: + repositories = tuple(_REPOSITORY_CACHE.values()) + _REPOSITORY_CACHE.clear() + for repository in repositories: + repository.close() + + +atexit.register(_close_cached_repositories) + + +@sector_radar_router.get("/dates", response_model=RadarDatesResponse) +def get_sector_radar_dates( + reader: Annotated[ReadSectorRadar, Depends(get_sector_radar_reader)], +) -> RadarDatesResponse: + """Return persisted availability without invoking Tushare.""" + + try: + return _dates_response(reader.list_dates()) + except SectorRadarRepositoryError as exc: + raise _storage_error() from exc + + +@sector_radar_router.get("/rankings", response_model=RadarRankingsResponse) +def get_sector_radar_rankings( + reader: Annotated[ReadSectorRadar, Depends(get_sector_radar_reader)], + trade_date: date | None = None, + sector_type: SectorType = SectorType.CONCEPT, + view: RadarView = RadarView.AMOUNT, + rank_change_metric: MetricKind = MetricKind.AMOUNT, + rank_change_days: Annotated[int, Query(ge=1, le=5)] = 1, + side: RankSide = RankSide.ALL, + search: Annotated[str | None, Query(max_length=100)] = None, + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=100)] = 20, +) -> RadarRankingsResponse: + """Return one filtered page from a successful publication.""" + + query = RadarQuery( + trade_date=trade_date, + sector_type=sector_type, + view=view, + rank_change_metric=rank_change_metric, + rank_change_days=rank_change_days, + side=side, + search=search.strip() or None if search else None, + page=page, + page_size=page_size, + ) + try: + return _rankings_response(reader.query(query)) + except SectorRadarRepositoryError as exc: + raise _storage_error() from exc + + +def _dates_response(index: RadarDateIndex) -> RadarDatesResponse: + return RadarDatesResponse( + status=index.status, + available_dates=list(index.available_dates), + current_attempt=( + _publication_response(index.current_attempt) + if index.current_attempt is not None + else None + ), + last_good=(_publication_response(index.last_good) if index.last_good is not None else None), + ) + + +def _rankings_response(page: RankingPage) -> RadarRankingsResponse: + query = page.query + return RadarRankingsResponse( + status=page.status, + requested_trade_date=query.trade_date, + sector_type=query.sector_type, + view=query.view, + rank_change_metric=query.rank_change_metric, + rank_change_days=query.rank_change_days, + side=query.side, + search=query.search, + publication=( + _publication_response(page.publication) if page.publication is not None else None + ), + definition=_definition_response(page.definition), + page=query.page, + page_size=query.page_size, + total=page.total, + rows=[_ranking_response(row, query.rank_change_days) for row in page.rows], + ) + + +def _publication_response(publication: RadarPublication) -> RadarPublicationResponse: + return RadarPublicationResponse( + publication_id=publication.publication_id, + target_trade_date=publication.target_trade_date, + status=publication.status, + source_version=publication.source_version, + universe_version=publication.universe_version, + metric_versions=list(publication.metric_versions), + input_hash=publication.input_hash, + coverage=publication.coverage, + started_at=publication.started_at, + finished_at=publication.finished_at, + error_summary=publication.error_summary, + ) + + +def _definition_response( + definition: RadarMetricDefinition, +) -> RadarMetricDefinitionResponse: + return RadarMetricDefinitionResponse( + metric_kind=definition.metric_kind, + metric_version=definition.metric_version, + label=definition.label, + unit=definition.unit, + implementation_kind=definition.implementation_kind, + disclaimer=definition.disclaimer, + ) + + +def _ranking_response(row: RankedMetric, rank_change_days: int) -> RadarRankingRowResponse: + observation = row.observation + return RadarRankingRowResponse( + trade_date=observation.trade_date, + sector_type=observation.sector_type, + sector_code=observation.sector_code, + sector_name=observation.sector_name, + metric_kind=observation.metric_kind, + metric_version=observation.metric_version, + implementation_kind=observation.implementation_kind, + unit=observation.unit, + metric_value=observation.value, + quality=observation.quality, + member_count=observation.member_count, + valid_sample_count=observation.valid_sample_count, + membership_coverage=observation.membership_coverage, + moneyflow_coverage=observation.moneyflow_coverage, + rank_position=row.rank_position, + rank_percentile=row.rank_percentile, + rank_change_days=rank_change_days, + rank_change=row.rank_change(rank_change_days), + ) + + +def _storage_error() -> HTTPException: + return HTTPException( + status_code=503, + detail={ + "code": "sector_radar_storage_unavailable", + "message": "sector radar storage is unavailable", + }, + ) + + +__all__ = [ + "RadarDatesResponse", + "RadarRankingsResponse", + "get_sector_radar_reader", + "sector_radar_router", +] diff --git a/zhixing-server/src/zhixing_server/shared/request_coordinator.py b/zhixing-server/src/zhixing_server/shared/request_coordinator.py new file mode 100644 index 0000000..3e5f28c --- /dev/null +++ b/zhixing-server/src/zhixing_server/shared/request_coordinator.py @@ -0,0 +1,186 @@ +"""Shared bounded retry and provider rate-limit coordination.""" + +from __future__ import annotations + +import logging +import random +import threading +import time +from collections.abc import Callable, Sequence + +logger = logging.getLogger(__name__) + +DEFAULT_RATE_LIMIT_COOLDOWNS = (60.0, 120.0, 180.0) +_RATE_LIMIT_MESSAGES = ( + "访问频繁", + "请稍后", + "超过频率", + "频率限制", + "too many requests", + "rate limit", + "rate_limit", + "http 429", + "status code: 429", + "429", + "http 403", + "status code: 403", + "403", +) + + +class TushareSourceError(RuntimeError): + """A Tushare request failed after the configured retry budget.""" + + +class RequestCoordinator: + """Coordinate retries, rate-limit cooling, and optional request start spacing. + + Provider calls execute outside the coordinator lock and may overlap. When a + positive request interval is configured, only their start times are serialized. + Injectable time functions keep waits deterministic in tests without coupling + the coordinator to any business bounded context. + """ + + def __init__( + self, + *, + max_retries: int = 3, + backoff_seconds: float = 1.0, + request_interval_seconds: float = 0.0, + cooldown_seconds: Sequence[float] = DEFAULT_RATE_LIMIT_COOLDOWNS, + random_fn: Callable[[], float] = random.random, + clock: Callable[[], float] = time.monotonic, + wait_fn: Callable[[float], None] = time.sleep, + sleep_fn: Callable[[float], None] | None = None, + ) -> None: + cooldowns = tuple(float(value) for value in cooldown_seconds) + if not cooldowns or any(value < 0 for value in cooldowns): + raise ValueError("cooldown_seconds must contain non-negative values") + self.max_retries = max(0, max_retries) + self.backoff_seconds = max(0.0, backoff_seconds) + self.request_interval_seconds = max(0.0, request_interval_seconds) + self.cooldown_seconds = cooldowns + self.random_fn = random_fn + self.clock = clock + self.wait_fn = wait_fn + self.sleep_fn = sleep_fn or wait_fn + self._condition = threading.Condition() + self._cooldown_until = 0.0 + self._next_request_start = 0.0 + self._rate_limit_count = 0 + + @property + def cooldown_until(self) -> float: + """Return the current monotonic cooldown deadline.""" + + with self._condition: + return self._cooldown_until + + def call(self, method_name: str, request: Callable[[], object]) -> object: + """Execute one provider request with bounded, shared retry behavior.""" + + last_error: BaseException | None = None + for attempt in range(self.max_retries + 1): + self._wait_for_request_start(method_name) + try: + result = request() + except Exception as exc: + last_error = exc + if self.is_rate_limited(exc): + cooldown = self._set_rate_limit_cooldown() + logger.warning( + "provider_rate_limit method=%s attempt=%d max_attempts=%d " + "cooldown_seconds=%.1f", + method_name, + attempt + 1, + self.max_retries + 1, + cooldown, + ) + if attempt < self.max_retries: + continue + break + if not self._is_retryable(exc): + raise + if attempt == self.max_retries: + break + delay = self.backoff_seconds * (2**attempt) * (0.5 + self.random_fn()) + logger.warning( + "provider_request_retry method=%s attempt=%d max_attempts=%d " + "backoff_seconds=%.1f", + method_name, + attempt + 1, + self.max_retries + 1, + delay, + ) + self.sleep_fn(delay) + else: + self._clear_rate_limit_after_success() + return result + logger.error( + "provider_request_failed method=%s attempts=%d", + method_name, + self.max_retries + 1, + ) + raise TushareSourceError(f"Tushare request failed: {method_name}") from last_error + + def request(self, method_name: str, operation: Callable[[], object]) -> object: + """Alias for ``call`` for adapters that model requests as a port.""" + + return self.call(method_name, operation) + + def _wait_for_request_start(self, method_name: str) -> None: + """Reserve one start slot after both shared wait deadlines have elapsed.""" + + while True: + with self._condition: + now = self.clock() + start_at = max(self._cooldown_until, self._next_request_start) + delay = start_at - now + if delay <= 0: + self._next_request_start = now + self.request_interval_seconds + return + if start_at == self._cooldown_until: + logger.info( + "provider_rate_limit_wait method=%s wait_seconds=%.1f", + method_name, + delay, + ) + else: + logger.debug( + "provider_request_interval_wait method=%s wait_seconds=%.3f", + method_name, + delay, + ) + self.wait_fn(delay) + + def _set_rate_limit_cooldown(self) -> float: + with self._condition: + self._rate_limit_count += 1 + index = min(self._rate_limit_count - 1, len(self.cooldown_seconds) - 1) + duration = self.cooldown_seconds[index] + self._cooldown_until = max(self._cooldown_until, self.clock() + duration) + self._condition.notify_all() + return duration + + def _clear_rate_limit_after_success(self) -> None: + with self._condition: + if self.clock() >= self._cooldown_until: + self._rate_limit_count = 0 + + @staticmethod + def is_rate_limited(error: BaseException) -> bool: + """Classify stable provider rate-limit signals without logging details.""" + + for attribute in ("status_code", "status", "code"): + value = getattr(error, attribute, None) + if str(value).strip() in {"403", "429"}: + return True + message = str(error).casefold() + return any(marker.casefold() in message for marker in _RATE_LIMIT_MESSAGES) + + @staticmethod + def _is_retryable(error: BaseException) -> bool: + return isinstance(error, (OSError, RuntimeError, TimeoutError)) + + +TushareRequestCoordinator = RequestCoordinator diff --git a/zhixing-server/tests/integration/test_market_data_migration.py b/zhixing-server/tests/integration/test_market_data_migration.py index 8a1ccca..7dc5af3 100644 --- a/zhixing-server/tests/integration/test_market_data_migration.py +++ b/zhixing-server/tests/integration/test_market_data_migration.py @@ -39,6 +39,13 @@ def test_postgres_migration_creates_market_data_contract( "selection_run", "selection_run_item", "selection_signal", + "sector_radar_source_snapshot", + "sector_radar_membership", + "sector_radar_stock_fact", + "sector_radar_publication", + "sector_radar_ranking", + "sector_radar_daily_aggregate", + "sector_radar_publication_source", } <= tables item_columns = {column["name"] for column in inspector.get_columns("selection_run_item")} assert { diff --git a/zhixing-server/tests/integration/test_sector_radar_repository.py b/zhixing-server/tests/integration/test_sector_radar_repository.py new file mode 100644 index 0000000..3d0e163 --- /dev/null +++ b/zhixing-server/tests/integration/test_sector_radar_repository.py @@ -0,0 +1,175 @@ +import os +from dataclasses import replace +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal +from pathlib import Path + +import psycopg +import pytest +from alembic import command +from alembic.config import Config + +from zhixing_server.bootstrap.config import sqlalchemy_database_url +from zhixing_server.modules.sector_radar.domain.models import ( + MembershipStatus, + PublicationStatus, + RadarPublication, + SectorType, + StockFactStatus, +) +from zhixing_server.modules.sector_radar.domain.persistence import ( + MembershipRecord, + StockFactRecord, +) +from zhixing_server.modules.sector_radar.domain.source import build_source_snapshot +from zhixing_server.modules.sector_radar.infrastructure.postgres import ( + PostgresSectorRadarRepository, +) + +TARGET_DATE = date(2099, 1, 4) +STARTED_AT = datetime(2099, 1, 4, 17, 30, tzinfo=UTC) + + +def prepare_database(database_url: str) -> None: + server_root = Path(__file__).parents[2] + config = Config(str(server_root / "alembic.ini")) + sqlalchemy_url = sqlalchemy_database_url(database_url) + config.set_main_option("sqlalchemy.url", sqlalchemy_url.replace("%", "%%")) + command.upgrade(config, "head") + + +@pytest.mark.integration +def test_postgres_sector_radar_revisions_and_last_good() -> None: + database_url = os.getenv("ZHIXING_TEST_DATABASE_URL") + if not database_url: + pytest.skip("set ZHIXING_TEST_DATABASE_URL to run PostgreSQL integration tests") + prepare_database(database_url) + snapshot = build_source_snapshot( + api_name="dc_member", + params={"trade_date": "20990104"}, + rows=( + { + "trade_date": "20990104", + "ts_code": "BKTEST.DC", + "con_code": "000001.SZ", + "name": "测试股票", + }, + ), + target_trade_date=TARGET_DATE, + observed_at=STARTED_AT, + ) + fact_revision = "b" * 64 + publication_ids = ("test-sector-radar-success", "test-sector-radar-failed") + with psycopg.connect(database_url) as connection, connection.transaction(): + connection.execute( + "DELETE FROM sector_radar_publication WHERE id = ANY(%s)", + (list(publication_ids),), + ) + connection.execute( + "DELETE FROM sector_radar_stock_fact WHERE fact_revision = %s", + (fact_revision,), + ) + connection.execute( + "DELETE FROM sector_radar_source_snapshot WHERE id = %s", + (snapshot.snapshot_id,), + ) + + repository = PostgresSectorRadarRepository(database_url, max_connections=2) + try: + assert repository.save_source_snapshots((snapshot,)).inserted == 1 + assert ( + repository.save_source_snapshots( + (replace(snapshot, observed_at=STARTED_AT + timedelta(minutes=1)),) + ).unchanged + == 1 + ) + assert ( + repository.save_memberships( + ( + MembershipRecord( + source_snapshot_id=snapshot.snapshot_id, + trade_date=TARGET_DATE, + sector_type=SectorType.CONCEPT, + sector_code="BKTEST.DC", + sector_name="测试概念", + stock_code="000001.SZ", + stock_name="测试股票", + status=MembershipStatus.AVAILABLE, + ), + ) + ).inserted + == 1 + ) + assert ( + repository.save_stock_facts( + ( + StockFactRecord( + fact_revision=fact_revision, + source_snapshot_ids=(snapshot.snapshot_id,), + trade_date=TARGET_DATE, + ts_code="000001.SZ", + status=StockFactStatus.AVAILABLE, + turnover_yuan=Decimal("1000"), + net_amount_yuan=Decimal("100"), + ), + ) + ).inserted + == 1 + ) + + running = RadarPublication( + publication_id=publication_ids[0], + target_trade_date=TARGET_DATE, + status=PublicationStatus.RUNNING, + source_version="tushare-pro-v1", + universe_version=snapshot.content_sha256, + metric_versions=("zhixing_amount_net_bn_v1",), + input_hash=None, + coverage=Decimal(0), + started_at=STARTED_AT, + ) + repository.create_publication(running) + repository.finish_publication( + replace( + running, + status=PublicationStatus.SUCCESS, + input_hash="a" * 64, + coverage=Decimal(1), + finished_at=STARTED_AT + timedelta(minutes=5), + ) + ) + failed = replace( + running, + publication_id=publication_ids[1], + started_at=STARTED_AT + timedelta(minutes=6), + ) + repository.create_publication(failed) + repository.finish_publication( + replace( + failed, + status=PublicationStatus.FAILED, + coverage=Decimal("0.8"), + finished_at=STARTED_AT + timedelta(minutes=7), + error_summary="safe_error", + ) + ) + + last_good = repository.get_last_good_publication(TARGET_DATE) + + assert last_good is not None + assert last_good.publication_id == publication_ids[0] + finally: + repository.close() + with psycopg.connect(database_url) as connection, connection.transaction(): + connection.execute( + "DELETE FROM sector_radar_publication WHERE id = ANY(%s)", + (list(publication_ids),), + ) + connection.execute( + "DELETE FROM sector_radar_stock_fact WHERE fact_revision = %s", + (fact_revision,), + ) + connection.execute( + "DELETE FROM sector_radar_source_snapshot WHERE id = %s", + (snapshot.snapshot_id,), + ) diff --git a/zhixing-server/tests/test_sector_radar_http.py b/zhixing-server/tests/test_sector_radar_http.py new file mode 100644 index 0000000..2aa7ee9 --- /dev/null +++ b/zhixing-server/tests/test_sector_radar_http.py @@ -0,0 +1,230 @@ +from dataclasses import replace +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from zhixing_server.bootstrap.app import create_app +from zhixing_server.modules.sector_radar.application.read import ( + RadarDateIndex, + RadarMetricDefinition, + RadarQuery, + RadarView, + RankingPage, +) +from zhixing_server.modules.sector_radar.domain.metrics import AmountNetStrategy +from zhixing_server.modules.sector_radar.domain.models import ( + MetricKind, + MetricObservation, + MetricQuality, + MetricUnit, + PublicationStatus, + RadarPublication, + RankChange, + RankedMetric, + RankSide, + SectorType, +) +from zhixing_server.modules.sector_radar.infrastructure.postgres import ( + SectorRadarRepositoryError, +) +from zhixing_server.modules.sector_radar.presentation.http import ( + RadarRankingRowResponse, + get_sector_radar_reader, +) + +TARGET_DATE = date(2026, 8, 28) +NOW = datetime(2026, 8, 28, 17, 30, tzinfo=UTC) + + +def _publication( + publication_id: str, + status: PublicationStatus, + *, + trade_date: date = TARGET_DATE, +) -> RadarPublication: + return RadarPublication( + publication_id=publication_id, + target_trade_date=trade_date, + status=status, + source_version="tushare-pro-v1", + universe_version="eastmoney-dc-v1", + metric_versions=(AmountNetStrategy.metric_version,), + input_hash="a" * 64 if status is PublicationStatus.SUCCESS else None, + coverage=Decimal(1) if status is PublicationStatus.SUCCESS else Decimal("0.8"), + started_at=NOW, + finished_at=None if status is PublicationStatus.RUNNING else NOW + timedelta(minutes=5), + error_summary=None if status is PublicationStatus.SUCCESS else "safe_error", + ) + + +def _ranking() -> RankedMetric: + return RankedMetric( + observation=MetricObservation( + trade_date=TARGET_DATE, + sector_type=SectorType.CONCEPT, + sector_code="BK0001.DC", + sector_name="机器人", + metric_kind=MetricKind.AMOUNT, + metric_version=AmountNetStrategy.metric_version, + implementation_kind="independent", + unit=MetricUnit.CNY_100M, + value=Decimal("12.5"), + quality=MetricQuality.AVAILABLE, + member_count=20, + valid_sample_count=19, + membership_coverage=Decimal(1), + moneyflow_coverage=Decimal("0.95"), + ), + rank_position=1, + rank_percentile=Decimal(100), + rank_changes=(RankChange(days=5, value=3),), + ) + + +class FakeReader: + def __init__(self, *, no_data: bool = False, fail: bool = False) -> None: + self.fail = fail + self.last_query: RadarQuery | None = None + success = _publication("publication-success", PublicationStatus.SUCCESS) + current = _publication( + "publication-partial", + PublicationStatus.PARTIAL, + trade_date=TARGET_DATE + timedelta(days=1), + ) + self.date_index = RadarDateIndex( + available_dates=() if no_data else (TARGET_DATE,), + current_attempt=None if no_data else current, + last_good=None if no_data else success, + ) + query = RadarQuery() + self.page = RankingPage( + status="no_data" if no_data else "success", + query=query, + publication=None if no_data else success, + definition=RadarMetricDefinition( + metric_kind=MetricKind.AMOUNT, + metric_version=AmountNetStrategy.metric_version, + label="主力净流入(知行独立实现)", + unit=MetricUnit.CNY_100M, + ), + rows=() if no_data else (_ranking(),), + total=0 if no_data else 1, + ) + + def list_dates(self) -> RadarDateIndex: + if self.fail: + raise SectorRadarRepositoryError("private database detail") + return self.date_index + + def query(self, query: RadarQuery) -> RankingPage: + if self.fail: + raise SectorRadarRepositoryError("private database detail") + self.last_query = query + return replace(self.page, query=query) + + +def _client(reader: FakeReader) -> TestClient: + application = create_app() + application.dependency_overrides[get_sector_radar_reader] = lambda: reader + return TestClient(application) + + +def test_dates_exposes_partial_attempt_without_replacing_last_good() -> None: + response = _client(FakeReader()).get("/api/v1/sector-radar/dates") + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "success" + assert payload["available_dates"] == ["2026-08-28"] + assert payload["current_attempt"]["status"] == "partial" + assert payload["last_good"]["status"] == "success" + assert payload["last_good"]["coverage"] == "1" + + +def test_rankings_maps_filters_and_independent_metric_contract() -> None: + reader = FakeReader() + response = _client(reader).get( + "/api/v1/sector-radar/rankings", + params={ + "trade_date": "2026-08-28", + "sector_type": "concept", + "view": "rank_change", + "rank_change_metric": "amount", + "rank_change_days": 5, + "side": "top", + "search": " 机器人 ", + "page": 2, + "page_size": 10, + }, + ) + + assert response.status_code == 200 + assert reader.last_query == RadarQuery( + trade_date=TARGET_DATE, + sector_type=SectorType.CONCEPT, + view=RadarView.RANK_CHANGE, + rank_change_metric=MetricKind.AMOUNT, + rank_change_days=5, + side=RankSide.TOP, + search="机器人", + page=2, + page_size=10, + ) + payload = response.json() + assert payload["definition"]["metric_version"] == "zhixing_amount_net_bn_v1" + assert payload["definition"]["implementation_kind"] == "independent" + assert "知行独立实现" in payload["definition"]["disclaimer"] + assert payload["rows"][0]["rank_change"] == 3 + assert payload["rows"][0]["unit"] == "CNY_100M" + + +def test_no_data_is_a_stable_200_response() -> None: + client = _client(FakeReader(no_data=True)) + + dates = client.get("/api/v1/sector-radar/dates") + rankings = client.get("/api/v1/sector-radar/rankings") + + assert dates.status_code == 200 + assert dates.json()["status"] == "no_data" + assert rankings.status_code == 200 + assert rankings.json()["status"] == "no_data" + assert rankings.json()["rows"] == [] + + +def test_http_contract_rejects_zero_rank_percentile() -> None: + payload = _client(FakeReader()).get("/api/v1/sector-radar/rankings").json()["rows"][0] + payload["rank_percentile"] = "0" + + with pytest.raises(ValidationError): + RadarRankingRowResponse.model_validate(payload) + + +def test_invalid_query_values_return_422() -> None: + client = _client(FakeReader()) + + for params in ( + {"rank_change_days": 0}, + {"rank_change_days": 6}, + {"page": 0}, + {"page_size": 101}, + {"sector_type": "region"}, + {"view": "unknown"}, + {"side": "unknown"}, + ): + assert client.get("/api/v1/sector-radar/rankings", params=params).status_code == 422 + + +def test_repository_error_maps_to_redacted_503() -> None: + response = _client(FakeReader(fail=True)).get("/api/v1/sector-radar/rankings") + + assert response.status_code == 503 + assert response.json() == { + "detail": { + "code": "sector_radar_storage_unavailable", + "message": "sector radar storage is unavailable", + } + } + assert "private database detail" not in response.text diff --git a/zhixing-server/tests/unit/market_data/test_tushare.py b/zhixing-server/tests/unit/market_data/test_tushare.py index 04d74fd..a6ea2bc 100644 --- a/zhixing-server/tests/unit/market_data/test_tushare.py +++ b/zhixing-server/tests/unit/market_data/test_tushare.py @@ -1,3 +1,4 @@ +import threading from datetime import date import pytest @@ -87,6 +88,109 @@ def test_rate_limit_cooldown_is_shared_by_following_requests() -> None: assert waits == [60] +def test_request_start_interval_allows_overlapping_provider_calls() -> None: + current = [0.0] + state_lock = threading.Lock() + first_started = threading.Event() + release_first = threading.Event() + waits: list[float] = [] + starts: list[tuple[str, float]] = [] + errors: list[BaseException] = [] + + def clock() -> float: + with state_lock: + return current[0] + + def wait(seconds: float) -> None: + with state_lock: + waits.append(seconds) + current[0] += seconds + + coordinator = RequestCoordinator( + max_retries=0, + request_interval_seconds=0.2, + clock=clock, + wait_fn=wait, + sleep_fn=wait, + ) + + def first_request() -> object: + starts.append(("first", clock())) + first_started.set() + if not release_first.wait(timeout=2): + raise AssertionError("first provider call was not released") + return "first" + + def run_first() -> None: + try: + coordinator.call("first", first_request) + except BaseException as exc: # pragma: no cover - surfaced by the assertion below + errors.append(exc) + + first_thread = threading.Thread(target=run_first) + first_thread.start() + assert first_started.wait(timeout=2) + + second = coordinator.call( + "second", + lambda: starts.append(("second", clock())) or "second", + ) + + assert second == "second" + assert first_thread.is_alive() + release_first.set() + first_thread.join(timeout=2) + assert not first_thread.is_alive() + assert errors == [] + assert starts == [("first", 0.0), ("second", 0.2)] + assert waits == [0.2] + + +def test_request_start_interval_is_disabled_by_default() -> None: + waits: list[float] = [] + starts: list[str] = [] + coordinator = RequestCoordinator( + max_retries=0, + clock=lambda: 0.0, + wait_fn=waits.append, + ) + + coordinator.call("first", lambda: starts.append("first")) + coordinator.call("second", lambda: starts.append("second")) + + assert starts == ["first", "second"] + assert waits == [] + + +def test_request_start_interval_applies_to_retry_attempts() -> None: + current = [0.0] + waits: list[float] = [] + starts: list[float] = [] + + def wait(seconds: float) -> None: + waits.append(seconds) + current[0] += seconds + + coordinator = RequestCoordinator( + max_retries=1, + backoff_seconds=0, + request_interval_seconds=0.2, + clock=lambda: current[0], + wait_fn=wait, + sleep_fn=wait, + ) + + def request() -> object: + starts.append(current[0]) + if len(starts) == 1: + raise RuntimeError("transient provider failure") + return "ok" + + assert coordinator.call("daily", request) == "ok" + assert starts == [0.0, 0.2] + assert waits == [0.0, 0.2] + + def test_pro_bar_qfq_calls_are_bound_to_the_shared_coordinator( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/zhixing-server/tests/unit/sector_radar/test_build.py b/zhixing-server/tests/unit/sector_radar/test_build.py new file mode 100644 index 0000000..c1b981d --- /dev/null +++ b/zhixing-server/tests/unit/sector_radar/test_build.py @@ -0,0 +1,641 @@ +import logging +from collections.abc import Sequence +from dataclasses import replace +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal + +import pytest + +from zhixing_server.modules.sector_radar.application.build import ( + BuildSectorRadar, + BuildSectorRadarCommand, +) +from zhixing_server.modules.sector_radar.domain.models import ( + MembershipStatus, + PublicationStatus, + RadarPublication, + SectorType, +) +from zhixing_server.modules.sector_radar.domain.persistence import PublicationSourceGroup +from zhixing_server.modules.sector_radar.domain.source import ( + CapabilityProbeResult, + DailyRow, + MoneyflowDcRow, + SectorIndexRow, + SectorMemberRow, + SourceContractError, + SourceResult, + SourceSnapshot, + StockBasicRow, + SuspendRow, + TradeCalendarRow, + build_source_snapshot, +) +from zhixing_server.modules.sector_radar.infrastructure.memory import ( + InMemorySectorRadarRepository, +) + +TARGET_DATE = date(2026, 8, 28) +NOW = datetime(2026, 8, 28, 17, 30, tzinfo=UTC) + + +class FakeRadarSource: + def __init__( + self, + *, + missing_moneyflow: bool = False, + missing_membership: bool = False, + net_scale: Decimal = Decimal(1), + ) -> None: + self.missing_moneyflow = missing_moneyflow + self.missing_membership = missing_membership + self.net_scale = net_scale + self.fail_daily = False + self.calls: list[str] = [] + self.moneyflow_candidate_codes: list[tuple[str, ...]] = [] + + def _result[T]( + self, api_name: str, target: date | None, rows: tuple[T, ...] + ) -> SourceResult[T]: + snapshot = build_source_snapshot( + api_name=api_name, + params={ + "trade_date": target.isoformat() if target is not None else "all", + "fixture_fingerprint": repr(rows), + }, + rows=tuple(self._raw_row(row) for row in rows), + target_trade_date=target, + partition_key="all" if api_name == "dc_member" else None, + observed_at=NOW, + ) + return SourceResult((snapshot,), rows) + + @staticmethod + def _raw_row(row: object) -> dict[str, object]: + if isinstance(row, TradeCalendarRow): + return { + "exchange": row.exchange, + "cal_date": row.cal_date, + "is_open": int(row.is_open), + "pretrade_date": row.pretrade_date, + } + if isinstance(row, SectorIndexRow): + return { + "trade_date": row.trade_date, + "ts_code": row.sector_code, + "name": row.name, + "level": row.level, + "pct_change": row.pct_change, + "leading_code": row.leading_code, + } + if isinstance(row, SectorMemberRow): + return { + "trade_date": row.trade_date, + "ts_code": row.sector_code, + "con_code": row.stock_code, + "name": row.stock_name, + } + if isinstance(row, StockBasicRow): + return { + "ts_code": row.ts_code, + "symbol": row.symbol, + "name": row.name, + "market": row.market, + "exchange": row.exchange, + "list_status": row.list_status, + "list_date": row.list_date, + "delist_date": row.delist_date, + } + if isinstance(row, SuspendRow): + return { + "ts_code": row.ts_code, + "trade_date": row.trade_date, + "suspend_timing": row.suspend_timing, + "suspend_type": row.suspend_type, + } + if isinstance(row, DailyRow): + return { + "ts_code": row.ts_code, + "trade_date": row.trade_date, + "close": row.close, + "pre_close": row.pre_close, + "pct_chg": row.pct_chg, + "vol": row.volume, + "amount": row.amount_thousand_yuan, + } + if isinstance(row, MoneyflowDcRow): + return { + "trade_date": row.trade_date, + "ts_code": row.ts_code, + "name": row.name, + "net_amount": row.net_amount_ten_thousand_yuan, + "net_amount_rate": row.net_amount_rate, + "pct_change": row.pct_change, + "close": row.close, + } + raise TypeError(f"unsupported fake source row: {type(row).__name__}") + + def fetch_trade_calendar(self, start: date, end: date) -> SourceResult[TradeCalendarRow]: + self.calls.append("calendar") + rows = tuple( + TradeCalendarRow("SSE", start + timedelta(days=offset), True, None) + for offset in range((end - start).days + 1) + ) + return self._result("trade_cal", end, rows) + + def fetch_sector_indices( + self, trade_date: date, sector_type: SectorType + ) -> SourceResult[SectorIndexRow]: + self.calls.append(f"{sector_type.value}_indices") + prefix = "BK0" if sector_type is SectorType.CONCEPT else "BK1" + row = SectorIndexRow( + trade_date, + sector_type, + f"{prefix}001.DC", + "示例概念" if sector_type is SectorType.CONCEPT else "示例行业", + "一级", + Decimal(1), + "000001.SZ", + ) + return self._result(f"dc_index_{sector_type.value}", trade_date, (row,)) + + def fetch_sector_members( + self, trade_date: date, sector_codes: Sequence[str] + ) -> SourceResult[SectorMemberRow]: + self.calls.append("members") + if self.missing_membership: + snapshots: list[SourceSnapshot] = [] + member_rows: list[SectorMemberRow] = [] + for sector_code in sector_codes: + sector_rows = ( + () + if sector_code == sector_codes[-1] + else tuple( + SectorMemberRow( + trade_date, + sector_code, + f"00000{index}.SZ", + f"股票{index}", + ) + for index in range(1, 6) + ) + ) + snapshots.append( + build_source_snapshot( + api_name="dc_member", + params={ + "trade_date": trade_date.isoformat(), + "ts_code": sector_code, + }, + rows=tuple(self._raw_row(row) for row in sector_rows), + target_trade_date=trade_date, + partition_key=sector_code, + observed_at=NOW, + ) + ) + member_rows.extend(sector_rows) + return SourceResult(tuple(snapshots), tuple(member_rows)) + rows = tuple( + SectorMemberRow(trade_date, sector_code, f"00000{index}.SZ", f"股票{index}") + for sector_code in sector_codes + for index in range(1, 6) + ) + return self._result("dc_member", trade_date, rows) + + def fetch_stock_basics(self) -> SourceResult[StockBasicRow]: + self.calls.append("stock_basics") + rows = tuple( + StockBasicRow( + f"00000{index}.SZ", + f"00000{index}", + f"股票{index}", + "主板", + "SZSE", + "L", + date(2020, 1, 1), + None, + ) + for index in range(1, 6) + ) + return self._result("stock_basic", None, rows) + + def fetch_suspensions(self, trade_date: date) -> SourceResult[SuspendRow]: + self.calls.append("suspensions") + return self._result("suspend_d", trade_date, ()) + + def fetch_daily(self, trade_date: date) -> SourceResult[DailyRow]: + self.calls.append("daily") + if self.fail_daily: + raise RuntimeError("private provider detail") + rows = tuple( + DailyRow( + f"00000{index}.SZ", + trade_date, + Decimal(10), + Decimal(10), + Decimal(0), + Decimal(100), + Decimal(1000), + ) + for index in range(1, 6) + ) + return self._result("daily", trade_date, rows) + + def fetch_moneyflow_dc( + self, + trade_date: date, + candidate_codes: Sequence[str], + ) -> SourceResult[MoneyflowDcRow]: + self.calls.append("moneyflow_dc") + self.moneyflow_candidate_codes.append(tuple(candidate_codes)) + count = 4 if self.missing_moneyflow else 5 + rows = tuple( + MoneyflowDcRow( + trade_date, + f"00000{index}.SZ", + f"股票{index}", + Decimal(index) * self.net_scale, + Decimal(0), + Decimal(0), + Decimal(10), + ) + for index in range(1, count + 1) + ) + return self._result("moneyflow_dc", trade_date, rows) + + def probe(self, trade_date: date) -> CapabilityProbeResult: + return CapabilityProbeResult(NOW, ()) + + +def test_successful_build_is_idempotent_and_failed_retry_preserves_last_good() -> None: + source = FakeRadarSource() + repository = InMemorySectorRadarRepository() + use_case = BuildSectorRadar( + source, + repository, + today=TARGET_DATE, + now_fn=lambda: NOW, + ) + + first = use_case.execute(BuildSectorRadarCommand(trade_date=TARGET_DATE)) + repeated = use_case.execute(BuildSectorRadarCommand(trade_date=TARGET_DATE)) + source.fail_daily = True + failed = use_case.execute(BuildSectorRadarCommand(trade_date=TARGET_DATE)) + + assert first.status == "success" + assert first.exit_code == 0 + assert first.outcomes[0].ranking_count == 6 + assert repeated.status == "unchanged" + assert repeated.outcomes[0].publication_id == first.outcomes[0].publication_id + assert failed.status == "failed" + assert "private provider detail" not in str(failed.as_dict()) + last_good = repository.get_last_good_publication() + assert last_good is not None + assert last_good.publication_id == first.outcomes[0].publication_id + assert any(item.status is PublicationStatus.FAILED for item in repository.publications.values()) + + +def test_build_passes_stable_current_listing_member_intersection_to_moneyflow() -> None: + class FutureListingSource(FakeRadarSource): + def fetch_stock_basics(self) -> SourceResult[StockBasicRow]: + result = super().fetch_stock_basics() + rows = result.rows[:-1] + (replace(result.rows[-1], list_date=date(2027, 1, 1)),) + return self._result("stock_basic", None, rows) + + source = FutureListingSource() + + summary = BuildSectorRadar( + source, + InMemorySectorRadarRepository(), + today=TARGET_DATE, + now_fn=lambda: NOW, + ).execute(BuildSectorRadarCommand(trade_date=TARGET_DATE)) + + assert summary.status == "success" + assert source.moneyflow_candidate_codes == [ + ("000001.SZ", "000002.SZ", "000003.SZ", "000004.SZ") + ] + + +def test_source_contract_failure_is_logged_with_safe_build_context( + caplog: pytest.LogCaptureFixture, +) -> None: + class InvalidDailySource(FakeRadarSource): + def fetch_daily(self, trade_date: date) -> SourceResult[DailyRow]: + self.calls.append("daily") + raise SourceContractError("daily returned duplicate business keys") + + repository = InMemorySectorRadarRepository() + caplog.set_level( + logging.ERROR, + logger="zhixing_server.modules.sector_radar.application.build", + ) + + failed = BuildSectorRadar( + InvalidDailySource(), + repository, + today=TARGET_DATE, + now_fn=lambda: NOW, + ).execute(BuildSectorRadarCommand(trade_date=TARGET_DATE)) + + messages = "\n".join(record.getMessage() for record in caplog.records) + assert failed.status == "failed" + assert failed.outcomes[0].error_message == "input or source contract validation failed" + assert "sector_radar_source_group_contract_failed" in messages + assert "source_group=daily" in messages + assert "publication_id=radar-20260828-running-" in messages + assert "validation=daily returned duplicate business keys" in messages + assert len(caplog.records) == 1 + + +def test_partial_coverage_and_lock_have_distinct_exit_codes() -> None: + repository = InMemorySectorRadarRepository() + partial = BuildSectorRadar( + FakeRadarSource(missing_moneyflow=True), + repository, + today=TARGET_DATE, + now_fn=lambda: NOW, + ).execute(BuildSectorRadarCommand(trade_date=TARGET_DATE)) + publication_count = len(repository.publications) + partial_repeated = BuildSectorRadar( + FakeRadarSource(missing_moneyflow=True), + repository, + today=TARGET_DATE, + now_fn=lambda: NOW, + ).execute(BuildSectorRadarCommand(trade_date=TARGET_DATE)) + + repository.lock_available = False + locked = BuildSectorRadar( + FakeRadarSource(), + repository, + today=TARGET_DATE, + now_fn=lambda: NOW, + ).execute(BuildSectorRadarCommand(trade_date=TARGET_DATE)) + + assert partial.status == "partial" + assert partial.exit_code == 2 + assert partial.outcomes[0].coverage == Decimal("0.8") + assert partial_repeated.status == "partial" + assert len(repository.publications) == publication_count + assert repository.get_last_good_publication() is None + assert { + record.source_group + for record in repository.load_publication_sources(partial.outcomes[0].publication_id or "") + if record.refresh_on_retry + } == {PublicationSourceGroup.MONEYFLOW_DC} + assert locked.status == "failed" + assert locked.exit_code == 1 + assert locked.outcomes[0].status == "locked" + + +def test_unknown_membership_is_persisted_as_partial_and_retried_independently() -> None: + repository = InMemorySectorRadarRepository() + source = FakeRadarSource(missing_membership=True) + use_case = BuildSectorRadar(source, repository, today=TARGET_DATE, now_fn=lambda: NOW) + + partial = use_case.execute(BuildSectorRadarCommand(trade_date=TARGET_DATE)) + partial_id = partial.outcomes[0].publication_id + assert partial_id is not None + publication = repository.get_publication(partial_id) + assert partial.status == "partial" + assert publication is not None + assert publication.error_summary == "membership_unknown" + assert any(item.status is MembershipStatus.UNKNOWN for item in repository.memberships.values()) + assert { + record.source_group + for record in repository.load_publication_sources(partial_id) + if record.refresh_on_retry + } == {PublicationSourceGroup.MEMBERS} + + source.missing_membership = False + source.calls.clear() + retried = use_case.execute(BuildSectorRadarCommand(retry_publication_id=partial_id)) + + assert retried.status == "success" + assert source.calls == ["members"] + + +def test_membership_retry_refreshes_moneyflow_when_replay_misses_new_candidates() -> None: + class ExpandingMembershipSource(FakeRadarSource): + def fetch_sector_members( + self, + trade_date: date, + sector_codes: Sequence[str], + ) -> SourceResult[SectorMemberRow]: + self.calls.append("members") + rows: list[SectorMemberRow] = [] + snapshots: list[SourceSnapshot] = [] + for index, sector_code in enumerate(sector_codes, start=1): + sector_rows = ( + () + if self.missing_membership and index == len(sector_codes) + else ( + SectorMemberRow( + trade_date, + sector_code, + f"00000{index}.SZ", + f"股票{index}", + ), + ) + ) + snapshots.append( + build_source_snapshot( + api_name="dc_member", + params={ + "trade_date": trade_date.isoformat(), + "ts_code": sector_code, + }, + rows=tuple(self._raw_row(row) for row in sector_rows), + target_trade_date=trade_date, + partition_key=sector_code, + observed_at=NOW, + ) + ) + rows.extend(sector_rows) + return SourceResult(tuple(snapshots), tuple(rows)) + + def fetch_moneyflow_dc( + self, + trade_date: date, + candidate_codes: Sequence[str], + ) -> SourceResult[MoneyflowDcRow]: + self.calls.append("moneyflow_dc") + self.moneyflow_candidate_codes.append(tuple(candidate_codes)) + rows = tuple( + MoneyflowDcRow( + trade_date, + code, + code, + Decimal(1), + Decimal(0), + Decimal(0), + Decimal(10), + ) + for code in candidate_codes + ) + return self._result("moneyflow_dc", trade_date, rows) + + repository = InMemorySectorRadarRepository() + source = ExpandingMembershipSource(missing_membership=True) + use_case = BuildSectorRadar(source, repository, today=TARGET_DATE, now_fn=lambda: NOW) + + partial = use_case.execute(BuildSectorRadarCommand(trade_date=TARGET_DATE)) + partial_id = partial.outcomes[0].publication_id + assert partial.status == "partial" + assert partial_id is not None + assert source.moneyflow_candidate_codes == [("000001.SZ",)] + + source.missing_membership = False + source.calls.clear() + retried = use_case.execute(BuildSectorRadarCommand(retry_publication_id=partial_id)) + + assert retried.status == "success" + assert source.calls == ["members", "moneyflow_dc"] + assert source.moneyflow_candidate_codes[-1] == ("000001.SZ", "000002.SZ") + + +def test_range_builds_dates_in_order_and_retry_uses_old_target() -> None: + repository = InMemorySectorRadarRepository() + source = FakeRadarSource(missing_moneyflow=True) + use_case = BuildSectorRadar(source, repository, now_fn=lambda: NOW) + end = TARGET_DATE + timedelta(days=1) + + summary = use_case.execute(BuildSectorRadarCommand(start_date=TARGET_DATE, end_date=end)) + partial_id = summary.outcomes[0].publication_id + assert partial_id is not None + source.missing_moneyflow = False + source.calls.clear() + retried = use_case.execute(BuildSectorRadarCommand(retry_publication_id=partial_id)) + + assert [item.target_trade_date for item in summary.outcomes] == [TARGET_DATE, end] + assert all(item.status == "partial" for item in summary.outcomes) + assert retried.outcomes[0].target_trade_date == TARGET_DATE + assert retried.status == "success" + assert source.calls == ["moneyflow_dc"] + + +def test_failed_retry_reuses_every_completed_source_group() -> None: + repository = InMemorySectorRadarRepository() + source = FakeRadarSource() + source.fail_daily = True + use_case = BuildSectorRadar(source, repository, now_fn=lambda: NOW) + + failed = use_case.execute(BuildSectorRadarCommand(trade_date=TARGET_DATE)) + failed_id = failed.outcomes[0].publication_id + assert failed_id is not None + source.fail_daily = False + source.calls.clear() + + retried = use_case.execute(BuildSectorRadarCommand(retry_publication_id=failed_id)) + + assert retried.status == "success" + assert source.calls == ["daily", "moneyflow_dc"] + old_groups = {record.source_group for record in repository.load_publication_sources(failed_id)} + assert len(old_groups) == 6 + + +def test_date_lock_recovers_an_orphaned_running_publication() -> None: + repository = InMemorySectorRadarRepository() + stale = RadarPublication( + publication_id="stale-running", + target_trade_date=TARGET_DATE, + status=PublicationStatus.RUNNING, + source_version="tushare-pro-v1", + universe_version="pending", + metric_versions=("zhixing_amount_net_bn_v1",), + input_hash=None, + coverage=Decimal(0), + started_at=NOW - timedelta(hours=1), + ) + repository.create_publication(stale) + + summary = BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute( + BuildSectorRadarCommand(trade_date=TARGET_DATE) + ) + + recovered = repository.get_publication("stale-running") + assert summary.status == "success" + assert recovered is not None + assert recovered.status is PublicationStatus.FAILED + assert recovered.error_summary == "recovered_stale_running" + + +def test_default_target_excludes_today_before_closing_data_is_ready() -> None: + before_close = datetime(2026, 8, 28, 6, 0, tzinfo=UTC) + after_close = datetime(2026, 8, 28, 8, 0, tzinfo=UTC) + + before = BuildSectorRadar( + FakeRadarSource(), + InMemorySectorRadarRepository(), + today=TARGET_DATE, + now_fn=lambda: before_close, + ).execute() + after = BuildSectorRadar( + FakeRadarSource(), + InMemorySectorRadarRepository(), + today=TARGET_DATE, + now_fn=lambda: after_close, + ).execute() + + assert before.outcomes[0].target_trade_date == TARGET_DATE - timedelta(days=1) + assert after.outcomes[0].target_trade_date == TARGET_DATE + + +def test_tenth_trading_day_publishes_swing_and_five_rank_changes() -> None: + repository = InMemorySectorRadarRepository() + end = TARGET_DATE + timedelta(days=9) + summary = BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute( + BuildSectorRadarCommand(start_date=TARGET_DATE, end_date=end) + ) + + publication = repository.get_last_good_publication(end) + assert summary.status == "success" + assert publication is not None + current = tuple( + record.ranking + for record in repository.rankings.values() + if record.publication_id == publication.publication_id + ) + swing = tuple( + ranking + for ranking in current + if ranking.observation.metric_version == "zhixing_swing_equal_3_10_v1" + ) + assert len(swing) == 2 + assert all(ranking.observation.value == Decimal("0.03") for ranking in swing) + assert all( + tuple(change.value for change in ranking.rank_changes) == (None, None, None, None, None) + for ranking in swing + ) + amount = tuple( + ranking + for ranking in current + if ranking.observation.metric_version == "zhixing_amount_net_bn_v1" + ) + assert all( + tuple(change.value for change in ranking.rank_changes) == (0, 0, 0, 0, 0) + for ranking in amount + ) + + +def test_history_uses_latest_successful_input_revision_for_a_date() -> None: + repository = InMemorySectorRadarRepository() + clock = [NOW] + first_source = FakeRadarSource(net_scale=Decimal(1)) + second_source = FakeRadarSource(net_scale=Decimal(2)) + + first = BuildSectorRadar(first_source, repository, now_fn=lambda: clock[0]).execute( + BuildSectorRadarCommand(trade_date=TARGET_DATE) + ) + clock[0] = NOW + timedelta(minutes=5) + second = BuildSectorRadar(second_source, repository, now_fn=lambda: clock[0]).execute( + BuildSectorRadarCommand(trade_date=TARGET_DATE) + ) + + history = repository.load_daily_aggregate_history( + TARGET_DATE + timedelta(days=1), limit_dates=1 + ) + assert first.status == "success" + assert second.status == "success" + assert len(history) == 2 + assert all(item.net_amount_yuan == Decimal(300_000) for item in history) diff --git a/zhixing-server/tests/unit/sector_radar/test_cli.py b/zhixing-server/tests/unit/sector_radar/test_cli.py new file mode 100644 index 0000000..e9101b9 --- /dev/null +++ b/zhixing-server/tests/unit/sector_radar/test_cli.py @@ -0,0 +1,129 @@ +import json +from datetime import date +from decimal import Decimal + +import pytest + +from zhixing_server.modules.sector_radar.application.build import ( + BuildDateOutcome, + BuildOutcomeStatus, + BuildSummary, +) +from zhixing_server.modules.sector_radar.presentation import cli +from zhixing_server.modules.sector_radar.presentation.cli import build_parser + + +def test_sector_radar_cli_parses_single_range_and_retry_modes() -> None: + parser = build_parser() + + single = parser.parse_args(["--trade-date", "2026-08-28"]) + date_range = parser.parse_args(["--start-date", "2026-08-18", "--end-date", "2026-08-28"]) + retry = parser.parse_args(["--retry-publication-id", "publication-a"]) + + assert single.trade_date == date(2026, 8, 28) + assert date_range.start_date == date(2026, 8, 18) + assert date_range.end_date == date(2026, 8, 28) + assert retry.retry_publication_id == "publication-a" + + +class FakeSettings: + log_level = "INFO" + tushare_token = "secret-token" + database_url = "postgresql://unused" + sector_radar_max_retries = 3 + sector_radar_retry_backoff_seconds = 1.0 + sector_radar_request_interval_seconds = 0.2 + sector_radar_advisory_lock_key = 7_380_522 + sector_radar_coverage_threshold = Decimal("0.99") + + +class FakeRepository: + closed = False + + def __init__(self, database_url: str, *, advisory_lock_key: int) -> None: + assert database_url == "postgresql://unused" + assert advisory_lock_key == 7_380_522 + + def close(self) -> None: + self.closed = True + + +@pytest.mark.parametrize( + ("outcome_status", "coverage", "expected_code"), + (("success", Decimal(1), 0), ("partial", Decimal("0.8"), 2), ("failed", Decimal(0), 1)), +) +def test_cli_main_returns_summary_exit_code_and_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + outcome_status: BuildOutcomeStatus, + coverage: Decimal, + expected_code: int, +) -> None: + summary = BuildSummary( + ( + BuildDateOutcome( + date(2026, 8, 28), + outcome_status, + "publication-a", + coverage, + 2, + 6, + ), + ) + ) + + class FakeSourceFactory: + @staticmethod + def from_token(token: str, **kwargs: object) -> object: + assert token == "secret-token" + assert kwargs["max_retries"] == 3 + assert kwargs["backoff_seconds"] == 1.0 + assert kwargs["request_interval_seconds"] == 0.2 + return object() + + class FakeBuild: + def __init__(self, source: object, repository: object, **kwargs: object) -> None: + assert source is not None + assert repository is not None + assert kwargs + + def execute(self, command: object) -> BuildSummary: + assert command is not None + return summary + + monkeypatch.setattr(cli, "get_settings", FakeSettings) + monkeypatch.setattr(cli, "TushareSectorRadarAdapter", FakeSourceFactory) + monkeypatch.setattr(cli, "PostgresSectorRadarRepository", FakeRepository) + monkeypatch.setattr(cli, "BuildSectorRadar", FakeBuild) + + exit_code = cli.main(["--trade-date", "2026-08-28"]) + output = json.loads(capsys.readouterr().out) + + assert exit_code == expected_code + assert output["status"] == summary.status + assert output["exit_code"] == expected_code + assert "secret-token" not in str(output) + + +def test_cli_initialization_failure_is_redacted( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class FailingSourceFactory: + @staticmethod + def from_token(token: str, **kwargs: object) -> object: + del token, kwargs + raise RuntimeError("private provider detail secret-token") + + monkeypatch.setattr(cli, "get_settings", FakeSettings) + monkeypatch.setattr(cli, "TushareSectorRadarAdapter", FailingSourceFactory) + + exit_code = cli.main(["--trade-date", "2026-08-28"]) + captured = capsys.readouterr() + output = json.loads(captured.out) + + assert exit_code == 1 + assert output["status"] == "failed" + assert output["error_type"] == "RuntimeError" + assert "private provider detail" not in captured.out + assert "secret-token" not in captured.out diff --git a/zhixing-server/tests/unit/sector_radar/test_facts.py b/zhixing-server/tests/unit/sector_radar/test_facts.py new file mode 100644 index 0000000..6f5af77 --- /dev/null +++ b/zhixing-server/tests/unit/sector_radar/test_facts.py @@ -0,0 +1,146 @@ +from datetime import UTC, date, datetime +from decimal import Decimal + +import pytest + +from zhixing_server.modules.sector_radar.domain.facts import aggregate_sector_snapshot +from zhixing_server.modules.sector_radar.domain.models import ( + MembershipStatus, + PublicationStatus, + RadarPublication, + SectorMembershipSnapshot, + SectorType, + StockDailyFact, + StockFactStatus, +) + +TARGET_DATE = date(2026, 8, 28) + + +def test_point_in_time_aggregation_distinguishes_suspension_missing_and_zero() -> None: + snapshot = SectorMembershipSnapshot( + trade_date=TARGET_DATE, + sector_type=SectorType.CONCEPT, + sector_code="BK0001.DC", + sector_name="示例概念", + member_codes=("000001.SZ", "000002.SZ", "000003.SZ", "000004.SZ"), + status=MembershipStatus.AVAILABLE, + source_version="dc-member-20260828-a", + ) + facts = ( + StockDailyFact( + trade_date=TARGET_DATE, + ts_code="000001.SZ", + status=StockFactStatus.AVAILABLE, + turnover_yuan=Decimal("1000"), + net_amount_yuan=Decimal("100"), + ), + StockDailyFact( + trade_date=TARGET_DATE, + ts_code="000002.SZ", + status=StockFactStatus.AVAILABLE, + turnover_yuan=Decimal("2000"), + net_amount_yuan=Decimal("0"), + ), + StockDailyFact( + trade_date=TARGET_DATE, + ts_code="000003.SZ", + status=StockFactStatus.SUSPENDED, + ), + StockDailyFact( + trade_date=TARGET_DATE, + ts_code="000004.SZ", + status=StockFactStatus.MISSING_MONEYFLOW, + ), + ) + + aggregate = aggregate_sector_snapshot(snapshot, facts) + + assert aggregate.member_count == 4 + assert aggregate.valid_sample_count == 2 + assert aggregate.net_amount_yuan == Decimal("100") + assert aggregate.turnover_yuan == Decimal("3000") + assert aggregate.membership_coverage == Decimal("1") + assert aggregate.moneyflow_coverage == Decimal("2") / Decimal("3") + + +def test_unknown_membership_never_falls_back_to_available_stock_facts() -> None: + snapshot = SectorMembershipSnapshot( + trade_date=TARGET_DATE, + sector_type=SectorType.INDUSTRY, + sector_code="BK1001.DC", + sector_name="示例行业", + member_codes=(), + status=MembershipStatus.UNKNOWN, + source_version="dc-member-missing", + ) + fact = StockDailyFact( + trade_date=TARGET_DATE, + ts_code="000001.SZ", + status=StockFactStatus.AVAILABLE, + turnover_yuan=Decimal("1000"), + net_amount_yuan=Decimal("100"), + ) + + aggregate = aggregate_sector_snapshot(snapshot, (fact,)) + + assert aggregate.member_count == 0 + assert aggregate.net_amount_yuan is None + assert aggregate.turnover_yuan is None + assert aggregate.membership_coverage == Decimal("0") + + +def test_stock_fact_rejects_non_finite_values_and_invalid_status_payloads() -> None: + with pytest.raises(ValueError, match="finite"): + StockDailyFact( + trade_date=TARGET_DATE, + ts_code="000001.SZ", + status=StockFactStatus.AVAILABLE, + turnover_yuan=Decimal("Infinity"), + net_amount_yuan=Decimal("1"), + ) + + with pytest.raises(ValueError, match="must not expose amounts"): + StockDailyFact( + trade_date=TARGET_DATE, + ts_code="000001.SZ", + status=StockFactStatus.SUSPENDED, + turnover_yuan=Decimal("0"), + ) + + +def test_publication_requires_terminal_completion_and_replay_identity() -> None: + started_at = datetime(2026, 8, 28, 17, 30, tzinfo=UTC) + + publication = RadarPublication( + publication_id="radar-20260828-a", + target_trade_date=TARGET_DATE, + status=PublicationStatus.SUCCESS, + source_version="tushare-pro-v1", + universe_version="eastmoney-dc-20260828-a", + metric_versions=( + "zhixing_amount_net_bn_v1", + "zhixing_ratio_turnover_v1", + "zhixing_swing_equal_3_10_v1", + ), + input_hash="a" * 64, + coverage=Decimal("0.995"), + started_at=started_at, + finished_at=datetime(2026, 8, 28, 17, 35, tzinfo=UTC), + ) + + assert publication.status is PublicationStatus.SUCCESS + + with pytest.raises(ValueError, match="finished_at"): + RadarPublication( + publication_id="radar-20260828-running", + target_trade_date=TARGET_DATE, + status=PublicationStatus.RUNNING, + source_version="tushare-pro-v1", + universe_version="eastmoney-dc-20260828-a", + metric_versions=("zhixing_amount_net_bn_v1",), + input_hash=None, + coverage=Decimal("0"), + started_at=started_at, + finished_at=started_at, + ) diff --git a/zhixing-server/tests/unit/sector_radar/test_metrics.py b/zhixing-server/tests/unit/sector_radar/test_metrics.py new file mode 100644 index 0000000..8d49fca --- /dev/null +++ b/zhixing-server/tests/unit/sector_radar/test_metrics.py @@ -0,0 +1,112 @@ +from datetime import date +from decimal import Decimal + +from zhixing_server.modules.sector_radar.domain.metrics import ( + AmountNetStrategy, + RatioTurnoverStrategy, + SwingEqualThreeToTenStrategy, +) +from zhixing_server.modules.sector_radar.domain.models import ( + MetricQuality, + SectorDailyAggregate, + SectorType, +) + +TARGET_DATE = date(2026, 8, 28) + + +def make_aggregate( + *, + net_amount_yuan: Decimal | None = Decimal("125000000"), + turnover_yuan: Decimal | None = Decimal("5000000000"), +) -> SectorDailyAggregate: + return SectorDailyAggregate( + trade_date=TARGET_DATE, + sector_type=SectorType.CONCEPT, + sector_code="BK0001.DC", + sector_name="示例概念", + member_count=10, + valid_sample_count=10, + net_amount_yuan=net_amount_yuan, + turnover_yuan=turnover_yuan, + membership_coverage=Decimal("1"), + moneyflow_coverage=Decimal("1"), + ) + + +def test_amount_and_ratio_strategies_expose_independent_versioned_values() -> None: + aggregate = make_aggregate() + + amount = AmountNetStrategy().evaluate((aggregate,), TARGET_DATE) + ratio = RatioTurnoverStrategy().evaluate((aggregate,), TARGET_DATE) + + assert amount.value == Decimal("1.25") + assert amount.metric_version == "zhixing_amount_net_bn_v1" + assert amount.implementation_kind == "independent" + assert amount.unit == "CNY_100M" + assert amount.quality is MetricQuality.AVAILABLE + + assert ratio.value == Decimal("0.025") + assert ratio.metric_version == "zhixing_ratio_turnover_v1" + assert ratio.implementation_kind == "independent" + assert ratio.unit == "ratio" + + +def test_missing_moneyflow_is_unavailable_but_zero_remains_a_real_value() -> None: + missing = AmountNetStrategy().evaluate((make_aggregate(net_amount_yuan=None),), TARGET_DATE) + zero = AmountNetStrategy().evaluate( + (make_aggregate(net_amount_yuan=Decimal("0")),), TARGET_DATE + ) + + assert missing.value is None + assert missing.quality is MetricQuality.UNAVAILABLE + assert zero.value == Decimal("0") + assert zero.quality is MetricQuality.AVAILABLE + + +def test_swing_strategy_uses_each_days_point_in_time_aggregate() -> None: + history = tuple( + SectorDailyAggregate( + trade_date=date(2026, 8, 18 + offset), + sector_type=SectorType.CONCEPT, + sector_code="BK0001.DC", + sector_name="示例概念", + member_count=6 + offset, + valid_sample_count=6 + offset, + net_amount_yuan=Decimal(str(offset + 1)), + turnover_yuan=Decimal("100"), + membership_coverage=Decimal("1"), + moneyflow_coverage=Decimal("1"), + ) + for offset in range(10) + ) + + result = SwingEqualThreeToTenStrategy().evaluate(history, date(2026, 8, 27)) + + # The worked 3..10-day window ratios average to exactly 0.0725. + assert result.value == Decimal("0.0725") + assert result.metric_version == "zhixing_swing_equal_3_10_v1" + assert result.member_count == 15 + + +def test_swing_strategy_carries_forward_limited_historical_sample_quality() -> None: + history = tuple( + SectorDailyAggregate( + trade_date=date(2026, 8, 18 + offset), + sector_type=SectorType.INDUSTRY, + sector_code="BK1001.DC", + sector_name="示例行业", + member_count=10, + valid_sample_count=4 if offset == 0 else 10, + net_amount_yuan=Decimal("10"), + turnover_yuan=Decimal("100"), + membership_coverage=Decimal("1"), + moneyflow_coverage=Decimal("1"), + ) + for offset in range(10) + ) + + result = SwingEqualThreeToTenStrategy().evaluate(history, date(2026, 8, 27)) + + assert result.value == Decimal("0.1") + assert result.quality is MetricQuality.AVAILABLE_LIMITED_SAMPLE diff --git a/zhixing-server/tests/unit/sector_radar/test_normalize.py b/zhixing-server/tests/unit/sector_radar/test_normalize.py new file mode 100644 index 0000000..3c9859f --- /dev/null +++ b/zhixing-server/tests/unit/sector_radar/test_normalize.py @@ -0,0 +1,215 @@ +from datetime import UTC, date, datetime +from decimal import Decimal + +from zhixing_server.modules.sector_radar.domain.models import ( + MembershipStatus, + SectorType, + StockFactStatus, +) +from zhixing_server.modules.sector_radar.domain.normalize import ( + normalize_memberships, + normalize_stock_facts, +) +from zhixing_server.modules.sector_radar.domain.source import ( + DailyRow, + MoneyflowDcRow, + SectorIndexRow, + SectorMemberRow, + SourceResult, + StockBasicRow, + SuspendRow, + build_source_snapshot, +) + +TARGET_DATE = date(2026, 8, 28) +OBSERVED_AT = datetime(2026, 8, 28, 17, 30, tzinfo=UTC) + + +def result[T](api_name: str, rows: tuple[T, ...]) -> SourceResult[T]: + snapshot = build_source_snapshot( + api_name=api_name, + params={"trade_date": "20260828"}, + rows=(), + target_trade_date=TARGET_DATE, + observed_at=OBSERVED_AT, + ) + return SourceResult((snapshot,), rows) + + +def basic( + ts_code: str, + *, + list_date: date = date(2020, 1, 1), + market: str | None = "主板", +) -> StockBasicRow: + return StockBasicRow( + ts_code=ts_code, + symbol=ts_code.split(".")[0], + name=ts_code, + market=market, + exchange="SZSE", + list_status="L", + list_date=list_date, + delist_date=None, + ) + + +def test_missing_market_keeps_hs_a_stock_but_code_rules_still_exclude_bse_and_b_shares() -> None: + codes = ("000001.SZ", "920001.BJ", "200001.SZ", "900001.SH") + basics = tuple(basic(code, market=None) for code in codes) + daily_rows = tuple(daily(code, Decimal("1")) for code in codes) + moneyflow_rows = tuple(moneyflow(code, Decimal("1")) for code in codes) + + facts = normalize_stock_facts( + target_trade_date=TARGET_DATE, + candidate_codes=codes, + stock_basics=result("stock_basic", basics), + suspensions=result("suspend_d", ()), + daily=result("daily", daily_rows), + moneyflow=result("moneyflow_dc", moneyflow_rows), + ) + by_code = {fact.ts_code: fact for fact in facts} + + assert by_code["000001.SZ"].status is StockFactStatus.AVAILABLE + assert by_code["920001.BJ"].status is StockFactStatus.LIFECYCLE_INVALID + assert by_code["200001.SZ"].status is StockFactStatus.LIFECYCLE_INVALID + assert by_code["900001.SH"].status is StockFactStatus.LIFECYCLE_INVALID + + +def daily(ts_code: str, amount: Decimal | None) -> DailyRow: + return DailyRow( + ts_code=ts_code, + trade_date=TARGET_DATE, + close=Decimal("10"), + pre_close=Decimal("10"), + pct_chg=Decimal(0), + volume=Decimal(0), + amount_thousand_yuan=amount, + ) + + +def moneyflow(ts_code: str, amount: Decimal | None) -> MoneyflowDcRow: + return MoneyflowDcRow( + trade_date=TARGET_DATE, + ts_code=ts_code, + name=ts_code, + net_amount_ten_thousand_yuan=amount, + net_amount_rate=Decimal(0), + pct_change=Decimal(0), + close=Decimal("10"), + ) + + +def test_membership_normalization_persists_an_explicit_unknown_sector() -> None: + indices = ( + SectorIndexRow( + TARGET_DATE, + SectorType.CONCEPT, + "BK0001.DC", + "机器人", + "一级", + Decimal(1), + None, + ), + SectorIndexRow( + TARGET_DATE, + SectorType.CONCEPT, + "BK0002.DC", + "低空经济", + "一级", + Decimal(1), + None, + ), + ) + member = SectorMemberRow( + TARGET_DATE, + "BK0001.DC", + "000001.SZ", + "平安银行", + ) + all_snapshot = build_source_snapshot( + api_name="dc_member", + params={"trade_date": "20260828"}, + rows=( + { + "trade_date": "20260828", + "ts_code": member.sector_code, + "con_code": member.stock_code, + "name": member.stock_name, + }, + ), + target_trade_date=TARGET_DATE, + partition_key="all", + observed_at=OBSERVED_AT, + ) + empty_partition = build_source_snapshot( + api_name="dc_member", + params={"trade_date": "20260828", "ts_code": "BK0002.DC"}, + rows=(), + target_trade_date=TARGET_DATE, + partition_key="BK0002.DC", + observed_at=OBSERVED_AT, + ) + + records = normalize_memberships( + indices, + SourceResult((all_snapshot, empty_partition), (member,)), + ) + + assert records[0].status is MembershipStatus.AVAILABLE + assert records[0].stock_code == "000001.SZ" + assert records[1].status is MembershipStatus.UNKNOWN + assert records[1].stock_code is None + assert records[1].membership_key == "__membership_unknown__" + + +def test_stock_fact_normalization_preserves_all_missing_and_zero_states() -> None: + codes = tuple(f"00000{index}.SZ" for index in range(1, 9)) + basics = tuple( + basic(code, list_date=date(2027, 1, 1) if code == codes[7] else date(2020, 1, 1)) + for code in codes + ) + daily_rows = ( + daily(codes[0], Decimal("1")), + daily(codes[3], None), + daily(codes[4], Decimal("1")), + daily(codes[5], Decimal("1")), + daily(codes[6], Decimal("0")), + daily(codes[7], Decimal("1")), + ) + moneyflow_rows = ( + moneyflow(codes[0], Decimal("0")), + moneyflow(codes[3], Decimal("1")), + moneyflow(codes[5], None), + moneyflow(codes[6], Decimal("0")), + moneyflow(codes[7], Decimal("1")), + ) + suspensions = ( + SuspendRow( + ts_code=codes[1], + trade_date=TARGET_DATE, + suspend_timing=None, + suspend_type="停牌", + ), + ) + + facts = normalize_stock_facts( + target_trade_date=TARGET_DATE, + candidate_codes=codes, + stock_basics=result("stock_basic", basics), + suspensions=result("suspend_d", suspensions), + daily=result("daily", daily_rows), + moneyflow=result("moneyflow_dc", moneyflow_rows), + ) + by_code = {fact.ts_code: fact for fact in facts} + + assert by_code[codes[0]].status is StockFactStatus.AVAILABLE + assert by_code[codes[0]].turnover_yuan == Decimal("1000") + assert by_code[codes[0]].net_amount_yuan == Decimal("0") + assert by_code[codes[1]].status is StockFactStatus.SUSPENDED + assert by_code[codes[2]].status is StockFactStatus.MISSING_DAILY + assert by_code[codes[3]].status is StockFactStatus.NULL_DAILY_AMOUNT + assert by_code[codes[4]].status is StockFactStatus.MISSING_MONEYFLOW + assert by_code[codes[5]].status is StockFactStatus.NULL_MONEYFLOW + assert by_code[codes[6]].status is StockFactStatus.LOW_LIQUIDITY + assert by_code[codes[7]].status is StockFactStatus.LIFECYCLE_INVALID diff --git a/zhixing-server/tests/unit/sector_radar/test_postgres_repository.py b/zhixing-server/tests/unit/sector_radar/test_postgres_repository.py new file mode 100644 index 0000000..f8629ad --- /dev/null +++ b/zhixing-server/tests/unit/sector_radar/test_postgres_repository.py @@ -0,0 +1,170 @@ +from collections.abc import Generator +from contextlib import contextmanager +from datetime import UTC, date, datetime +from decimal import Decimal +from typing import Any, cast + +from psycopg_pool import ConnectionPool + +from zhixing_server.modules.sector_radar.domain.models import PublicationStatus +from zhixing_server.modules.sector_radar.infrastructure.postgres import ( + PostgresSectorRadarRepository, +) + +TARGET_DATE = date(2026, 8, 28) + + +class FakeResult: + def __init__( + self, + row: tuple[object, ...] | None = None, + rows: tuple[tuple[object, ...], ...] | None = None, + ) -> None: + self.row = row + self.rows = rows or (() if row is None else (row,)) + + def fetchone(self) -> tuple[object, ...] | None: + return self.row + + def fetchall(self) -> tuple[tuple[object, ...], ...]: + return self.rows + + +class FakeConnection: + def __init__(self) -> None: + self.statements: list[tuple[str, tuple[object, ...]]] = [] + + def execute( + self, + query: str, + parameters: tuple[object, ...] = (), + ) -> FakeResult: + self.statements.append((query, parameters)) + if "FROM sector_radar_ranking" in query: + return FakeResult( + rows=( + ( + TARGET_DATE, + "concept", + "BK0001.DC", + "机器人", + "amount", + "zhixing_amount_net_bn_v1", + "independent", + "CNY_100M", + Decimal("12.5"), + "available", + 20, + 19, + Decimal(1), + Decimal("0.95"), + 1, + Decimal(100), + {"1": 3, "2": None}, + ), + ) + ) + if "FROM sector_radar_publication" in query: + return FakeResult( + ( + "publication-a", + TARGET_DATE, + "success", + "tushare-pro-v1", + "eastmoney-dc-v1", + ["zhixing_amount_net_bn_v1"], + "a" * 64, + Decimal("1"), + datetime(2026, 8, 28, 17, 30, tzinfo=UTC), + datetime(2026, 8, 28, 17, 35, tzinfo=UTC), + None, + ) + ) + if "pg_try_advisory_lock" in query: + return FakeResult((True,)) + return FakeResult((True,)) + + +class FakePool: + def __init__(self, connection: FakeConnection) -> None: + self._connection = connection + self._opened = False + + def open(self, *, wait: bool) -> None: + assert wait + self._opened = True + + def close(self) -> None: + self._opened = False + + @contextmanager + def connection(self) -> Generator[FakeConnection]: + yield self._connection + + +def make_repository(connection: FakeConnection) -> PostgresSectorRadarRepository: + pool = cast(ConnectionPool[Any], cast(object, FakePool(connection))) + return PostgresSectorRadarRepository("postgresql://unused", pool=pool) + + +def test_last_good_query_strictly_filters_success_and_date() -> None: + connection = FakeConnection() + + publication = make_repository(connection).get_last_good_publication(TARGET_DATE) + + assert publication is not None + assert publication.status is PublicationStatus.SUCCESS + query, parameters = connection.statements[0] + assert "status = 'success'" in query + assert "partial" not in query + assert "target_trade_date <= %s" in query + assert parameters == (TARGET_DATE,) + + +def test_advisory_lock_uses_target_date_and_releases_same_key() -> None: + connection = FakeConnection() + + with make_repository(connection).advisory_lock(TARGET_DATE) as acquired: + assert acquired + + assert len(connection.statements) == 2 + assert "pg_try_advisory_lock" in connection.statements[0][0] + assert "2026-08-28" in str(connection.statements[0][1][0]) + assert "pg_advisory_unlock" in connection.statements[1][0] + assert connection.statements[0][1] == connection.statements[1][1] + + +def test_exact_success_and_latest_attempt_queries_use_distinct_semantics() -> None: + connection = FakeConnection() + repository = make_repository(connection) + + exact = repository.get_successful_publication(TARGET_DATE) + latest = repository.get_latest_publication() + + assert exact is not None + assert latest is not None + exact_query, exact_parameters = connection.statements[0] + latest_query, latest_parameters = connection.statements[1] + assert "status = 'success' AND target_trade_date = %s" in exact_query + assert exact_parameters == (TARGET_DATE,) + assert "status = 'success'" not in latest_query + assert "started_at DESC" in latest_query + assert latest_parameters == () + + +def test_load_rankings_reconstructs_values_and_rank_changes() -> None: + connection = FakeConnection() + + rankings = make_repository(connection).load_rankings("publication-a") + + assert len(rankings) == 1 + ranking = rankings[0] + assert ranking.observation.metric_version == "zhixing_amount_net_bn_v1" + assert ranking.observation.value == Decimal("12.5") + assert ranking.rank_position == 1 + assert ranking.rank_change(1) == 3 + assert ranking.rank_change(2) is None + query, parameters = connection.statements[0] + assert "WHERE publication_id = %s" in query + assert "rank_position NULLS LAST" in query + assert parameters == ("publication-a",) diff --git a/zhixing-server/tests/unit/sector_radar/test_ranking.py b/zhixing-server/tests/unit/sector_radar/test_ranking.py new file mode 100644 index 0000000..5bda433 --- /dev/null +++ b/zhixing-server/tests/unit/sector_radar/test_ranking.py @@ -0,0 +1,147 @@ +from datetime import date +from decimal import Decimal + +from zhixing_server.modules.sector_radar.domain.models import ( + MetricKind, + MetricObservation, + MetricQuality, + MetricUnit, + RankSide, + SectorType, +) +from zhixing_server.modules.sector_radar.domain.ranking import ( + rank_metric_observations, + select_percentile_side, + select_rank_change_side, + with_rank_changes, +) + +TARGET_DATE = date(2026, 8, 28) + + +def make_observation( + sector_code: str, + sector_type: SectorType, + value: str | None, + *, + trade_date: date = TARGET_DATE, +) -> MetricObservation: + metric_value = Decimal(value) if value is not None else None + return MetricObservation( + trade_date=trade_date, + sector_type=sector_type, + sector_code=sector_code, + sector_name=sector_code, + metric_kind=MetricKind.AMOUNT, + metric_version="zhixing_amount_net_bn_v1", + implementation_kind="independent", + unit=MetricUnit.CNY_100M, + value=metric_value, + quality=( + MetricQuality.AVAILABLE if metric_value is not None else MetricQuality.UNAVAILABLE + ), + member_count=10, + valid_sample_count=10 if metric_value is not None else 0, + membership_coverage=Decimal("1"), + moneyflow_coverage=Decimal("1"), + ) + + +def test_ranking_separates_types_and_uses_code_as_stable_tie_breaker() -> None: + observations = ( + make_observation("BK2002.DC", SectorType.INDUSTRY, "20"), + make_observation("BK1002.DC", SectorType.CONCEPT, "30"), + make_observation("BK2001.DC", SectorType.INDUSTRY, "20"), + make_observation("BK1001.DC", SectorType.CONCEPT, "10"), + ) + + ranked = rank_metric_observations(tuple(reversed(observations))) + by_code = {row.observation.sector_code: row for row in ranked} + + assert by_code["BK1002.DC"].rank_position == 1 + assert by_code["BK1002.DC"].rank_percentile == Decimal("100") + assert by_code["BK1001.DC"].rank_position == 2 + assert by_code["BK1001.DC"].rank_percentile == Decimal("50") + + assert by_code["BK2001.DC"].rank_position == 1 + assert by_code["BK2002.DC"].rank_position == 2 + + +def test_ranking_handles_empty_and_single_element_pools() -> None: + assert rank_metric_observations(()) == () + + [single] = rank_metric_observations((make_observation("BK0001.DC", SectorType.CONCEPT, "0"),)) + + assert single.rank_position == 1 + assert single.rank_percentile == Decimal("100") + + +def test_percentile_sides_use_confirmed_inclusive_thresholds() -> None: + ranked = rank_metric_observations( + make_observation(f"BK{position:04d}.DC", SectorType.CONCEPT, str(11 - position)) + for position in range(1, 11) + ) + + top = select_percentile_side(ranked, RankSide.TOP) + bottom = select_percentile_side(ranked, RankSide.BOTTOM) + + assert [row.observation.sector_code for row in top] == ["BK0001.DC", "BK0002.DC"] + assert [row.observation.sector_code for row in bottom] == ["BK0010.DC"] + + +def test_rank_change_is_past_rank_minus_current_and_preserves_missing_history() -> None: + current = rank_metric_observations( + ( + make_observation("BK0001.DC", SectorType.CONCEPT, "30"), + make_observation("BK0002.DC", SectorType.CONCEPT, "20"), + ) + ) + previous = rank_metric_observations( + ( + make_observation( + "BK0001.DC", + SectorType.CONCEPT, + "10", + trade_date=date(2026, 8, 27), + ), + make_observation( + "BK0002.DC", + SectorType.CONCEPT, + "40", + trade_date=date(2026, 8, 27), + ), + ) + ) + + changed = with_rank_changes(current, {1: previous, 5: ()}) + by_code = {row.observation.sector_code: row for row in changed} + + assert by_code["BK0001.DC"].rank_change(1) == 1 + assert by_code["BK0002.DC"].rank_change(1) == -1 + assert by_code["BK0001.DC"].rank_change(5) is None + + +def test_rank_change_sides_take_ceiling_ten_percent_per_pool() -> None: + current = rank_metric_observations( + make_observation(f"BK{position:04d}.DC", SectorType.CONCEPT, str(12 - position)) + for position in range(1, 12) + ) + previous = rank_metric_observations( + make_observation( + f"BK{position:04d}.DC", + SectorType.CONCEPT, + str(position), + trade_date=date(2026, 8, 27), + ) + for position in range(1, 12) + ) + changed = with_rank_changes(current, {1: previous}) + + top = select_rank_change_side(changed, days=1, side=RankSide.TOP) + bottom = select_rank_change_side(changed, days=1, side=RankSide.BOTTOM) + + assert [row.observation.sector_code for row in top] == ["BK0001.DC", "BK0002.DC"] + assert [row.observation.sector_code for row in bottom] == [ + "BK0011.DC", + "BK0010.DC", + ] diff --git a/zhixing-server/tests/unit/sector_radar/test_read.py b/zhixing-server/tests/unit/sector_radar/test_read.py new file mode 100644 index 0000000..c071e56 --- /dev/null +++ b/zhixing-server/tests/unit/sector_radar/test_read.py @@ -0,0 +1,189 @@ +from dataclasses import replace +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal + +from zhixing_server.modules.sector_radar.application.read import ( + RadarQuery, + RadarView, + ReadSectorRadar, +) +from zhixing_server.modules.sector_radar.domain.metrics import AmountNetStrategy +from zhixing_server.modules.sector_radar.domain.models import ( + MetricKind, + MetricObservation, + MetricQuality, + MetricUnit, + PublicationStatus, + RadarPublication, + RankChange, + RankedMetric, + RankSide, + SectorType, +) +from zhixing_server.modules.sector_radar.domain.persistence import RankingRecord +from zhixing_server.modules.sector_radar.domain.ranking import rank_metric_observations +from zhixing_server.modules.sector_radar.infrastructure.memory import ( + InMemorySectorRadarRepository, +) + +TARGET_DATE = date(2026, 8, 28) +NOW = datetime(2026, 8, 28, 17, 30, tzinfo=UTC) + + +def _running(publication_id: str, trade_date: date) -> RadarPublication: + return RadarPublication( + publication_id=publication_id, + target_trade_date=trade_date, + status=PublicationStatus.RUNNING, + source_version="tushare-pro-v1", + universe_version="eastmoney-dc-v1", + metric_versions=(AmountNetStrategy.metric_version,), + input_hash=None, + coverage=Decimal(0), + started_at=NOW, + ) + + +def _finish( + publication: RadarPublication, + status: PublicationStatus, +) -> RadarPublication: + return replace( + publication, + status=status, + input_hash="a" * 64 if status is PublicationStatus.SUCCESS else None, + coverage=Decimal(1) if status is PublicationStatus.SUCCESS else Decimal("0.8"), + finished_at=publication.started_at + timedelta(minutes=5), + error_summary=None if status is PublicationStatus.SUCCESS else "safe_error", + ) + + +def _amount_rankings() -> tuple[RankedMetric, ...]: + observations = tuple( + MetricObservation( + trade_date=TARGET_DATE, + sector_type=SectorType.CONCEPT, + sector_code=f"BK{index:04d}.DC", + sector_name=f"概念{index}", + metric_kind=MetricKind.AMOUNT, + metric_version=AmountNetStrategy.metric_version, + implementation_kind="independent", + unit=MetricUnit.CNY_100M, + value=Decimal(11 - index), + quality=MetricQuality.AVAILABLE, + member_count=5, + valid_sample_count=5, + membership_coverage=Decimal(1), + moneyflow_coverage=Decimal(1), + ) + for index in range(1, 11) + ) + rankings = rank_metric_observations(observations) + return tuple( + replace( + row, + rank_changes=tuple( + RankChange( + days=days, + value=( + None + if row.observation.sector_code == "BK0005.DC" and days == 5 + else (row.rank_position or 0) - 5 + ), + ) + for days in range(1, 6) + ), + ) + for row in rankings + ) + + +def _published_repository() -> InMemorySectorRadarRepository: + repository = InMemorySectorRadarRepository() + publication = _running("publication-success", TARGET_DATE) + repository.create_publication(publication) + repository.finish_publication(_finish(publication, PublicationStatus.SUCCESS)) + repository.save_rankings( + RankingRecord(publication.publication_id, ranking) for ranking in _amount_rankings() + ) + return repository + + +def test_no_successful_publication_returns_stable_no_data() -> None: + reader = ReadSectorRadar(InMemorySectorRadarRepository()) + + dates = reader.list_dates() + rankings = reader.query(RadarQuery()) + + assert dates.status == "no_data" + assert dates.available_dates == () + assert rankings.status == "no_data" + assert rankings.publication is None + assert rankings.total == 0 + assert rankings.definition.metric_version == AmountNetStrategy.metric_version + + +def test_explicit_date_never_falls_back_to_an_earlier_last_good() -> None: + reader = ReadSectorRadar(_published_repository()) + + missing = reader.query(RadarQuery(trade_date=TARGET_DATE + timedelta(days=1))) + + assert missing.status == "no_data" + assert missing.publication is None + + +def test_percentile_side_is_selected_before_search_and_pagination() -> None: + reader = ReadSectorRadar(_published_repository()) + + top = reader.query(RadarQuery(side=RankSide.TOP, page_size=1)) + second_page = reader.query(RadarQuery(side=RankSide.TOP, page=2, page_size=1)) + searched = reader.query(RadarQuery(side=RankSide.TOP, search="概念2")) + bottom = reader.query(RadarQuery(side=RankSide.BOTTOM)) + + assert top.total == 2 + assert top.rows[0].observation.sector_code == "BK0001.DC" + assert second_page.rows[0].observation.sector_code == "BK0002.DC" + assert searched.total == 1 + assert searched.rows[0].observation.sector_name == "概念2" + assert bottom.total == 1 + assert bottom.rows[0].observation.sector_code == "BK0010.DC" + + +def test_rank_change_uses_selected_metric_days_and_pool_sides() -> None: + reader = ReadSectorRadar(_published_repository()) + query = RadarQuery( + view=RadarView.RANK_CHANGE, + rank_change_metric=MetricKind.AMOUNT, + rank_change_days=5, + ) + + top = reader.query(replace(query, side=RankSide.TOP)) + bottom = reader.query(replace(query, side=RankSide.BOTTOM)) + all_rows = reader.query(query) + + assert top.total == 1 + assert top.rows[0].rank_change(5) == 5 + assert bottom.total == 1 + assert bottom.rows[0].rank_change(5) == -4 + assert all_rows.total == 10 + assert all_rows.rows[-1].observation.sector_code == "BK0005.DC" + assert all_rows.rows[-1].rank_change(5) is None + + +def test_latest_partial_attempt_is_visible_but_does_not_replace_last_good() -> None: + repository = _published_repository() + partial = replace( + _running("publication-partial", TARGET_DATE + timedelta(days=1)), + started_at=NOW + timedelta(days=1), + ) + repository.create_publication(partial) + repository.finish_publication(_finish(partial, PublicationStatus.PARTIAL)) + + index = ReadSectorRadar(repository).list_dates() + + assert index.status == "success" + assert index.current_attempt is not None + assert index.current_attempt.status is PublicationStatus.PARTIAL + assert index.last_good is not None + assert index.last_good.publication_id == "publication-success" + assert index.available_dates == (TARGET_DATE,) diff --git a/zhixing-server/tests/unit/sector_radar/test_repository.py b/zhixing-server/tests/unit/sector_radar/test_repository.py new file mode 100644 index 0000000..7440e5a --- /dev/null +++ b/zhixing-server/tests/unit/sector_radar/test_repository.py @@ -0,0 +1,121 @@ +from dataclasses import replace +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal + +import pytest + +from zhixing_server.modules.sector_radar.domain.models import ( + PublicationStatus, + RadarPublication, + SectorType, +) +from zhixing_server.modules.sector_radar.domain.persistence import MembershipRecord +from zhixing_server.modules.sector_radar.domain.source import build_source_snapshot +from zhixing_server.modules.sector_radar.infrastructure.memory import ( + InMemorySectorRadarRepository, +) + +TARGET_DATE = date(2026, 8, 28) +STARTED_AT = datetime(2026, 8, 28, 17, 30, tzinfo=UTC) + + +def make_running(publication_id: str, target_trade_date: date = TARGET_DATE) -> RadarPublication: + return RadarPublication( + publication_id=publication_id, + target_trade_date=target_trade_date, + status=PublicationStatus.RUNNING, + source_version="tushare-pro-v1", + universe_version="eastmoney-dc-v1", + metric_versions=("zhixing_amount_net_bn_v1",), + input_hash=None, + coverage=Decimal(0), + started_at=STARTED_AT, + ) + + +def finish( + publication: RadarPublication, + status: PublicationStatus, + *, + offset_minutes: int = 5, +) -> RadarPublication: + return replace( + publication, + status=status, + input_hash="a" * 64 if status is PublicationStatus.SUCCESS else None, + coverage=Decimal("1") if status is PublicationStatus.SUCCESS else Decimal("0.8"), + finished_at=publication.started_at + timedelta(minutes=offset_minutes), + error_summary=None if status is PublicationStatus.SUCCESS else "safe_error", + ) + + +def test_source_and_membership_revisions_are_idempotent_but_not_overwritable() -> None: + repository = InMemorySectorRadarRepository() + snapshot = build_source_snapshot( + api_name="dc_member", + params={"trade_date": "20260828"}, + rows=( + { + "trade_date": "20260828", + "ts_code": "BK0001.DC", + "con_code": "000001.SZ", + "name": "平安银行", + }, + ), + target_trade_date=TARGET_DATE, + observed_at=STARTED_AT, + ) + member = MembershipRecord( + source_snapshot_id=snapshot.snapshot_id, + trade_date=TARGET_DATE, + sector_type=SectorType.CONCEPT, + sector_code="BK0001.DC", + sector_name="示例概念", + stock_code="000001.SZ", + stock_name="平安银行", + ) + + assert repository.save_source_snapshots((snapshot,)).inserted == 1 + assert repository.save_source_snapshots((snapshot,)).unchanged == 1 + assert repository.save_memberships((member,)).inserted == 1 + assert repository.save_memberships((member,)).unchanged == 1 + + with pytest.raises(ValueError, match="cannot change content"): + repository.save_memberships((replace(member, stock_name="已改变"),)) + + +def test_partial_and_failed_revisions_never_replace_last_good() -> None: + repository = InMemorySectorRadarRepository() + successful = make_running("success-a") + partial = make_running("partial-b") + failed = make_running("failed-c", TARGET_DATE + timedelta(days=1)) + repository.create_publication(successful) + repository.finish_publication(finish(successful, PublicationStatus.SUCCESS)) + repository.create_publication(partial) + repository.finish_publication(finish(partial, PublicationStatus.PARTIAL, offset_minutes=6)) + repository.create_publication(failed) + repository.finish_publication(finish(failed, PublicationStatus.FAILED, offset_minutes=7)) + + last_good = repository.get_last_good_publication() + + assert last_good is not None + assert last_good.publication_id == "success-a" + assert repository.list_successful_dates() == (TARGET_DATE,) + + +def test_publication_identity_allows_sequential_same_date_revisions() -> None: + repository = InMemorySectorRadarRepository() + first = make_running("revision-a") + second = make_running("revision-b") + + assert repository.create_publication(first).inserted == 1 + with pytest.raises(ValueError, match="already has a running"): + repository.create_publication(second) + + with pytest.raises(ValueError, match="terminal"): + repository.finish_publication(first) + + repository.finish_publication(finish(first, PublicationStatus.FAILED)) + assert repository.create_publication(second).inserted == 1 + with pytest.raises(ValueError, match="running status"): + repository.finish_publication(finish(first, PublicationStatus.SUCCESS)) diff --git a/zhixing-server/tests/unit/sector_radar/test_tushare_source.py b/zhixing-server/tests/unit/sector_radar/test_tushare_source.py new file mode 100644 index 0000000..b889a91 --- /dev/null +++ b/zhixing-server/tests/unit/sector_radar/test_tushare_source.py @@ -0,0 +1,669 @@ +import logging +import threading +from collections.abc import Mapping +from datetime import UTC, date, datetime +from decimal import Decimal + +import pytest + +from zhixing_server.modules.sector_radar.domain.models import SectorType +from zhixing_server.modules.sector_radar.domain.source import ( + CapabilityStatus, + SourceContractError, + build_source_snapshot, +) +from zhixing_server.modules.sector_radar.infrastructure import tushare as source_module +from zhixing_server.modules.sector_radar.infrastructure.tushare import ( + TushareSectorRadarAdapter, +) + +TARGET_DATE = date(2026, 8, 28) +OBSERVED_AT = datetime(2026, 8, 28, 17, 30, tzinfo=UTC) + + +class QueryClient: + def __init__(self, responses: Mapping[tuple[str, str], object]) -> None: + self.responses = dict(responses) + self.calls: list[tuple[str, dict[str, object]]] = [] + self._lock = threading.Lock() + + def query(self, api_name: str, **kwargs: object) -> object: + partition = str(kwargs.get("ts_code") or kwargs.get("list_status") or "") + with self._lock: + self.calls.append((api_name, kwargs)) + response = self.responses.get((api_name, partition), ()) + if isinstance(response, BaseException): + raise response + return response + + +def make_adapter(client: object) -> TushareSectorRadarAdapter: + return TushareSectorRadarAdapter( + client, + max_retries=0, + request_interval_seconds=0, + sleep_fn=lambda _: None, + now_fn=lambda: OBSERVED_AT, + ) + + +def moneyflow_record( + ts_code: str, + *, + trade_date: str = "20260828", +) -> dict[str, object]: + return { + "trade_date": trade_date, + "ts_code": ts_code, + "name": ts_code, + "net_amount": "1", + "net_amount_rate": "0.1", + "pct_change": "1", + "close": "10", + } + + +def test_daily_and_moneyflow_keep_source_units_and_distinguish_missing_from_zero() -> None: + client = QueryClient( + { + ( + "daily", + "", + ): ( + { + "ts_code": "000001.SZ", + "trade_date": "20260828", + "close": "10", + "pre_close": "9.5", + "pct_chg": "1.5", + "vol": "100", + "amount": "12.5", + }, + { + "ts_code": "000002.SZ", + "trade_date": "20260828", + "close": "20", + "pre_close": "20", + "pct_chg": "0", + "vol": "0", + "amount": float("nan"), + }, + ), + ( + "moneyflow_dc", + "", + ): ( + { + "trade_date": "20260828", + "ts_code": "000001.SZ", + "name": "平安银行", + "net_amount": "2.5", + "net_amount_rate": "0.2", + "pct_change": "1.5", + "close": "10", + }, + { + "trade_date": "20260828", + "ts_code": "000002.SZ", + "name": "示例股票", + "net_amount": "0", + "net_amount_rate": "0", + "pct_change": "0", + "close": "20", + }, + ), + } + ) + adapter = make_adapter(client) + + daily = adapter.fetch_daily(TARGET_DATE) + moneyflow = adapter.fetch_moneyflow_dc(TARGET_DATE, ("000001.SZ", "000002.SZ")) + + assert daily.rows[0].amount_thousand_yuan == Decimal("12.5") + assert daily.rows[0].turnover_yuan == Decimal("12500.0") + assert daily.rows[1].amount_thousand_yuan is None + assert moneyflow.rows[0].net_amount_ten_thousand_yuan == Decimal("2.5") + assert moneyflow.rows[0].net_amount_yuan == Decimal("25000.0") + assert moneyflow.rows[1].net_amount_yuan == Decimal("0") + assert client.calls[0][1]["fields"] == ",".join(source_module.FIELDS["daily"]) + + +def test_moneyflow_accepts_a_full_initial_snapshot_at_the_provider_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(source_module.ROW_LIMITS, "moneyflow_dc", 2) + client = QueryClient( + { + ("moneyflow_dc", ""): ( + moneyflow_record("000001.SZ"), + moneyflow_record("000002.SZ"), + ) + } + ) + + result = make_adapter(client).fetch_moneyflow_dc( + TARGET_DATE, + ("000001.SZ", "000002.SZ"), + ) + + assert result.snapshots[0].limit_reached is True + assert [row.ts_code for row in result.rows] == ["000001.SZ", "000002.SZ"] + assert len(client.calls) == 1 + + +@pytest.mark.parametrize( + ("initial_rows", "message"), + ( + ((moneyflow_record("000001.SZ", trade_date="20260827"),), "trade_date"), + ( + (moneyflow_record("000001.SZ"), moneyflow_record("000001.SZ")), + "duplicate business keys", + ), + ), +) +def test_moneyflow_initial_contract_errors_fail_closed( + initial_rows: tuple[dict[str, object], ...], + message: str, +) -> None: + client = QueryClient({("moneyflow_dc", ""): initial_rows}) + + with pytest.raises(SourceContractError, match=message): + make_adapter(client).fetch_moneyflow_dc(TARGET_DATE, ()) + + +def test_moneyflow_refills_only_missing_codes_in_stable_snapshot_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(source_module.ROW_LIMITS, "moneyflow_dc", 3) + third_finished = threading.Event() + completion_order: list[str] = [] + completion_lock = threading.Lock() + + class ReverseCompletionClient(QueryClient): + def query(self, api_name: str, **kwargs: object) -> object: + response = super().query(api_name, **kwargs) + ts_code = str(kwargs.get("ts_code") or "") + if ts_code == "000004.SZ": + if not third_finished.wait(timeout=2): + raise AssertionError("second moneyflow worker did not start") + elif ts_code == "000005.SZ": + third_finished.set() + if ts_code: + with completion_lock: + completion_order.append(ts_code) + return response + + client = ReverseCompletionClient( + { + ("moneyflow_dc", ""): tuple( + moneyflow_record(f"00000{index}.SZ") for index in range(1, 4) + ), + ("moneyflow_dc", "000004.SZ"): (moneyflow_record("000004.SZ"),), + ("moneyflow_dc", "000005.SZ"): (moneyflow_record("000005.SZ"),), + } + ) + + result = make_adapter(client).fetch_moneyflow_dc( + TARGET_DATE, + tuple(f"00000{index}.SZ" for index in range(1, 6)), + ) + + assert completion_order == ["000005.SZ", "000004.SZ"] + assert [snapshot.partition_key for snapshot in result.snapshots] == [ + "all", + "000004.SZ", + "000005.SZ", + ] + assert [row.ts_code for row in result.rows] == [ + "000001.SZ", + "000002.SZ", + "000003.SZ", + "000004.SZ", + "000005.SZ", + ] + assert len(client.calls) == 3 + + +def test_moneyflow_empty_and_exhausted_refills_remain_real_gaps( + caplog: pytest.LogCaptureFixture, +) -> None: + client = QueryClient( + { + ("moneyflow_dc", ""): (moneyflow_record("000001.SZ"),), + ("moneyflow_dc", "000002.SZ"): (), + ("moneyflow_dc", "000003.SZ"): RuntimeError("private provider payload"), + } + ) + caplog.set_level( + logging.WARNING, + logger="zhixing_server.modules.sector_radar.infrastructure.tushare", + ) + + result = make_adapter(client).fetch_moneyflow_dc( + TARGET_DATE, + ("000001.SZ", "000002.SZ", "000003.SZ"), + ) + + assert [row.ts_code for row in result.rows] == ["000001.SZ"] + assert [snapshot.partition_key for snapshot in result.snapshots] == ["all"] + messages = "\n".join(record.getMessage() for record in caplog.records) + assert "partition_empty partition_key=000002.SZ" in messages + assert "partition_failed partition_key=000003.SZ" in messages + assert "private provider payload" not in messages + + +@pytest.mark.parametrize( + ("partition_rows", "row_limit", "message"), + ( + ((moneyflow_record("000002.SZ", trade_date="20260827"),), 6_000, "trade_date"), + ((moneyflow_record("000099.SZ"),), 6_000, "different ts_code"), + ( + (moneyflow_record("000002.SZ"), moneyflow_record("000002.SZ")), + 6_000, + "duplicate business keys", + ), + ( + (moneyflow_record("000002.SZ"), moneyflow_record("000002.SZ")), + 2, + "provider row limit", + ), + ), +) +def test_moneyflow_partition_contract_errors_fail_closed( + monkeypatch: pytest.MonkeyPatch, + partition_rows: tuple[dict[str, object], ...], + row_limit: int, + message: str, +) -> None: + monkeypatch.setitem(source_module.ROW_LIMITS, "moneyflow_dc", row_limit) + client = QueryClient( + { + ("moneyflow_dc", ""): (moneyflow_record("000001.SZ"),), + ("moneyflow_dc", "000002.SZ"): partition_rows, + } + ) + + with pytest.raises(SourceContractError, match=message): + make_adapter(client).fetch_moneyflow_dc( + TARGET_DATE, + ("000001.SZ", "000002.SZ"), + ) + + +def test_non_finite_source_values_are_rejected() -> None: + client = QueryClient( + { + ( + "daily", + "", + ): ( + { + "ts_code": "000001.SZ", + "trade_date": "20260828", + "close": "Infinity", + "pre_close": "9.5", + "pct_chg": "1.5", + "vol": "100", + "amount": "12.5", + }, + ) + } + ) + + with pytest.raises(SourceContractError, match="finite"): + make_adapter(client).fetch_daily(TARGET_DATE) + + +def test_contract_failure_log_identifies_member_partition_without_payload( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(source_module.ROW_LIMITS, "dc_member", 2) + client = QueryClient( + { + ( + "dc_member", + "", + ): ( + { + "trade_date": "20260828", + "ts_code": "BK0001.DC", + "con_code": "000001.SZ", + "name": "private-payload-marker", + }, + { + "trade_date": "20260828", + "ts_code": "BK0001.DC", + "con_code": "000002.SZ", + "name": "private-payload-marker", + }, + ), + ( + "dc_member", + "BK0001.DC", + ): ( + { + "trade_date": "20260828", + "ts_code": "BK9999.DC", + "con_code": "000001.SZ", + "name": "private-payload-marker", + }, + ), + } + ) + caplog.set_level( + logging.ERROR, + logger="zhixing_server.modules.sector_radar.infrastructure.tushare", + ) + + with pytest.raises(SourceContractError, match="different sector"): + make_adapter(client).fetch_sector_members(TARGET_DATE, ("BK0001.DC",)) + + messages = "\n".join(record.getMessage() for record in caplog.records) + assert "sector_radar_source_contract_failed" in messages + assert "api_name=dc_member" in messages + assert "partition_key=BK0001.DC" in messages + assert "validation=dc_member partition returned a different sector" in messages + assert "private-payload-marker" not in messages + assert len(caplog.records) == 1 + + +def test_merged_member_contract_failure_has_one_interface_level_log( + caplog: pytest.LogCaptureFixture, +) -> None: + duplicate = { + "trade_date": "20260828", + "ts_code": "BK0001.DC", + "con_code": "000001.SZ", + "name": "private-payload-marker", + } + client = QueryClient({("dc_member", ""): (duplicate, duplicate)}) + caplog.set_level( + logging.ERROR, + logger="zhixing_server.modules.sector_radar.infrastructure.tushare", + ) + + with pytest.raises(SourceContractError, match="duplicate business keys"): + make_adapter(client).fetch_sector_members(TARGET_DATE, ("BK0001.DC",)) + + messages = "\n".join(record.getMessage() for record in caplog.records) + assert "api_name=dc_member" in messages + assert "partition_key=merged" in messages + assert "validation=dc_member returned duplicate business keys" in messages + assert "private-payload-marker" not in messages + assert len(caplog.records) == 1 + + +def test_dc_member_reloads_by_sector_when_the_all_market_call_hits_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(source_module.ROW_LIMITS, "dc_member", 2) + client = QueryClient( + { + ( + "dc_member", + "", + ): ( + { + "trade_date": "20260828", + "ts_code": "BK0001.DC", + "con_code": "000001.SZ", + "name": "A", + }, + { + "trade_date": "20260828", + "ts_code": "BK0001.DC", + "con_code": "000002.SZ", + "name": "B", + }, + ), + ( + "dc_member", + "BK0001.DC", + ): ( + { + "trade_date": "20260828", + "ts_code": "BK0001.DC", + "con_code": "000001.SZ", + "name": "A", + }, + ), + ( + "dc_member", + "BK0002.DC", + ): ( + { + "trade_date": "20260828", + "ts_code": "BK0002.DC", + "con_code": "600000.SH", + "name": "C", + }, + ), + } + ) + + result = make_adapter(client).fetch_sector_members( + TARGET_DATE, + ("BK0001.DC", "BK0002.DC"), + ) + + assert [row.stock_code for row in result.rows] == ["000001.SZ", "600000.SH"] + assert [snapshot.partition_key for snapshot in result.snapshots] == [ + "all", + "BK0001.DC", + "BK0002.DC", + ] + + +def test_dc_member_preserves_an_explicit_empty_partition() -> None: + client = QueryClient( + { + ( + "dc_member", + "", + ): ( + { + "trade_date": "20260828", + "ts_code": "BK0001.DC", + "con_code": "000001.SZ", + "name": "A", + }, + ), + ("dc_member", "BK0002.DC"): (), + } + ) + + result = make_adapter(client).fetch_sector_members( + TARGET_DATE, + ("BK0001.DC", "BK0002.DC"), + ) + + assert [row.sector_code for row in result.rows] == ["BK0001.DC"] + assert [snapshot.partition_key for snapshot in result.snapshots] == [ + "all", + "BK0002.DC", + ] + assert result.snapshots[1].row_count == 0 + + +def test_stock_basic_requests_only_current_listings() -> None: + client = QueryClient( + { + ( + "stock_basic", + "L", + ): ( + { + "ts_code": "000001.SZ", + "symbol": "000001", + "name": "L", + "market": "主板", + "exchange": "SZSE", + "list_status": "L", + "list_date": "20200101", + "delist_date": None, + }, + ) + } + ) + + result = make_adapter(client).fetch_stock_basics() + + assert {row.list_status for row in result.rows} == {"L"} + assert [snapshot.partition_key for snapshot in result.snapshots] == ["L"] + assert [call[1]["list_status"] for call in client.calls] == ["L"] + + +def test_stock_basic_rejects_a_non_listed_row_from_the_l_partition() -> None: + client = QueryClient( + { + ("stock_basic", "L"): ( + { + "ts_code": "000001.SZ", + "symbol": "000001", + "name": "unexpected", + "market": "主板", + "exchange": "SZSE", + "list_status": "D", + "list_date": "20200101", + "delist_date": "20260828", + }, + ) + } + ) + + with pytest.raises(SourceContractError, match="unexpected list_status"): + make_adapter(client).fetch_stock_basics() + + +def test_suspend_timing_may_be_missing_while_suspend_type_remains_required() -> None: + client = QueryClient( + { + ( + "suspend_d", + "", + ): ( + { + "ts_code": "000001.SZ", + "trade_date": "20260828", + "suspend_timing": None, + "suspend_type": "S", + }, + ) + } + ) + + result = make_adapter(client).fetch_suspensions(TARGET_DATE) + + assert result.rows[0].suspend_timing is None + assert result.rows[0].suspend_type == "S" + + missing_type = QueryClient( + { + ( + "suspend_d", + "", + ): ( + { + "ts_code": "000001.SZ", + "trade_date": "20260828", + "suspend_timing": None, + "suspend_type": None, + }, + ) + } + ) + with pytest.raises(SourceContractError, match="suspend_type must be a non-empty string"): + make_adapter(missing_type).fetch_suspensions(TARGET_DATE) + + +def test_source_snapshot_hash_is_order_stable_and_excludes_token_params() -> None: + first = build_source_snapshot( + api_name="daily", + params={"trade_date": "20260828", "token": "secret"}, + rows=({"ts_code": "2"}, {"ts_code": "1"}), + target_trade_date=TARGET_DATE, + observed_at=OBSERVED_AT, + ) + second = build_source_snapshot( + api_name="daily", + params={"trade_date": "20260828"}, + rows=({"ts_code": "1"}, {"ts_code": "2"}), + target_trade_date=TARGET_DATE, + observed_at=OBSERVED_AT, + ) + + assert first.snapshot_id == second.snapshot_id + assert "secret" not in repr(first) + + +def test_source_snapshot_identity_includes_schema_and_limit_metadata() -> None: + first = build_source_snapshot( + api_name="daily", + params={"trade_date": "20260828"}, + rows=({"ts_code": "000001.SZ"},), + target_trade_date=TARGET_DATE, + observed_at=OBSERVED_AT, + returned_fields=("ts_code",), + row_limit=1, + ) + changed_schema = build_source_snapshot( + api_name="daily", + params={"trade_date": "20260828"}, + rows=({"ts_code": "000001.SZ"},), + target_trade_date=TARGET_DATE, + observed_at=OBSERVED_AT, + returned_fields=("name", "ts_code"), + row_limit=1, + ) + changed_limit = build_source_snapshot( + api_name="daily", + params={"trade_date": "20260828"}, + rows=({"ts_code": "000001.SZ"},), + target_trade_date=TARGET_DATE, + observed_at=OBSERVED_AT, + returned_fields=("ts_code",), + row_limit=2, + ) + + assert first.content_sha256 != changed_schema.content_sha256 + assert first.snapshot_id != changed_schema.snapshot_id + assert first.content_sha256 != changed_limit.content_sha256 + assert first.snapshot_id != changed_limit.snapshot_id + + +def test_capability_probe_classifies_errors_without_exposing_provider_text() -> None: + client = QueryClient({("daily", ""): RuntimeError("权限不足 private-detail")}) + + probe = make_adapter(client).probe(TARGET_DATE) + + by_name = {result.api_name: result for result in probe.interfaces} + assert by_name["daily"].status is CapabilityStatus.FORBIDDEN + assert "private-detail" not in repr(probe) + assert len(probe.interfaces) == 7 + + +def test_sector_index_uses_independent_concept_and_industry_params() -> None: + client = QueryClient( + { + ( + "dc_index", + "", + ): ( + { + "ts_code": "BK0001.DC", + "trade_date": "20260828", + "name": "示例", + "idx_type": "概念板块", + "level": "一级", + "pct_change": "1", + "leading_code": "000001.SZ", + }, + ) + } + ) + + result = make_adapter(client).fetch_sector_indices(TARGET_DATE, SectorType.CONCEPT) + + assert result.rows[0].sector_type is SectorType.CONCEPT + assert client.calls[0][1]["idx_type"] == "概念板块" diff --git a/zhixing-web/DESIGN.md b/zhixing-web/DESIGN.md index 469f514..052a33a 100644 --- a/zhixing-web/DESIGN.md +++ b/zhixing-web/DESIGN.md @@ -263,6 +263,7 @@ The single chromatic accent is **Fin Orange** (`{colors.fin-orange}` #ff5600) The page rhythm is heavy on **product mockups**: every section's payload is a high-fidelity screenshot of Intercom's product UI, framed in white cards with a consistent 6px corner radius. The marketing chrome is intentionally quiet so the product can be the protagonist. **Key Characteristics:** + - **Cream canvas** (`{colors.canvas}` #f5f1ec) is the brand's defining surface — neither white nor gray, deliberately warm. - Product-screenshot-led page rhythm: every section centers a product mockup card, marketing chrome stays minimal. - **Saans** proprietary sans-serif carries the entire hierarchy; SaansMono for code-only contexts. @@ -276,6 +277,7 @@ The page rhythm is heavy on **product mockups**: every section's payload is a hi > Source pages: intercom.com (home), /pricing, /helpdesk, /customers, /helpdesk/inbox. ### Brand & Accent + - **Charcoal** ({colors.ink}): The system primary surface. Headlines, body type, and primary CTA background — all charcoal. - **White** ({colors.on-primary}): Text on charcoal CTAs; canvas of floating cards. - **Fin Orange** ({colors.fin-orange}): The AI-product accent. Used on the Fin CTA, Fin badge, and a small set of inline emphasis moments. @@ -283,6 +285,7 @@ The page rhythm is heavy on **product mockups**: every section's payload is a hi - **Brand Blue** ({colors.brand-blue}): Saturated brand blue (#0007cb) — used on a small set of marketing illustrations. ### Surface + - **Canvas** ({colors.canvas}): Default page background — soft cream-white #f5f1ec. - **Surface 1** ({colors.surface-1}): Pure white — used for floating cards (pricing, feature, product-mockup). - **Surface 2** ({colors.surface-2}): Slightly darker cream — startup-discount banner, alt-row stripes. @@ -292,6 +295,7 @@ The page rhythm is heavy on **product mockups**: every section's payload is a hi - **Inverse Surface 1** ({colors.inverse-surface-1}): One step lighter — hovered footer items in dark contexts. ### Text + - **Ink** ({colors.ink}): All headlines, body type, button labels — charcoal #111111. - **Ink Muted** ({colors.ink-muted}): Secondary type at #626260 — meta info, deselected pricing tabs. - **Ink Subtle** ({colors.ink-subtle}): Tertiary type at #7b7b78 — footer columns, helper text. @@ -300,6 +304,7 @@ The page rhythm is heavy on **product mockups**: every section's payload is a hi - **Inverse Ink Muted** ({colors.inverse-ink-muted}): Light gray on black — quote-strip meta. ### Semantic & Report Palette (in-product mockups) + - **Error Red** ({colors.semantic-error}): Form validation, destructive states. - **Success Green** ({colors.semantic-success}): Positive states (also `{colors.report-green}`). - **Report Blue** ({colors.report-blue}): Analytics chart blue. @@ -320,21 +325,21 @@ The same family carries the entire hierarchy. Hierarchy is carried by size + wei ### Hierarchy -| Token | Size | Weight | Line Height | Letter Spacing | Use | -|---|---|---|---|---|---| -| `{typography.display-xl}` | 72px | 500 | 1.05 | -2.0px | Largest hero headline | -| `{typography.display-lg}` | 56px | 500 | 1.10 | -1.4px | Section opener headlines | -| `{typography.display-md}` | 40px | 500 | 1.15 | -0.8px | Sub-section headlines | -| `{typography.headline}` | 28px | 500 | 1.20 | -0.5px | Pricing tier titles, CTA banner | -| `{typography.card-title}` | 22px | 500 | 1.25 | -0.3px | Card title, feature card | -| `{typography.subhead}` | 20px | 400 | 1.40 | -0.2px | Lead body, intro paragraphs | -| `{typography.body-lg}` | 18px | 400 | 1.50 | -0.1px | Hero subhead, lead paragraphs | -| `{typography.body}` | 16px | 400 | 1.50 | 0 | Default body | -| `{typography.body-sm}` | 14px | 400 | 1.50 | 0 | Card body, footer | -| `{typography.caption}` | 12px | 400 | 1.40 | 0 | Captions, meta | -| `{typography.button}` | 15px | 500 | 1.20 | 0 | Button labels | -| `{typography.eyebrow}` | 14px | 500 | 1.30 | 0 | Section eyebrow (sentence case) | -| `{typography.mono}` | 13px | 400 | 1.50 | 0 | SaansMono for code in mockups | +| Token | Size | Weight | Line Height | Letter Spacing | Use | +| ------------------------- | ---- | ------ | ----------- | -------------- | ------------------------------- | +| `{typography.display-xl}` | 72px | 500 | 1.05 | -2.0px | Largest hero headline | +| `{typography.display-lg}` | 56px | 500 | 1.10 | -1.4px | Section opener headlines | +| `{typography.display-md}` | 40px | 500 | 1.15 | -0.8px | Sub-section headlines | +| `{typography.headline}` | 28px | 500 | 1.20 | -0.5px | Pricing tier titles, CTA banner | +| `{typography.card-title}` | 22px | 500 | 1.25 | -0.3px | Card title, feature card | +| `{typography.subhead}` | 20px | 400 | 1.40 | -0.2px | Lead body, intro paragraphs | +| `{typography.body-lg}` | 18px | 400 | 1.50 | -0.1px | Hero subhead, lead paragraphs | +| `{typography.body}` | 16px | 400 | 1.50 | 0 | Default body | +| `{typography.body-sm}` | 14px | 400 | 1.50 | 0 | Card body, footer | +| `{typography.caption}` | 12px | 400 | 1.40 | 0 | Captions, meta | +| `{typography.button}` | 15px | 500 | 1.20 | 0 | Button labels | +| `{typography.eyebrow}` | 14px | 500 | 1.30 | 0 | Section eyebrow (sentence case) | +| `{typography.mono}` | 13px | 400 | 1.50 | 0 | SaansMono for code in mockups | ### Principles @@ -370,12 +375,12 @@ The cream canvas does the work white space would in another brand. Sections sepa ## Elevation & Depth -| Level | Treatment | Use | -|---|---|---| -| 0 (flat) | No shadow, no border | Default for body type, hero text, footer | +| Level | Treatment | Use | +| ----------------- | ---------------------------------------------------------------- | --------------------------------------------- | +| 0 (flat) | No shadow, no border | Default for body type, hero text, footer | | 1 (lift on cream) | `{colors.surface-1}` white background on `{colors.canvas}` cream | Pricing cards, feature cards, product mockups | -| 2 (hairline lift) | `{colors.surface-1}` + 1px `{colors.hairline}` border | Floating tiles with extra definition | -| 3 (deep accent) | `{colors.inverse-canvas}` true black | Quote / testimonial callout strip | +| 2 (hairline lift) | `{colors.surface-1}` + 1px `{colors.hairline}` border | Floating tiles with extra definition | +| 3 (deep accent) | `{colors.inverse-canvas}` true black | Quote / testimonial callout strip | Intercom resists drop shadows. Depth is communicated by the white-on-cream surface change. @@ -388,16 +393,16 @@ Intercom resists drop shadows. Depth is communicated by the white-on-cream surfa ### Border Radius Scale -| Token | Value | Use | -|---|---|---| -| `{rounded.xs}` | 6px | Small chips, badges | -| `{rounded.sm}` | 6px | Inline tags | -| `{rounded.md}` | 6px | All buttons, form inputs | -| `{rounded.lg}` | 6px | Pricing cards, feature cards, FAQ rows | -| `{rounded.xl}` | 6px | Product mockup cards | -| `{rounded.xxl}` | 6px | Oversized CTA banners | -| `{rounded.pill}` | 6px | Tab toggles (legacy alias) | -| `{rounded.full}` | 6px | Avatar containers (legacy alias) | +| Token | Value | Use | +| ---------------- | ----- | -------------------------------------- | +| `{rounded.xs}` | 6px | Small chips, badges | +| `{rounded.sm}` | 6px | Inline tags | +| `{rounded.md}` | 6px | All buttons, form inputs | +| `{rounded.lg}` | 6px | Pricing cards, feature cards, FAQ rows | +| `{rounded.xl}` | 6px | Product mockup cards | +| `{rounded.xxl}` | 6px | Oversized CTA banners | +| `{rounded.pill}` | 6px | Tab toggles (legacy alias) | +| `{rounded.full}` | 6px | Avatar containers (legacy alias) | ### Photography & Illustration Geometry @@ -410,68 +415,85 @@ Intercom resists drop shadows. Depth is communicated by the white-on-cream surfa ### Buttons **`button-primary`** — Charcoal CTA. The default primary CTA across all pages. + - Background `{colors.ink}`, text `{colors.on-primary}`, type `{typography.button}`, padding 10px 18px, rounded `{rounded.md}`. - Pressed state lives in `button-primary-pressed`. **`button-secondary`** — White button on cream. Used for secondary CTAs. + - Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.button}`, padding 10px 18px, rounded `{rounded.md}`. 1px `{colors.hairline}` border. **`button-tertiary`** — Plain text button. + - Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.button}`, rounded `{rounded.md}`, padding 10px 18px. **`button-fin`** — Fin Orange CTA — reserved for Fin AI product CTAs. + - Background `{colors.fin-orange}`, text `{colors.on-primary}`, type `{typography.button}`, rounded `{rounded.md}`, padding 10px 18px. ### Pricing Tabs **`pricing-tab-default`** + **`pricing-tab-selected`** — Compact tab toggle on `/pricing`. + - Default: `{colors.canvas}` background, `{colors.ink-muted}` text, rounded `{rounded.md}` (6px). - Selected: `{colors.surface-1}` white background, `{colors.ink}` text — selected = lift onto white. ### Cards & Containers **`pricing-card`** — Each tier on `/pricing`. + - Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.lg}`, padding 24px. **`pricing-card-featured`** — Featured / recommended tier — inverts to charcoal. + - Background `{colors.ink}`, text `{colors.on-primary}`, otherwise identical structure. **`feature-card`** — Generic feature highlight. + - Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.lg}`, padding 24px. **`product-mockup-card`** — The dominant card type — frames a high-fidelity product UI screenshot. + - Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.xl}`, padding 24px. **`testimonial-card`** — Customer quote with avatar + name + company. + - Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body-lg}`, rounded `{rounded.lg}`, padding 32px. **`startup-discount-card`** — The "Startups get 90% off" tinted card. + - Background `{colors.surface-2}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.lg}`, padding 32px. **`customer-logo-tile`** — Small tile in the customer marquee. + - Background `{colors.canvas}`, text `{colors.ink-muted}`, type `{typography.caption}`, rounded `{rounded.xs}`, padding 16px. **`cta-banner`** — Closing CTA panel near page bottom. + - Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.headline}`, rounded `{rounded.lg}`, padding 48px. ### Inputs & Forms **`text-input`** + **`text-input-focused`** — Form fields on contact and search overlays. + - Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.md}`, padding 10px 14px. ### FAQ **`faq-row`** — Expandable accordion row in the pricing FAQ. + - Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.md}`, padding 24px. 1px `{colors.hairline-soft}` bottom rule. ### Navigation **`top-nav`** — Sticky cream bar with the Intercom wordmark left, nav links centered, log-in + sign-up pair right. + - Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-sm}`, height 56px. ### Footer **`footer`** — Dense link grid on `{colors.canvas}` cream with the Intercom wordmark left. + - Background `{colors.canvas}`, text `{colors.ink-muted}`, type `{typography.caption}`, padding 64px 32px. ## Do's and Don'ts @@ -501,13 +523,13 @@ Intercom resists drop shadows. Depth is communicated by the white-on-cream surfa ### Breakpoints -| Name | Width | Key Changes | -|---|---|---| -| Desktop-XL | 1440px | Default desktop layout | -| Desktop | 1280px | Card grid 3-up maintained | -| Tablet | 1024px | Card grid 3-up → 2-up | -| Mobile-Lg | 768px | Pricing comparison becomes accordion; nav hamburger | -| Mobile | 480px | Single-column; display-xl scales 72px → ~32px | +| Name | Width | Key Changes | +| ---------- | ------ | --------------------------------------------------- | +| Desktop-XL | 1440px | Default desktop layout | +| Desktop | 1280px | Card grid 3-up maintained | +| Tablet | 1024px | Card grid 3-up → 2-up | +| Mobile-Lg | 768px | Pricing comparison becomes accordion; nav hamburger | +| Mobile | 480px | Single-column; display-xl scales 72px → ~32px | ### Touch Targets diff --git a/zhixing-web/src/app/layout/app-layout.tsx b/zhixing-web/src/app/layout/app-layout.tsx index 71f7d68..d81b6c8 100644 --- a/zhixing-web/src/app/layout/app-layout.tsx +++ b/zhixing-web/src/app/layout/app-layout.tsx @@ -23,6 +23,7 @@ import { function useActiveRoutePath() { const matchRoute = useMatchRoute() + if (matchRoute({ to: "/sector-radar", fuzzy: true })) return "/sector-radar" if (matchRoute({ to: "/selection", fuzzy: true })) return "/selection" if (matchRoute({ to: "/sync", fuzzy: true })) return "/sync" if (matchRoute({ to: "/components", fuzzy: true })) return "/components" diff --git a/zhixing-web/src/app/layout/navigation.test.ts b/zhixing-web/src/app/layout/navigation.test.ts index 812e50b..6899d80 100644 --- a/zhixing-web/src/app/layout/navigation.test.ts +++ b/zhixing-web/src/app/layout/navigation.test.ts @@ -26,3 +26,24 @@ describe("sync navigation", () => { }) }) }) + +describe("sector radar navigation", () => { + it("exposes the radar route in desktop and mobile navigation", () => { + const radar = primaryNavigation.find((item) => item.id === "sector-radar") + + expect(radar).toMatchObject({ + availability: "available", + label: "资金雷达", + to: "/sector-radar", + }) + expect(mobilePrimaryNavigation).toContain(radar) + }) + + it("provides the active route presentation for the radar", () => { + expect(routePresentation["/sector-radar"]).toEqual({ + breadcrumb: "研究工作台", + id: "sector-radar", + title: "板块资金雷达", + }) + }) +}) diff --git a/zhixing-web/src/app/layout/navigation.ts b/zhixing-web/src/app/layout/navigation.ts index 065f1cd..34d8d18 100644 --- a/zhixing-web/src/app/layout/navigation.ts +++ b/zhixing-web/src/app/layout/navigation.ts @@ -1,9 +1,9 @@ import { - BarChart3, BrainCircuit, ClipboardCheck, Component, LayoutDashboard, + Radar, type LucideIcon, } from "lucide-react" @@ -26,11 +26,11 @@ export const primaryNavigation: readonly NavigationItem[] = [ availability: "available", }, { - id: "market", - label: "行情数据", - to: null, - icon: BarChart3, - availability: "unavailable", + id: "sector-radar", + label: "资金雷达", + to: "/sector-radar", + icon: Radar, + availability: "available", }, { id: "sync", @@ -79,6 +79,11 @@ export const routePresentation: Record = { breadcrumb: "研究工作台", title: "知行 B1 执行结果", }, + "/sector-radar": { + id: "sector-radar", + breadcrumb: "研究工作台", + title: "板块资金雷达", + }, "/sync": { id: "sync", breadcrumb: "研究工作台", diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.api.test.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.api.test.ts new file mode 100644 index 0000000..a77a8be --- /dev/null +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.api.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const requestJson = vi.hoisted(() => vi.fn()) + +vi.mock("@/shared/api/request-json", () => ({ requestJson })) + +import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api" + +const publication = { + publication_id: "publication-1", + target_trade_date: "2026-08-28", + status: "success", + source_version: "tushare-pro-v1", + universe_version: "eastmoney-dc-v1", + metric_versions: ["zhixing_amount_net_bn_v1"], + input_hash: "a".repeat(64), + coverage: "0.99", + started_at: "2026-08-28T08:00:00Z", + finished_at: "2026-08-28T08:05:00Z", + error_summary: null, +} + +const rankingPayload = { + status: "success", + requested_trade_date: "2026-08-28", + sector_type: "concept", + view: "rank_change", + rank_change_metric: "amount", + rank_change_days: 5, + side: "top", + search: "机器人", + publication, + definition: { + metric_kind: "amount", + metric_version: "zhixing_amount_net_bn_v1", + label: "主力净流入(知行独立实现)", + unit: "CNY_100M", + implementation_kind: "independent", + disclaimer: "知行独立实现,非 OneChartLab 原站公式。", + }, + page: 2, + page_size: 10, + total: 1, + rows: [ + { + trade_date: "2026-08-28", + sector_type: "concept", + sector_code: "BK0001.DC", + sector_name: "机器人", + metric_kind: "amount", + metric_version: "zhixing_amount_net_bn_v1", + implementation_kind: "independent", + unit: "CNY_100M", + metric_value: "12.5", + quality: "available", + member_count: 20, + valid_sample_count: 19, + membership_coverage: "1", + moneyflow_coverage: "0.95", + rank_position: 1, + rank_percentile: "100", + rank_change_days: 5, + rank_change: 3, + }, + ], +} + +describe("sector radar API adapters", () => { + beforeEach(() => { + requestJson.mockReset() + }) + + it("forwards AbortSignal and normalizes decimal publication fields", async () => { + const signal = new AbortController().signal + requestJson.mockResolvedValue({ + status: "success", + available_dates: ["2026-08-28"], + current_attempt: publication, + last_good: publication, + }) + + const result = await getSectorRadarDates(signal) + + expect(requestJson).toHaveBeenCalledWith("/api/v1/sector-radar/dates", { + signal, + }) + expect(result.last_good?.coverage).toBe(0.99) + }) + + it("maps URL-backed filters to the ranking HTTP contract", async () => { + const signal = new AbortController().signal + requestJson.mockResolvedValue(rankingPayload) + + const result = await getSectorRadarRankings( + { + tradeDate: "2026-08-28", + sectorType: "concept", + view: "rank_change", + rankChangeMetric: "amount", + rankChangeDays: 5, + side: "top", + search: " 机器人 ", + page: 2, + pageSize: 10, + }, + signal, + ) + + const [input, init] = requestJson.mock.calls[0] as [ + string, + { signal?: AbortSignal }, + ] + const params = new URL(input, "http://localhost").searchParams + expect(input).toContain("/api/v1/sector-radar/rankings?") + expect(params.get("trade_date")).toBe("2026-08-28") + expect(params.get("sector_type")).toBe("concept") + expect(params.get("view")).toBe("rank_change") + expect(params.get("rank_change_metric")).toBe("amount") + expect(params.get("rank_change_days")).toBe("5") + expect(params.get("side")).toBe("top") + expect(params.get("search")).toBe("机器人") + expect(params.get("page")).toBe("2") + expect(params.get("page_size")).toBe("10") + expect(init).toEqual({ signal }) + expect(result.rows[0]?.metric_value).toBe(12.5) + expect(result.rows[0]?.rank_percentile).toBe(100) + }) + + it("rejects unknown stable enum values at the feature boundary", async () => { + requestJson.mockResolvedValue({ ...rankingPayload, view: "private_score" }) + + await expect( + getSectorRadarRankings({ + sectorType: "concept", + view: "amount", + rankChangeMetric: "amount", + rankChangeDays: 1, + side: "all", + page: 1, + pageSize: 20, + }), + ).rejects.toThrow("Invalid sector radar response: rankings.view") + }) + + it("rejects non-finite decimal strings instead of rendering them", async () => { + requestJson.mockResolvedValue({ + ...rankingPayload, + rows: [{ ...rankingPayload.rows[0], metric_value: "NaN" }], + }) + + await expect( + getSectorRadarRankings({ + sectorType: "concept", + view: "amount", + rankChangeMetric: "amount", + rankChangeDays: 1, + side: "all", + page: 1, + pageSize: 20, + }), + ).rejects.toThrow("rankings.rows[0].metric_value must be finite") + }) + + it("rejects a zero rank percentile that the domain cannot produce", async () => { + requestJson.mockResolvedValue({ + ...rankingPayload, + rows: [{ ...rankingPayload.rows[0], rank_percentile: "0" }], + }) + + await expect( + getSectorRadarRankings({ + sectorType: "concept", + view: "amount", + rankChangeMetric: "amount", + rankChangeDays: 1, + side: "all", + page: 1, + pageSize: 20, + }), + ).rejects.toThrow("rankings.rows[0].rank_percentile must be greater than 0") + }) +}) diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.api.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.api.ts new file mode 100644 index 0000000..dd5e1ba --- /dev/null +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.api.ts @@ -0,0 +1,420 @@ +import { requestJson } from "@/shared/api/request-json" + +import { + radarMetricKinds, + radarMetricQualities, + radarMetricUnits, + radarPublicationStatuses, + radarRankSides, + radarViews, + sectorTypes, + type RadarDatesResponse, + type RadarMetricDefinition, + type RadarPublication, + type RadarRankingRow, + type RadarRankingsQuery, + type RadarRankingsResponse, +} from "./sector-radar.types" + +type JsonRecord = Record + +/** + * Fetch and validate the available sector-radar publication dates. + * + * @param signal - React Query cancellation signal forwarded to fetch. + * @returns A normalized response whose decimal fields are finite numbers. + * @throws Error when the server response violates the stable HTTP contract. + */ +export async function getSectorRadarDates(signal?: AbortSignal) { + const payload = await requestJson("/api/v1/sector-radar/dates", { + signal, + }) + return parseRadarDatesResponse(payload) +} + +/** + * Fetch one persisted ranking page using the backend's snake-case query names. + * + * @param query - URL-backed ranking filters owned by the sector-radar feature. + * @param signal - React Query cancellation signal forwarded to fetch. + * @returns A validated and normalized ranking page. + * @throws Error when the server response violates the stable HTTP contract. + */ +export async function getSectorRadarRankings( + query: RadarRankingsQuery, + signal?: AbortSignal, +) { + const params = new URLSearchParams({ + sector_type: query.sectorType, + view: query.view, + rank_change_metric: query.rankChangeMetric, + rank_change_days: String(query.rankChangeDays), + side: query.side, + page: String(query.page), + page_size: String(query.pageSize), + }) + if (query.tradeDate) params.set("trade_date", query.tradeDate) + if (query.search?.trim()) params.set("search", query.search.trim()) + + const payload = await requestJson( + `/api/v1/sector-radar/rankings?${params.toString()}`, + { signal }, + ) + return parseRadarRankingsResponse(payload) +} + +/** + * Validate the dates response at the feature boundary. + * + * The shared transport intentionally does not own feature schemas. Decimal + * strings emitted by Pydantic are converted here so the page never handles + * mixed string/number arithmetic. + */ +export function parseRadarDatesResponse(value: unknown): RadarDatesResponse { + const record = readRecord(value, "dates") + return { + status: readEnum(record.status, ["success", "no_data"], "dates.status"), + available_dates: readArray( + record.available_dates, + "dates.available_dates", + ).map((item, index) => readDate(item, `dates.available_dates[${index}]`)), + current_attempt: readNullablePublication( + record.current_attempt, + "dates.current_attempt", + ), + last_good: readNullablePublication(record.last_good, "dates.last_good"), + } +} + +/** Validate and normalize one ranking response from the same-repository API. */ +export function parseRadarRankingsResponse( + value: unknown, +): RadarRankingsResponse { + const record = readRecord(value, "rankings") + return { + status: readEnum(record.status, ["success", "no_data"], "rankings.status"), + requested_trade_date: readNullableDate( + record.requested_trade_date, + "rankings.requested_trade_date", + ), + sector_type: readEnum( + record.sector_type, + sectorTypes, + "rankings.sector_type", + ), + view: readEnum(record.view, radarViews, "rankings.view"), + rank_change_metric: readEnum( + record.rank_change_metric, + radarMetricKinds, + "rankings.rank_change_metric", + ), + rank_change_days: readIntegerInRange( + record.rank_change_days, + 1, + 5, + "rankings.rank_change_days", + ), + side: readEnum(record.side, radarRankSides, "rankings.side"), + search: readNullableString(record.search, "rankings.search"), + publication: readNullablePublication( + record.publication, + "rankings.publication", + ), + definition: readMetricDefinition(record.definition), + page: readIntegerInRange( + record.page, + 1, + Number.MAX_SAFE_INTEGER, + "rankings.page", + ), + page_size: readIntegerInRange( + record.page_size, + 1, + 100, + "rankings.page_size", + ), + total: readIntegerInRange( + record.total, + 0, + Number.MAX_SAFE_INTEGER, + "rankings.total", + ), + rows: readArray(record.rows, "rankings.rows").map((item, index) => + readRankingRow(item, index), + ), + } +} + +function readNullablePublication( + value: unknown, + path: string, +): RadarPublication | null { + if (value === null) return null + const record = readRecord(value, path) + return { + publication_id: readNonEmptyString( + record.publication_id, + `${path}.publication_id`, + ), + target_trade_date: readDate( + record.target_trade_date, + `${path}.target_trade_date`, + ), + status: readEnum(record.status, radarPublicationStatuses, `${path}.status`), + source_version: readNonEmptyString( + record.source_version, + `${path}.source_version`, + ), + universe_version: readNonEmptyString( + record.universe_version, + `${path}.universe_version`, + ), + metric_versions: readArray( + record.metric_versions, + `${path}.metric_versions`, + ).map((item, index) => + readNonEmptyString(item, `${path}.metric_versions[${index}]`), + ), + input_hash: readNullableString(record.input_hash, `${path}.input_hash`), + coverage: readFraction(record.coverage, `${path}.coverage`), + started_at: readDateTime(record.started_at, `${path}.started_at`), + finished_at: readNullableDateTime( + record.finished_at, + `${path}.finished_at`, + ), + error_summary: readNullableString( + record.error_summary, + `${path}.error_summary`, + ), + } +} + +function readMetricDefinition(value: unknown): RadarMetricDefinition { + const path = "rankings.definition" + const record = readRecord(value, path) + return { + metric_kind: readEnum( + record.metric_kind, + radarMetricKinds, + `${path}.metric_kind`, + ), + metric_version: readNonEmptyString( + record.metric_version, + `${path}.metric_version`, + ), + label: readNonEmptyString(record.label, `${path}.label`), + unit: readEnum(record.unit, radarMetricUnits, `${path}.unit`), + implementation_kind: readEnum( + record.implementation_kind, + ["independent"], + `${path}.implementation_kind`, + ), + disclaimer: readNonEmptyString(record.disclaimer, `${path}.disclaimer`), + } +} + +function readRankingRow(value: unknown, index: number): RadarRankingRow { + const path = `rankings.rows[${index}]` + const record = readRecord(value, path) + return { + trade_date: readDate(record.trade_date, `${path}.trade_date`), + sector_type: readEnum( + record.sector_type, + sectorTypes, + `${path}.sector_type`, + ), + sector_code: readNonEmptyString(record.sector_code, `${path}.sector_code`), + sector_name: readNonEmptyString(record.sector_name, `${path}.sector_name`), + metric_kind: readEnum( + record.metric_kind, + radarMetricKinds, + `${path}.metric_kind`, + ), + metric_version: readNonEmptyString( + record.metric_version, + `${path}.metric_version`, + ), + implementation_kind: readEnum( + record.implementation_kind, + ["independent"], + `${path}.implementation_kind`, + ), + unit: readEnum(record.unit, radarMetricUnits, `${path}.unit`), + metric_value: readNullableFiniteNumber( + record.metric_value, + `${path}.metric_value`, + ), + quality: readEnum(record.quality, radarMetricQualities, `${path}.quality`), + member_count: readIntegerInRange( + record.member_count, + 0, + Number.MAX_SAFE_INTEGER, + `${path}.member_count`, + ), + valid_sample_count: readIntegerInRange( + record.valid_sample_count, + 0, + Number.MAX_SAFE_INTEGER, + `${path}.valid_sample_count`, + ), + membership_coverage: readFraction( + record.membership_coverage, + `${path}.membership_coverage`, + ), + moneyflow_coverage: readFraction( + record.moneyflow_coverage, + `${path}.moneyflow_coverage`, + ), + rank_position: readNullableInteger( + record.rank_position, + 1, + `${path}.rank_position`, + ), + rank_percentile: readNullablePositiveNumber( + record.rank_percentile, + 100, + `${path}.rank_percentile`, + ), + rank_change_days: readIntegerInRange( + record.rank_change_days, + 1, + 5, + `${path}.rank_change_days`, + ), + rank_change: readNullableInteger( + record.rank_change, + Number.MIN_SAFE_INTEGER, + `${path}.rank_change`, + ), + } +} + +function readRecord(value: unknown, path: string): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw contractError(path, "must be an object") + } + return value as JsonRecord +} + +function readArray(value: unknown, path: string): unknown[] { + if (!Array.isArray(value)) throw contractError(path, "must be an array") + return value +} + +function readEnum( + value: unknown, + allowed: Values, + path: string, +): Values[number] { + if (typeof value !== "string" || !allowed.includes(value)) { + throw contractError(path, `must be one of ${allowed.join(", ")}`) + } + return value as Values[number] +} + +function readNonEmptyString(value: unknown, path: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw contractError(path, "must be a non-empty string") + } + return value +} + +function readNullableString(value: unknown, path: string): string | null { + if (value === null) return null + if (typeof value !== "string") + throw contractError(path, "must be a string or null") + return value +} + +function readDate(value: unknown, path: string): string { + const text = readNonEmptyString(value, path) + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text) + if (!match) throw contractError(path, "must be an ISO trade date") + const parsed = new Date(`${text}T00:00:00Z`) + if ( + Number.isNaN(parsed.getTime()) || + parsed.getUTCFullYear() !== Number(match[1]) || + parsed.getUTCMonth() + 1 !== Number(match[2]) || + parsed.getUTCDate() !== Number(match[3]) + ) { + throw contractError(path, "must be a valid trade date") + } + return text +} + +function readNullableDate(value: unknown, path: string): string | null { + return value === null ? null : readDate(value, path) +} + +function readDateTime(value: unknown, path: string): string { + const text = readNonEmptyString(value, path) + if (!Number.isFinite(Date.parse(text))) { + throw contractError(path, "must be an ISO date-time") + } + return text +} + +function readNullableDateTime(value: unknown, path: string): string | null { + return value === null ? null : readDateTime(value, path) +} + +function readFiniteNumber(value: unknown, path: string): number { + const number = + typeof value === "number" + ? value + : typeof value === "string" && value.trim().length > 0 + ? Number(value) + : Number.NaN + if (!Number.isFinite(number)) throw contractError(path, "must be finite") + return number +} + +function readNullableFiniteNumber(value: unknown, path: string): number | null { + return value === null ? null : readFiniteNumber(value, path) +} + +function readFraction(value: unknown, path: string): number { + const number = readFiniteNumber(value, path) + if (number < 0 || number > 1) + throw contractError(path, "must be between 0 and 1") + return number +} + +function readIntegerInRange( + value: unknown, + min: number, + max: number, + path: string, +): number { + const number = readFiniteNumber(value, path) + if (!Number.isInteger(number) || number < min || number > max) { + throw contractError(path, `must be an integer between ${min} and ${max}`) + } + return number +} + +function readNullableInteger( + value: unknown, + min: number, + path: string, +): number | null { + if (value === null) return null + return readIntegerInRange(value, min, Number.MAX_SAFE_INTEGER, path) +} + +function readNullablePositiveNumber( + value: unknown, + max: number, + path: string, +): number | null { + if (value === null) return null + const number = readFiniteNumber(value, path) + if (number <= 0 || number > max) { + throw contractError(path, `must be greater than 0 and at most ${max}`) + } + return number +} + +function contractError(path: string, reason: string): Error { + return new Error(`Invalid sector radar response: ${path} ${reason}`) +} diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts new file mode 100644 index 0000000..3166f20 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const useQuery = vi.hoisted(() => vi.fn()) +const api = vi.hoisted(() => ({ + getSectorRadarDates: vi.fn(), + getSectorRadarRankings: vi.fn(), +})) + +vi.mock("@tanstack/react-query", () => ({ useQuery })) +vi.mock("./sector-radar.api", () => api) + +import { + sectorRadarDatesQueryKey, + sectorRadarRankingsQueryKey, + useSectorRadarDates, + useSectorRadarRankings, +} from "./sector-radar.query" + +const query = { + tradeDate: "2026-08-28", + sectorType: "industry" as const, + view: "rank_change" as const, + rankChangeMetric: "ratio" as const, + rankChangeDays: 3, + side: "bottom" as const, + search: "银行", + page: 2, + pageSize: 50, +} + +describe("sector radar query hooks", () => { + beforeEach(() => { + vi.clearAllMocks() + useQuery.mockImplementation((options) => options) + }) + + it("keeps every server-affecting filter in the ranking query key", () => { + expect(sectorRadarDatesQueryKey).toEqual(["sectorRadar", "dates"]) + expect(sectorRadarRankingsQueryKey(query)).toEqual([ + "sectorRadar", + "rankings", + "2026-08-28", + "industry", + "rank_change", + "ratio", + 3, + "bottom", + "银行", + 2, + 50, + ]) + }) + + it("forwards React Query cancellation signals to both adapters", async () => { + useSectorRadarDates() + useSectorRadarRankings(query) + const datesOptions = useQuery.mock.calls[0]?.[0] as { + queryFn: (context: { signal: AbortSignal }) => unknown + } + const rankingsOptions = useQuery.mock.calls[1]?.[0] as { + queryFn: (context: { signal: AbortSignal }) => unknown + } + const signal = new AbortController().signal + + await datesOptions.queryFn({ signal }) + await rankingsOptions.queryFn({ signal }) + + expect(api.getSectorRadarDates).toHaveBeenCalledWith(signal) + expect(api.getSectorRadarRankings).toHaveBeenCalledWith(query, signal) + }) +}) diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts new file mode 100644 index 0000000..37d4d05 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query" + +import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api" +import type { RadarRankingsQuery } from "./sector-radar.types" + +export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const + +export const sectorRadarRankingsQueryKey = (query: RadarRankingsQuery) => + [ + "sectorRadar", + "rankings", + query.tradeDate ?? "latest", + query.sectorType, + query.view, + query.rankChangeMetric, + query.rankChangeDays, + query.side, + query.search ?? "", + query.page, + query.pageSize, + ] as const + +export function useSectorRadarDates() { + return useQuery({ + queryFn: ({ signal }) => getSectorRadarDates(signal), + queryKey: sectorRadarDatesQueryKey, + }) +} + +export function useSectorRadarRankings(query: RadarRankingsQuery) { + return useQuery({ + queryFn: ({ signal }) => getSectorRadarRankings(query, signal), + queryKey: sectorRadarRankingsQueryKey(query), + }) +} diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.types.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.types.ts new file mode 100644 index 0000000..1300fca --- /dev/null +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.types.ts @@ -0,0 +1,113 @@ +export const sectorTypes = ["concept", "industry"] as const +export type SectorType = (typeof sectorTypes)[number] + +export const radarViews = ["amount", "ratio", "swing", "rank_change"] as const +export type RadarView = (typeof radarViews)[number] + +export const radarMetricKinds = ["amount", "ratio", "swing"] as const +export type RadarMetricKind = (typeof radarMetricKinds)[number] + +export const radarRankSides = ["top", "bottom", "all"] as const +export type RadarRankSide = (typeof radarRankSides)[number] + +export const radarPublicationStatuses = [ + "running", + "success", + "partial", + "failed", +] as const +export type RadarPublicationStatus = (typeof radarPublicationStatuses)[number] + +export const radarMetricQualities = [ + "available", + "available_limited_sample", + "unavailable", +] as const +export type RadarMetricQuality = (typeof radarMetricQualities)[number] + +export const radarMetricUnits = ["CNY_100M", "ratio"] as const +export type RadarMetricUnit = (typeof radarMetricUnits)[number] + +export interface RadarPublication { + publication_id: string + target_trade_date: string + status: RadarPublicationStatus + source_version: string + universe_version: string + metric_versions: string[] + input_hash: string | null + coverage: number + started_at: string + finished_at: string | null + error_summary: string | null +} + +export interface RadarDatesResponse { + status: "success" | "no_data" + available_dates: string[] + current_attempt: RadarPublication | null + last_good: RadarPublication | null +} + +export interface RadarMetricDefinition { + metric_kind: RadarMetricKind + metric_version: string + label: string + unit: RadarMetricUnit + implementation_kind: "independent" + disclaimer: string +} + +export interface RadarRankingRow { + trade_date: string + sector_type: SectorType + sector_code: string + sector_name: string + metric_kind: RadarMetricKind + metric_version: string + implementation_kind: "independent" + unit: RadarMetricUnit + metric_value: number | null + quality: RadarMetricQuality + member_count: number + valid_sample_count: number + membership_coverage: number + moneyflow_coverage: number + rank_position: number | null + rank_percentile: number | null + rank_change_days: number + rank_change: number | null +} + +export interface RadarRankingsResponse { + status: "success" | "no_data" + requested_trade_date: string | null + sector_type: SectorType + view: RadarView + rank_change_metric: RadarMetricKind + rank_change_days: number + side: RadarRankSide + search: string | null + publication: RadarPublication | null + definition: RadarMetricDefinition + page: number + page_size: number + total: number + rows: RadarRankingRow[] +} + +export interface RadarRankingsQuery { + tradeDate?: string + sectorType: SectorType + view: RadarView + rankChangeMetric: RadarMetricKind + rankChangeDays: number + side: RadarRankSide + search?: string + page: number + pageSize: number +} + +export interface SectorRadarRouteSearch extends RadarRankingsQuery { + search: string +} diff --git a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx new file mode 100644 index 0000000..79c7f23 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx @@ -0,0 +1,330 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import type { + RadarDatesResponse, + RadarRankingsResponse, + SectorRadarRouteSearch, +} from "../api/sector-radar.types" + +import { SectorRadarPage } from "./sector-radar-page" + +const navigate = vi.hoisted(() => vi.fn()) +const useSectorRadarDates = vi.fn() +const useSectorRadarRankings = vi.fn() +const refetchDates = vi.fn() +const refetchRankings = vi.fn() + +let routeSearch: SectorRadarRouteSearch + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => navigate, + useSearch: () => routeSearch, +})) + +vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({ + useSectorRadarDates: () => useSectorRadarDates(), + useSectorRadarRankings: (...args: unknown[]) => + useSectorRadarRankings(...args), +})) + +const successPublication = { + publication_id: "publication-success", + target_trade_date: "2026-08-28", + status: "success" as const, + source_version: "tushare-pro-v1", + universe_version: "eastmoney-dc-v1", + metric_versions: ["zhixing_amount_net_bn_v1"], + input_hash: "a".repeat(64), + coverage: 0.99, + started_at: "2026-08-28T08:00:00Z", + finished_at: "2026-08-28T08:05:00Z", + error_summary: null, +} + +const datesResponse: RadarDatesResponse = { + status: "success", + available_dates: ["2026-08-28", "2026-08-27"], + current_attempt: successPublication, + last_good: successPublication, +} + +const rankingsResponse: RadarRankingsResponse = { + status: "success", + requested_trade_date: null, + sector_type: "concept", + view: "amount", + rank_change_metric: "amount", + rank_change_days: 1, + side: "all", + search: null, + publication: successPublication, + definition: { + metric_kind: "amount", + metric_version: "zhixing_amount_net_bn_v1", + label: "主力净流入(知行独立实现)", + unit: "CNY_100M", + implementation_kind: "independent", + disclaimer: "知行独立实现,非 OneChartLab 原站公式。", + }, + page: 1, + page_size: 20, + total: 2, + rows: [ + { + trade_date: "2026-08-28", + sector_type: "concept", + sector_code: "BK0001.DC", + sector_name: "机器人", + metric_kind: "amount", + metric_version: "zhixing_amount_net_bn_v1", + implementation_kind: "independent", + unit: "CNY_100M", + metric_value: 12.5, + quality: "available", + member_count: 20, + valid_sample_count: 19, + membership_coverage: 1, + moneyflow_coverage: 0.95, + rank_position: 1, + rank_percentile: 100, + rank_change_days: 1, + rank_change: 3, + }, + { + trade_date: "2026-08-28", + sector_type: "concept", + sector_code: "BK0002.DC", + sector_name: "低空经济", + metric_kind: "amount", + metric_version: "zhixing_amount_net_bn_v1", + implementation_kind: "independent", + unit: "CNY_100M", + metric_value: null, + quality: "available_limited_sample", + member_count: 4, + valid_sample_count: 3, + membership_coverage: 1, + moneyflow_coverage: 0.75, + rank_position: null, + rank_percentile: null, + rank_change_days: 1, + rank_change: null, + }, + ], +} + +describe("SectorRadarPage", () => { + beforeEach(() => { + routeSearch = { + tradeDate: undefined, + sectorType: "concept", + view: "amount", + rankChangeMetric: "amount", + rankChangeDays: 1, + side: "all", + search: "", + page: 1, + pageSize: 20, + } + useSectorRadarDates.mockReturnValue({ + data: datesResponse, + isError: false, + isFetching: false, + isPending: false, + refetch: refetchDates, + }) + useSectorRadarRankings.mockReturnValue({ + data: rankingsResponse, + isError: false, + isFetching: false, + isPending: false, + refetch: refetchRankings, + }) + navigate.mockReset() + refetchDates.mockReset() + refetchRankings.mockReset() + }) + + it("renders independent metric provenance, units, quality, and ranking rows", () => { + render() + + expect(screen.getAllByText("知行独立实现").length).toBeGreaterThan(0) + expect(screen.getByText("zhixing_amount_net_bn_v1")).toBeInTheDocument() + expect(screen.getByText("12.5 亿元")).toBeInTheDocument() + expect(screen.getByText("机器人")).toBeInTheDocument() + expect(screen.getByText("样本有限")).toBeInTheDocument() + expect(screen.getByText("95%")).toBeInTheDocument() + }) + + it("stores search and pagination changes in router search state", () => { + render() + + fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), { + target: { value: "机器人" }, + }) + fireEvent.click(screen.getByRole("button", { name: "第 1 页" })) + + const searchCall = navigate.mock.calls[0]?.[0].search as ( + previous: SectorRadarRouteSearch, + ) => SectorRadarRouteSearch + expect(searchCall({ ...routeSearch, page: 3 })).toMatchObject({ + page: 1, + search: "机器人", + }) + }) + + it("shows rank changes without inventing missing history", () => { + routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 } + useSectorRadarRankings.mockReturnValue({ + data: { + ...rankingsResponse, + view: "rank_change", + rank_change_days: 5, + rows: rankingsResponse.rows.map((row, index) => ({ + ...row, + rank_change_days: 5, + rank_change: index === 0 ? 3 : null, + })), + }, + isError: false, + isFetching: false, + isPending: false, + refetch: refetchRankings, + }) + + render() + + expect( + screen.getByRole("columnheader", { name: "5 日排名变化" }), + ).toBeInTheDocument() + expect(screen.getByText("+3")).toBeInTheDocument() + expect(screen.getByText("暂无可比历史")).toBeInTheDocument() + }) + + it("warns when a partial attempt has not replaced last-good", () => { + useSectorRadarDates.mockReturnValue({ + data: { + ...datesResponse, + current_attempt: { + ...successPublication, + publication_id: "publication-partial", + target_trade_date: "2026-08-29", + status: "partial", + coverage: 0.8, + input_hash: null, + }, + }, + isError: false, + isFetching: false, + isPending: false, + refetch: refetchDates, + }) + + render() + + expect(screen.getByRole("status")).toHaveTextContent("新一期数据不完整") + expect(screen.getByRole("status")).toHaveTextContent( + "当前仍展示最近有效发布 2026-08-28", + ) + }) + + it("keeps the last response visible while React Query refreshes it", () => { + useSectorRadarRankings.mockReturnValue({ + data: rankingsResponse, + isError: false, + isFetching: true, + isPending: false, + refetch: refetchRankings, + }) + + render() + + expect(screen.getByRole("status")).toHaveTextContent( + "当前继续展示上一次已读取的有效结果", + ) + expect(screen.getByText("机器人")).toBeInTheDocument() + }) + + it("keeps stale data visible when a background refresh fails", () => { + useSectorRadarRankings.mockReturnValue({ + data: rankingsResponse, + isError: true, + isFetching: false, + isPending: false, + refetch: refetchRankings, + }) + + render() + + expect(screen.getByRole("status")).toHaveTextContent("雷达数据刷新失败") + expect(screen.getByText("机器人")).toBeInTheDocument() + expect(screen.queryByText("板块资金雷达暂时不可用")).not.toBeInTheDocument() + }) + + it("renders loading, error with retry, and no-data states", () => { + useSectorRadarDates.mockReturnValue({ + data: undefined, + isError: false, + isFetching: true, + isPending: true, + refetch: refetchDates, + }) + useSectorRadarRankings.mockReturnValue({ + data: undefined, + isError: false, + isFetching: true, + isPending: true, + refetch: refetchRankings, + }) + const { rerender } = render() + expect(screen.getByLabelText("正在加载板块资金雷达")).toBeInTheDocument() + + useSectorRadarDates.mockReturnValue({ + data: undefined, + isError: true, + isFetching: false, + isPending: false, + refetch: refetchDates, + }) + useSectorRadarRankings.mockReturnValue({ + data: undefined, + isError: true, + isFetching: false, + isPending: false, + refetch: refetchRankings, + }) + rerender() + fireEvent.click(screen.getByRole("button", { name: "重试" })) + expect(refetchDates).toHaveBeenCalledOnce() + expect(refetchRankings).toHaveBeenCalledOnce() + + useSectorRadarDates.mockReturnValue({ + data: { + status: "no_data", + available_dates: [], + current_attempt: null, + last_good: null, + }, + isError: false, + isFetching: false, + isPending: false, + refetch: refetchDates, + }) + useSectorRadarRankings.mockReturnValue({ + data: { + ...rankingsResponse, + status: "no_data", + publication: null, + rows: [], + total: 0, + }, + isError: false, + isFetching: false, + isPending: false, + refetch: refetchRankings, + }) + rerender() + expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument() + }) +}) diff --git a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx new file mode 100644 index 0000000..53710e1 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx @@ -0,0 +1,648 @@ +import { AlertTriangle, Database, RefreshCw } from "lucide-react" +import { useEffect } from "react" +import { useNavigate, useSearch } from "@tanstack/react-router" + +import { PageLayout } from "@/app/layout/page-layout" +import { + useSectorRadarDates, + useSectorRadarRankings, +} from "@/features/sector-radar/api/sector-radar.query" +import type { + RadarMetricQuality, + RadarMetricUnit, + RadarPublication, + RadarRankingRow, + RadarRankingsQuery, + SectorRadarRouteSearch, +} from "@/features/sector-radar/api/sector-radar.types" +import { Badge } from "@/shared/ui/badge" +import { Button } from "@/shared/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/ui/card" +import { Input } from "@/shared/ui/input" +import { Pagination } from "@/shared/ui/pagination" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui/select" +import { Skeleton } from "@/shared/ui/skeleton" + +const PAGE_SIZE_OPTIONS = [10, 20, 50] as const + +const sectorTypeOptions = [ + { label: "概念板块", value: "concept" }, + { label: "行业板块", value: "industry" }, +] as const + +const viewOptions = [ + { label: "主力净额", value: "amount" }, + { label: "单日资金率", value: "ratio" }, + { label: "3—10 日波段资金率", value: "swing" }, + { label: "排名变化", value: "rank_change" }, +] as const + +const metricOptions = [ + { label: "主力净额", value: "amount" }, + { label: "单日资金率", value: "ratio" }, + { label: "3—10 日波段资金率", value: "swing" }, +] as const + +const sideOptions = [ + { label: "全部板块", value: "all" }, + { label: "强榜", value: "top" }, + { label: "弱榜", value: "bottom" }, +] as const + +const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({ + label: `${value} 日变化`, + value: String(value), +})) + +export function SectorRadarPage() { + const search = useSearch({ from: "/_workspace/sector-radar" }) + const navigate = useNavigate({ from: "/sector-radar" }) + const query: RadarRankingsQuery = { + tradeDate: search.tradeDate, + sectorType: search.sectorType, + view: search.view, + rankChangeMetric: search.rankChangeMetric, + rankChangeDays: search.rankChangeDays, + side: search.side, + search: search.search || undefined, + page: search.page, + pageSize: search.pageSize, + } + const dates = useSectorRadarDates() + const rankings = useSectorRadarRankings(query) + const total = rankings.data?.total ?? 0 + const pageCount = Math.max(1, Math.ceil(total / search.pageSize)) + + useEffect(() => { + if (rankings.data?.status === "success" && search.page > pageCount) { + void navigate({ + search: (previous) => ({ ...previous, page: pageCount }), + }) + } + }, [navigate, pageCount, rankings.data?.status, search.page]) + + function updateSearch(next: Partial) { + void navigate({ search: (previous) => ({ ...previous, ...next }) }) + } + + const loading = dates.isPending || rankings.isPending + const refreshing = + !loading && (Boolean(dates.isFetching) || Boolean(rankings.isFetching)) + const fatalError = + (dates.isError && !dates.data) || (rankings.isError && !rankings.data) + const refreshFailed = + !fatalError && (Boolean(dates.isError) || Boolean(rankings.isError)) + const noData = + dates.data?.status === "no_data" || rankings.data?.status === "no_data" + + return ( + + } + mode="bounded-workspace" + > +
+ {loading ? : null} + {!loading && fatalError ? ( + { + void dates.refetch() + void rankings.refetch() + }} + /> + ) : null} + {!loading && !fatalError && noData ? : null} + {!loading && + !fatalError && + !noData && + dates.data && + rankings.data?.status === "success" && + rankings.data.publication ? ( + <> + {refreshFailed ? ( + + ) : refreshing ? ( + + ) : null} + + updateSearch({ page })} + onPageSizeChange={(pageSize) => + updateSearch({ page: 1, pageSize }) + } + page={Math.min(search.page, pageCount)} + pageSize={search.pageSize} + response={rankings.data} + /> + + ) : null} +
+
+ ) +} + +interface RadarFiltersProps { + availableDates: string[] + onChange: (next: Partial) => void + search: SectorRadarRouteSearch +} + +function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) { + const dateOptions = [ + { label: "最近有效交易日", value: "latest" }, + ...availableDates.map((value) => ({ label: value, value })), + ] + + return ( +
+ + onChange({ + page: 1, + tradeDate: value === "latest" ? undefined : value, + }) + } + /> + + onChange({ + page: 1, + sectorType: value as SectorRadarRouteSearch["sectorType"], + }) + } + /> + + onChange({ page: 1, view: value as SectorRadarRouteSearch["view"] }) + } + /> + {search.view === "rank_change" ? ( + <> + + onChange({ + page: 1, + rankChangeMetric: + value as SectorRadarRouteSearch["rankChangeMetric"], + }) + } + /> + + onChange({ page: 1, rankChangeDays: Number(value) }) + } + /> + + ) : null} + + onChange({ page: 1, side: value as SectorRadarRouteSearch["side"] }) + } + /> + +
+ ) +} + +function FilterSelect({ + className, + label, + onValueChange, + options, + value, +}: { + className?: string + label: string + onValueChange: (value: string) => void + options: readonly { label: string; value: string }[] + value: string +}) { + return ( + + ) +} + +function RadarStatusSummary({ + currentAttempt, + disclaimer, + lastGood, + metricVersion, + publication, +}: { + currentAttempt: RadarPublication | null + disclaimer: string + lastGood: RadarPublication | null + metricVersion: string + publication: RadarPublication +}) { + const degradedAttempt = + currentAttempt && + currentAttempt.status !== "success" && + currentAttempt.publication_id !== lastGood?.publication_id + + return ( + + +
+
+ 收盘后板块资金排名 + {disclaimer} +
+ 知行独立实现 + {metricVersion} +
+ {degradedAttempt ? ( +
+ {attemptStatusLabel(currentAttempt.status)}:目标交易日{" "} + {currentAttempt.target_trade_date} + ,当前仍展示最近有效发布{" "} + {lastGood?.target_trade_date ?? publication.target_trade_date}。 +
+ ) : null} +
+ + + + + + + +
+ ) +} + +function SummaryItem({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

+ {value} +

+
+ ) +} + +function RadarTable({ + onPageChange, + onPageSizeChange, + page, + pageSize, + response, +}: { + onPageChange: (page: number) => void + onPageSizeChange: (pageSize: number) => void + page: number + pageSize: number + response: Exclude< + ReturnType["data"], + undefined + > +}) { + const rankChangeView = response.view === "rank_change" + return ( +
+
+ + + + + + + + {rankChangeView ? ( + + ) : null} + + + + + + + + {response.rows.map((row) => ( + + ))} + {response.rows.length === 0 ? ( + + + + ) : null} + +
+ {response.sector_type === "concept" ? "概念" : "行业"}板块资金排名 +
+ 名次 + + 板块 + + {rankChangeView + ? `${response.rank_change_days} 日排名变化` + : response.definition.label} + + 参考指标值 + + 排名百分位 + + 样本 + + 资金覆盖率 + + 质量 +
+ 没有符合当前筛选条件的板块。 +
+
+ +
+ ) +} + +function RadarTableRow({ + rankChangeView, + row, +}: { + rankChangeView: boolean + row: RadarRankingRow +}) { + return ( + + + {row.rank_position ?? "—"} + + +

{row.sector_name}

+

+ {row.sector_code} +

+ + + {rankChangeView + ? formatRankChange(row.rank_change) + : formatMetricValue(row.metric_value, row.unit)} + + {rankChangeView ? ( + + {formatMetricValue(row.metric_value, row.unit)} + + ) : null} + + {row.rank_percentile === null + ? "—" + : `${formatNumber(row.rank_percentile, 2)}%`} + + + {row.valid_sample_count}/{row.member_count} + + + {formatCoverage(row.moneyflow_coverage)} + + + + + + ) +} + +function QualityBadge({ quality }: { quality: RadarMetricQuality }) { + if (quality === "available") return 可用 + if (quality === "available_limited_sample") { + return ( + + 样本有限 + + ) + } + return 不可用 +} + +function RadarLoading() { + return ( + + + 正在加载板块资金雷达 + 正在读取最近有效发布和板块排名。 + + + + + {Array.from({ length: 7 }, (_, index) => ( + + ))} + + + ) +} + +function RadarRefreshStatus() { + return ( +
+ 雷达数据正在刷新,当前继续展示上一次已读取的有效结果。 +
+ ) +} + +function RadarRefreshFailureStatus() { + return ( +
+ 雷达数据刷新失败,当前继续展示上一次已读取的有效结果。请稍后重试。 +
+ ) +} + +function RadarError({ onRetry }: { onRetry: () => void }) { + return ( + + + + + + 请求或响应校验失败,可能是服务、网络或接口契约暂时异常。 + + + + + + + ) +} + +function RadarNoData() { + return ( + + + + + + 完成第一次收盘后构建并通过完整性门槛后,这里会显示最近有效排名。 + + + + ) +} + +function formatMetricValue(value: number | null, unit: RadarMetricUnit) { + if (value === null) return "—" + return unit === "CNY_100M" + ? `${formatNumber(value, 2)} 亿元` + : `${formatNumber(value * 100, 2)}%` +} + +function formatRankChange(value: number | null) { + if (value === null) return "暂无可比历史" + if (value > 0) return `+${value}` + return String(value) +} + +function formatCoverage(value: number) { + return `${formatNumber(value * 100, 1)}%` +} + +function formatNumber(value: number, maximumFractionDigits: number) { + return value.toLocaleString("zh-CN", { maximumFractionDigits }) +} + +function formatDateTime(value: string | null) { + if (!value) return "—" + return new Intl.DateTimeFormat("zh-CN", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Asia/Shanghai", + }).format(new Date(value)) +} + +function attemptStatusLabel(status: RadarPublication["status"]) { + if (status === "running") return "新一期仍在构建" + if (status === "partial") return "新一期数据不完整" + if (status === "failed") return "新一期构建失败" + return "新一期已发布" +} diff --git a/zhixing-web/src/routes/route-tree.tsx b/zhixing-web/src/routes/route-tree.tsx index 6373685..ab102ad 100644 --- a/zhixing-web/src/routes/route-tree.tsx +++ b/zhixing-web/src/routes/route-tree.tsx @@ -3,6 +3,17 @@ import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router" import { AppLayout } from "@/app/layout/app-layout" import { ComponentsPreviewPage } from "@/features/components/pages/components-preview-page" import { HomePage } from "@/features/home/pages/home-page" +import { + radarMetricKinds, + radarRankSides, + radarViews, + sectorTypes, + type RadarMetricKind, + type RadarRankSide, + type RadarView, + type SectorType, +} from "@/features/sector-radar/api/sector-radar.types" +import { SectorRadarPage } from "@/features/sector-radar/pages/sector-radar-page" import { selectionCategoryFilters, selectionSorts, @@ -28,6 +39,63 @@ const indexRoute = createRoute({ component: HomePage, }) +const sectorRadarRoute = createRoute({ + getParentRoute: () => workspaceRoute, + path: "/sector-radar", + validateSearch: (search: Record) => { + const rawPage = Number(search.page) + const rawPageSize = Number(search.pageSize) + const rawRankChangeDays = Number(search.rankChangeDays) + const page = + Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1 + const pageSize = [10, 20, 50].includes(rawPageSize) ? rawPageSize : 20 + const rankChangeDays = + Number.isFinite(rawRankChangeDays) && + rawRankChangeDays >= 1 && + rawRankChangeDays <= 5 + ? Math.floor(rawRankChangeDays) + : 1 + const searchValue = + typeof search.search === "string" ? search.search.slice(0, 100) : "" + const tradeDate = + typeof search.tradeDate === "string" && isValidTradeDate(search.tradeDate) + ? search.tradeDate + : undefined + const sectorType = validatedSearchValue( + search.sectorType, + sectorTypes, + "concept", + ) + const view = validatedSearchValue( + search.view, + radarViews, + "amount", + ) + const rankChangeMetric = validatedSearchValue( + search.rankChangeMetric, + radarMetricKinds, + "amount", + ) + const side = validatedSearchValue( + search.side, + radarRankSides, + "all", + ) + return { + tradeDate, + sectorType, + view, + rankChangeMetric, + rankChangeDays, + side, + search: searchValue, + page, + pageSize, + } + }, + component: SectorRadarPage, +}) + const selectionRoute = createRoute({ getParentRoute: () => workspaceRoute, path: "/selection", @@ -72,8 +140,31 @@ const componentsRoute = createRoute({ export const routeTree = rootRoute.addChildren([ workspaceRoute.addChildren([ indexRoute, + sectorRadarRoute, selectionRoute, syncRoute, componentsRoute, ]), ]) + +function validatedSearchValue( + value: unknown, + allowed: readonly Value[], + fallback: Value, +): Value { + return typeof value === "string" && allowed.includes(value as Value) + ? (value as Value) + : fallback +} + +function isValidTradeDate(value: string) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) + if (!match) return false + const parsed = new Date(`${value}T00:00:00Z`) + return ( + !Number.isNaN(parsed.getTime()) && + parsed.getUTCFullYear() === Number(match[1]) && + parsed.getUTCMonth() + 1 === Number(match[2]) && + parsed.getUTCDate() === Number(match[3]) + ) +}