diff --git a/.trellis/tasks/09-05-selection-layout-sector-filter/prd.md b/.trellis/tasks/09-05-selection-layout-sector-filter/prd.md index 8d5001f..8511037 100644 --- a/.trellis/tasks/09-05-selection-layout-sector-filter/prd.md +++ b/.trellis/tasks/09-05-selection-layout-sector-filter/prd.md @@ -8,7 +8,8 @@ ## 关键决策 -- 板块口径:细分行业(sector_type=industry,即东财行业快照),selection 上下文固定为 industry——HTTP `/sectors` 不再接受 sector_type 参数,端口/适配器默认值均为 industry;「细分行业」仍是 sector 词汇的一种,内部参数名保持 `sector` 不变。 +- 板块口径:细分行业(sector_type=industry,即东财行业快照),selection 上下文固定为 industry——HTTP `/sectors` 不再接受 sector_type 参数,端口/适配器方法签名不再携带 sector_type,适配器固定传 `SectorType.INDUSTRY`;「细分行业」仍是 sector 词汇的一种,内部参数名保持 `sector` 不变。 +- 详情面板 membership 消费面收窄到 industries:前端 `sector-radar.api.ts` / 类型不再解析 concepts 字段(后端 `/sector-radar/stocks/{ts_code}/membership` 契约仍返回 concepts,供板块雷达等其余消费方使用)。 - 单选下拉;排序按 stock_count 倒序(后端保证),名称升序 tie-break。 - 行业数据不在 selection 表中,按 ADR 0001 通过端口委托 sector_radar 读服务(不跨上下文 join SQL)。 diff --git a/.trellis/tasks/09-06-capital-radar-daily-detail/check.jsonl b/.trellis/tasks/09-06-capital-radar-daily-detail/check.jsonl new file mode 100644 index 0000000..21b0814 --- /dev/null +++ b/.trellis/tasks/09-06-capital-radar-daily-detail/check.jsonl @@ -0,0 +1,6 @@ +{"file": ".trellis/spec/backend/market-data-sync.md", "reason": "Tushare 落库及同步契约"} +{"file": ".trellis/spec/backend/tushare-listed-stock-universe.md", "reason": "当前上市母集约束"} +{"file": ".trellis/spec/backend/http-api-contracts.md", "reason": "详情与排名API"} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "弹窗与榜单组件"} +{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "按日期的查询状态"} +{"file": ".trellis/tasks/09-06-capital-radar-daily-detail/design.md", "reason": "本次数据及交互设计"} diff --git a/.trellis/tasks/09-06-capital-radar-daily-detail/design.md b/.trellis/tasks/09-06-capital-radar-daily-detail/design.md new file mode 100644 index 0000000..15ef677 --- /dev/null +++ b/.trellis/tasks/09-06-capital-radar-daily-detail/design.md @@ -0,0 +1,21 @@ +# 资金雷达单日详情设计 + +## 数据边界 +继续使用 sector_radar bounded context。Tushare 原始响应先进入现有 snapshot 持久化链路,再扩展事实与查询投影;浏览器只访问同源 API,不直接请求 Tushare 或参考站点。扩展板块日事实保存 pct_change 与领涨标识,股票事实保存 daily.pct_chg 和独立 active_buy_net_amount_yuan。新字段可空,旧发布保持可读。moneyflow 作为详情可选来源,不将其失败当成主力净额为零;与原始 snapshot、发布日期和输入版本关联。 + +## 查询契约 +rankings 在原字段上增加 pct_change、daily_net_amount_yuan、daily_ratio 和对应侧的 30 日在榜次数;领域 percentile/coverage 保留,仅移除两种视角的展示列。避免逐板块查询历史,按日期和类型批量加载历史排名,按每日池的 TOP/BOTTOM 阈值计数。 + +新增板块 history 与 detail 读取接口,键包含 sector_type、sector_code、trade_date;返回解析后的 publication 标识和实际日期。history 含日期、三指标排名、当日池规模/百分位和缺失状态,严格截至目标日,最多 30 个交易日;同日选最新成功发布,历史版本不兼容时不混用。详情包含当日指标摘要、成员三种强弱数据、成员列表和相似板块。共享历史读取实现,避免重复计算。 + +## 指标 +沿用现有主力净额/成交额流入率及波段策略。板块涨跌幅优先使用 dc_index 源字段;成员 daily.pct_chg 独立于资金流是否缺失。主买净额来自 moneyflow.net_mf_amount,须核对官方单位后转为元,与主力 net_amount 分开。重合度为交集数量/并集数量,使用同日可确认的当前上市母集成员;未知成员不参与,空并集不计算。同分稳定按代码排列。 + +## 前端 +将两种单日榜列配置与其他视角隔离;拆分历史格子和详情弹窗组件,沿用项目 UI primitive 和 ECharts。详情请求按需触发,query key 包含类型/代码/目标日,切换日期时不显示前一天的详情。格子按日期从近到远,曲线按时间从早到晚且排名 1 在上方,缺失处断线。导出及复制仅使用 API 返回成员,生成本地 CSV/文本,CSV 对不可信字段进行转义与公式注入防护。 + +## 兼容与回滚 +采用增量 nullable 数据迁移,不删除原字段。旧发布缺详情时显式显示暂无数据;可通过重新构建目标日补齐,不在读取路径触发网络取数。新增来源失败保留最近有效发布并体现详情缺失。真实自测使用隔离的本地数据库或受控测试批次,不覆盖完整发布。若迁移/验证失败,停止新构建并保留原始快照,回退代码前确认旧代码可读取新增 nullable 列。 + +## 实施前核验 +确认 moneyflow 官方单位、当前采集权限和现有 source 失败策略;确定具体 migration 编号与可复用 UI primitive。原站私有算法不构成本次完成条件。真实全池历史不足仅报告覆盖,不人为补齐。 diff --git a/.trellis/tasks/09-06-capital-radar-daily-detail/implement.jsonl b/.trellis/tasks/09-06-capital-radar-daily-detail/implement.jsonl new file mode 100644 index 0000000..21b0814 --- /dev/null +++ b/.trellis/tasks/09-06-capital-radar-daily-detail/implement.jsonl @@ -0,0 +1,6 @@ +{"file": ".trellis/spec/backend/market-data-sync.md", "reason": "Tushare 落库及同步契约"} +{"file": ".trellis/spec/backend/tushare-listed-stock-universe.md", "reason": "当前上市母集约束"} +{"file": ".trellis/spec/backend/http-api-contracts.md", "reason": "详情与排名API"} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "弹窗与榜单组件"} +{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "按日期的查询状态"} +{"file": ".trellis/tasks/09-06-capital-radar-daily-detail/design.md", "reason": "本次数据及交互设计"} diff --git a/.trellis/tasks/09-06-capital-radar-daily-detail/implement.md b/.trellis/tasks/09-06-capital-radar-daily-detail/implement.md new file mode 100644 index 0000000..639c794 --- /dev/null +++ b/.trellis/tasks/09-06-capital-radar-daily-detail/implement.md @@ -0,0 +1,17 @@ +# 实施与验证计划 + +- [x] 用户审核当前规划后,加载 Phase 1.3/1.4,校验上下文清单并进入 in_progress。 +- [x] 读取受影响层规范和确切修改代码,确认 Tushare moneyflow 字段单位、权限及本地隔离数据库方案。 +- [x] 扩展 source、事实和 PostgreSQL migration,保存板块涨跌幅与成分行情/主买净额,保持旧发布可读;更新 memory repository。 +- [x] 增加批量历史/详情读取和 HTTP 响应,补充单日榜附加字段、在榜次数、三指标历史、前后5名、成员及重合度。 +- [x] 调整前端 API/types/query,完成两种单日列、名称入口、在榜展开、详情弹窗与复制/导出。 +- [x] 后端测试重点:单位转换、来源缺失不归零、同日发布去重、30交易日边界、无未来数据、每天排名池变化、相似度与历史成员;运行 `cd zhixing-server && uv run pytest tests/unit/sector_radar tests/test_sector_radar_http.py`,有数据库时补集成测试 `tests/integration/test_sector_radar_repository.py`。 +- [x] 前端测试重点:视角切换列名、左右板块指标、展开收起、日期切换、详情指标切换、少量样本、关闭/焦点和导出内容;运行 `cd zhixing-web && pnpm test src/features/sector-radar`。 +- [x] 使用一个真实板块完成 Tushare 采集、落库、API核对,记录请求日期、条数、缺失及核对值,不记录密钥;不得将单板块测试发布为全市场榜单。 +- [x] 后端运行 `uv run ruff format --check .`、`uv run ruff check .`、`uv run pyright`;前端运行 `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm build`。按变更范围扩大回归,不重复无关检查。 +- [x] 浏览器验证本地两个面板与弹窗、滚动及窄屏。核对 API 无请求时取 Tushare 的行为。 +- [x] 主会话完成最终验证并汇报实际证据;不自动提交用户改动,不更新全局知识或 specs。 + +风险位置:sector_radar/application/build.py 的成功发布门、infrastructure/postgres.py 的快照/事实事务与新迁移、application/read.py 的日期回退。新增可选详情来源不应改变旧榜成功条件。 + +执行结果与全局既有检查例外详见 verification.md;勾选代表本步骤已执行,不表示全仓检查全部通过。 diff --git a/.trellis/tasks/09-06-capital-radar-daily-detail/prd.md b/.trellis/tasks/09-06-capital-radar-daily-detail/prd.md new file mode 100644 index 0000000..ef149cd --- /dev/null +++ b/.trellis/tasks/09-06-capital-radar-daily-detail/prd.md @@ -0,0 +1,23 @@ +# 资金雷达:单日榜单与板块详情 + +## 目标与边界 +尽可能复刻 OneChartLab 单日榜单和截图中的板块详情交互,支持解释当日资金强弱与历史持续性。所有业务数据通过 Tushare 采集落库,再由本地分析和 API 提供;参考站点仅用于交互研究。保留现有独立指标,不反演或新增原站未公开的加权评分,不改动波段榜及排名变化榜的算法,不提交已有用户改动。 + +## 已确认背景 +当前排名接口没有涨跌幅及附加指标,见 `zhixing-server/src/zhixing_server/modules/sector_radar/presentation/http.py:83`。归一化事实只存成交额和主力净额,见同模块 `domain/persistence.py:101`。但采集已请求 `dc_index.pct_change/leading_code` 和 `daily.pct_chg`,见同模块 `infrastructure/tushare.py:40`。现有 3—10 日聚合及三指标排名可复用,30 日详情接口和前端交互尚未实现。 + +## 需求与验收 +R1:单日流入率移除排名百分位和样本列,增加涨跌幅、净额、在榜;板块仅显示名称。按用户截图将“净值”解释为主力净额,金额以亿元显示。左右榜字段镜像排列,保留现有独立流入率排序。验收检查列名、数据、正负颜色和名称点击。 + +R2:单日净额移除排名百分位和样本列,增加涨跌幅、单日流入率;同样只展示板块名称。验收核对附加指标属于同一板块、同一交易日和同一发布版本。 + +R3:在榜为截至所选日近 30 个交易日内进入对应 TOP/BOTTOM 10% 的次数,不是连续天数。点击展开日期和当日排名格子,支持收起;分别用每日同类型排名池确定强弱。缺失记录显示缺失,不补零,不借用未来数据;不足 30 日标明覆盖范围。 + +R4:点击板块打开可滚动详情弹窗,包含名称、类型、代码、日期、领涨股、当日涨跌幅、波段/单日率/单日额排名摘要;三指标近 30 交易日轨迹可切换,悬停显示日期和排名;成分强弱支持涨跌幅、主力净额、主买净额和前后 5 名;提供相似板块、复制及导出成员。少于 5 个有效成员按实际数量展示;缺失字段不伪装为 0。弹窗支持关闭按钮、Escape、焦点返回和加载/失败/空状态。 + +R5:优先使用落库的 dc_index 板块涨跌幅,不以成分平均值静默替代。成分涨跌幅来自 daily,主力与主买净额分别保存。相似度使用同日成员集合 Jaccard 重合度,明确为本系统口径;最多显示四个非自身板块,允许概念与行业交叉比较。 + +R6:真实 Tushare 单板块自测先采集落库,再通过 API 读取并核对金额、涨跌幅和成员。单板块数据不能验证全市场排名或相似度,不得发布为完整排名池;全池排名/历史边界使用受控数据验证。缺权限或历史数据不足时记录实际限制,不伪造通过。 + +## 验证与风险 +后端验证单位、成员日期、历史截止日、独立发布版本、缺失和在榜计数;前端验证两面板、展开、弹窗、切换和导出交互。真实调用前核实环境与权限,密钥不进入日志。新增详情来源失败不得让已有完整榜单丢失;旧发布缺字段可读且明确为空。当前仍处规划阶段,尚未进行产品修改或真实采集。 diff --git a/.trellis/tasks/09-06-capital-radar-daily-detail/research/collect_selftest.py b/.trellis/tasks/09-06-capital-radar-daily-detail/research/collect_selftest.py new file mode 100644 index 0000000..1ed1de0 --- /dev/null +++ b/.trellis/tasks/09-06-capital-radar-daily-detail/research/collect_selftest.py @@ -0,0 +1,62 @@ +"""Collect one real sector into an isolated PostgreSQL snapshot store. + +Run with the server's uv environment from zhixing-server. No production +publication is created; facts must be read back from this store for validation. +""" +from datetime import date +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit +import json +import time +import tushare as ts +from zhixing_server.bootstrap.config import Settings +from zhixing_server.modules.sector_radar.domain.source import build_source_snapshot +from zhixing_server.modules.sector_radar.infrastructure.postgres import PostgresSectorRadarRepository + + +def main(): + """Persist provider responses before inspecting their values; redact errors.""" + settings = Settings(_env_file='../.env') + url = urlsplit(settings.database_url) + host = url.netloc.rsplit('@', 1)[0] + '@127.0.0.1:5433' + database = urlunsplit((url.scheme, host, '/radar_detail_selftest_0906', url.query, '')) + repository = PostgresSectorRadarRepository(database, max_connections=2) + client = ts.pro_api(settings.tushare_token) + target = date(2026, 9, 4) + counts = [] + + def collect(api, fields, **params): + """Store each raw response atomically and return persisted rows.""" + time.sleep(0.25) + frame = client.query(api, fields=fields, **params) + snapshot = build_source_snapshot(api_name=api, params=params, + rows=frame.to_dict('records'), target_trade_date=target) + repository.save_source_snapshots((snapshot,)) + counts.append({'api':api, 'rows':snapshot.row_count, 'snapshot':snapshot.snapshot_id}) + return snapshot.rows + + try: + collect('dc_index','ts_code,trade_date,name,idx_type,level,pct_change,leading_code', + trade_date='20260904', ts_code='BK1147.DC') + members = collect('dc_member','trade_date,ts_code,con_code,name', + trade_date='20260904', ts_code='BK1147.DC') + collect('stock_basic','ts_code,symbol,name,market,exchange,list_status,list_date,delist_date',list_status='L') + collect('suspend_d','ts_code,trade_date,suspend_timing,suspend_type',trade_date='20260904') + collect('trade_cal','exchange,cal_date,is_open,pretrade_date',exchange='SSE',start_date='20260720',end_date='20260904') + for member in members: + code = member['con_code'] + collect('daily','ts_code,trade_date,close,pre_close,pct_chg,vol,amount',trade_date='20260904',ts_code=code) + collect('moneyflow_dc','trade_date,ts_code,name,net_amount,net_amount_rate,pct_change,close',trade_date='20260904',ts_code=code) + collect('moneyflow','trade_date,ts_code,net_mf_amount',trade_date='20260904',ts_code=code) + result={'status':'collected','sector':'BK1147.DC','trade_date':str(target),'members':len(members),'snapshots':counts} + except Exception as exc: + result={'status':'partial','snapshots':counts,'error_type':type(exc).__name__, + 'message':str(exc).replace(settings.tushare_token,'[redacted]')[:300]} + finally: + repository.close() + Path('../.trellis/tasks/09-06-capital-radar-daily-detail/research/collection-result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)) + print(json.dumps({k:v for k,v in result.items() if k!='snapshots'},ensure_ascii=False)) + print('Stored snapshots:',len(counts)) + +if __name__ == '__main__': + main() diff --git a/.trellis/tasks/09-06-capital-radar-daily-detail/research/collection-result.json b/.trellis/tasks/09-06-capital-radar-daily-detail/research/collection-result.json new file mode 100644 index 0000000..b207c03 --- /dev/null +++ b/.trellis/tasks/09-06-capital-radar-daily-detail/research/collection-result.json @@ -0,0 +1,243 @@ +{ + "status": "collected", + "sector": "BK1147.DC", + "trade_date": "2026-09-04", + "members": 14, + "snapshots": [ + { + "api": "dc_index", + "rows": 1, + "snapshot": "73f94be4b5c94238b611396b7cdf2e314f17cdd23532c3773febf3e3277f1674" + }, + { + "api": "dc_member", + "rows": 14, + "snapshot": "2046df68966d3eda9a392b0254b84576842c42c487ea0a2f1044b6cc75de3afa" + }, + { + "api": "stock_basic", + "rows": 5556, + "snapshot": "1c87ce65e081e670add1c1167473f798e97e8c9cf41db3e4ac71df0e3de5bb90" + }, + { + "api": "suspend_d", + "rows": 8, + "snapshot": "b91d0981345538463407939a7ed863b8fb8de5e447b8ec731b8767b7ae13aa5d" + }, + { + "api": "trade_cal", + "rows": 47, + "snapshot": "afff3cba955380cc0af9cdaac1d8861c17a70b089c70ea95aadb1cc568f8a26e" + }, + { + "api": "daily", + "rows": 1, + "snapshot": "4f2c4f92685180b3de23a26c9d0602a8acf3fedb7dbc2413ac92bda76d8fa65a" + }, + { + "api": "moneyflow_dc", + "rows": 1, + "snapshot": "7c1907d19c2db8134a807edba87fe165b44a1e96d6b3731e65a26f52715f6f12" + }, + { + "api": "moneyflow", + "rows": 1, + "snapshot": "521254cf598c28608571973eeec1e52c56c856ee547bbd2d9ceacc4e1a29727e" + }, + { + "api": "daily", + "rows": 1, + "snapshot": "9bf1ca69c3be480113c933c25681dab1379e0a068bdf9a31452794de8c4bff94" + }, + { + "api": "moneyflow_dc", + "rows": 1, + "snapshot": "8b81006f266edabf8ac1e2aa292e9b278ca9131eea47f86fbef89373197215f6" + }, + { + "api": "moneyflow", + "rows": 1, + "snapshot": "e90138a791bf7e3d458d3865838ac3aee394fe491ebf93cb363425852d1bca92" + }, + { + "api": "daily", + "rows": 1, + "snapshot": "81ca417096a073698375daa03f0a660398d53c3943b16c9598a8faa918ff177f" + }, + { + "api": "moneyflow_dc", + "rows": 1, + "snapshot": "40fb4ad0d2bbe8413e6dfcc213e8efe7f3367f84f492f68e430f1a9d08e9cc51" + }, + { + "api": "moneyflow", + "rows": 1, + "snapshot": "47d32d618d5d8a2118253b1e3d7b354628bae22a7b10880f4cfdd3ee370dae22" + }, + { + "api": "daily", + "rows": 1, + "snapshot": "2418e65c6800ef5633e6f260cbe617eb3b5bbda4d71e16e56589cd1603827b6b" + }, + { + "api": "moneyflow_dc", + "rows": 1, + "snapshot": "d3559b1413d931dfe882fa64f501b75c76c91125fa0fbe616b385e9a3b7c9e15" + }, + { + "api": "moneyflow", + "rows": 1, + "snapshot": "76a6e0cbe16e92c707af828a9936d87bd810844c357256c18e4d9ef10d86e40d" + }, + { + "api": "daily", + "rows": 1, + "snapshot": "a3307443f9e89ff5523043055509dc309c6766067a5d9eb83bdef5d19e6e44c1" + }, + { + "api": "moneyflow_dc", + "rows": 1, + "snapshot": "8d4c401bf8ade3dae6c0eab35069d59e61d4c6383b190e21a420d7735d0ab5a2" + }, + { + "api": "moneyflow", + "rows": 1, + "snapshot": "ee3a90e0e406940f60358edfa3688f8774687bc768c4e9c069ca6d8337a39bcc" + }, + { + "api": "daily", + "rows": 1, + "snapshot": "bf21400564f20c771905d46be0dd225eb9d0579393ab5734038ba93db63042f4" + }, + { + "api": "moneyflow_dc", + "rows": 1, + "snapshot": "66611bdd15e87cd5ccec0f48d7e94282cedb210f0393a313519b6896abf0de1f" + }, + { + "api": "moneyflow", + "rows": 1, + "snapshot": "eb98e9d138478b7ec2ae57557a1e61d34f825fd9faf8f30ef530c4d54f5e391e" + }, + { + "api": "daily", + "rows": 1, + "snapshot": "550627fe30dccb760e4dbc73dd0ecee85676f781dac1ba059483e7d83b701091" + }, + { + "api": "moneyflow_dc", + "rows": 1, + "snapshot": "5d1e6b1222b37896ad79d759e8fb8a240fb438bf6d50eda687c56181648c3a12" + }, + { + "api": "moneyflow", + "rows": 1, + "snapshot": "7bc22506f40cd9c550a48699c93bee28f00c8717e304cd26761b33d9e66dbf35" + }, + { + "api": "daily", + "rows": 1, + "snapshot": "39dda855afcfcf4e32ccd67e4cd28942f8eed13a171b82fa438000bfcff87252" + }, + { + "api": "moneyflow_dc", + "rows": 1, + "snapshot": "b9042adf9d7c2823a7155691c6c349f451b6d84fe7eb2ca83ab749a51eaa6c6e" + }, + { + "api": "moneyflow", + "rows": 1, + "snapshot": "aed26a777f4d4eae9236b677d6ed7180a093aa49076eeee3b796325db3ce2e9f" + }, + { + "api": "daily", + "rows": 1, + "snapshot": "26d7d7beb26a8318f9b1104b5bc7446562663bba3e7142d9957562f5dce16c64" + }, + { + "api": "moneyflow_dc", + "rows": 1, + "snapshot": "560d0e8e0a1d1220acab7a4d22ba6aadb4d47555344f75bbdbaf7650d62d95bf" + }, + { + "api": "moneyflow", + "rows": 1, + "snapshot": "7f6759aea68eb193c271c6f8230de9237bcd25bb445eef786adc5b39d5527e08" + }, + { + "api": "daily", + "rows": 1, + "snapshot": "467d8fa44adf1aeecb81bb970a3239db4f48c75ec680bd802310ba0a40b9a92f" + }, + { + "api": "moneyflow_dc", + "rows": 1, + "snapshot": "cc80509ebf2c47e85cb1eebe0735db7c84facd01eba73212db218fbabcf6c980" + }, + { + "api": "moneyflow", + "rows": 1, + "snapshot": "7f145408f62626e955b83617821e4c41ce0af3353533de790de22211f02ba36c" + }, + { + "api": "daily", + "rows": 1, + "snapshot": "e515db0257399b1433fc14c4062a020a4837dfe6ff968cd70ab2b60f1035794e" + }, + { + "api": "moneyflow_dc", + "rows": 1, + "snapshot": "31aa693eb092cf03a7b1e0da65ffd46e79389831f93809e6738789bfa6e8d619" + }, + { + "api": "moneyflow", + "rows": 1, + "snapshot": "b4073fdbd1e5f73c52d64aae07ea3dd8a9613f12cdea316abcbbaf4bdff6f28e" + }, + { + "api": "daily", + "rows": 1, + "snapshot": "ffd0d7d09fee0dc7e5a035c9131220042563a8d5c852ff5ca8d0a4d8b7ba8004" + }, + { + "api": "moneyflow_dc", + "rows": 1, + "snapshot": "7e9bff54c0b00afc12bbcc7a98dfcd7e504ce780e47ac7a3c3443e989d9da031" + }, + { + "api": "moneyflow", + "rows": 1, + "snapshot": "fd045165e72b1127df96bd73487a99d2b561c641783a0dfa3b0ed8b470e4abda" + }, + { + "api": "daily", + "rows": 1, + "snapshot": "30ccd5069b7a8c361cc5408ecb9e0013f36d314cf5dde4c6d79b9e10f8942315" + }, + { + "api": "moneyflow_dc", + "rows": 1, + "snapshot": "bbcc7b25b1e2d51487dcb76b28404966db7e83c9153af8f3119e1653f20f8618" + }, + { + "api": "moneyflow", + "rows": 1, + "snapshot": "c210e7f840c5baecc94d6fd333f73fd98b994b65575f2ce38faed428e59b8f9f" + }, + { + "api": "daily", + "rows": 1, + "snapshot": "bbb69dfcf4862e214b3a47265ba3607476695cb9147eed3d9f8199a0e7501ff1" + }, + { + "api": "moneyflow_dc", + "rows": 1, + "snapshot": "fce61a39b30de56dc3b898f6d8e4bf156504bedc001c5c85abfceaecbb490f36" + }, + { + "api": "moneyflow", + "rows": 1, + "snapshot": "2e0739949d25df4a56631c761e064121ff8f99ead4e95306c1c1f1bfa44111ae" + } + ] +} \ No newline at end of file diff --git a/.trellis/tasks/09-06-capital-radar-daily-detail/research/persisted-baseline.json b/.trellis/tasks/09-06-capital-radar-daily-detail/research/persisted-baseline.json new file mode 100644 index 0000000..dbdcd35 --- /dev/null +++ b/.trellis/tasks/09-06-capital-radar-daily-detail/research/persisted-baseline.json @@ -0,0 +1,20 @@ +{ + "trade_date": "2026-09-04", + "sector": "BK1147.DC", + "members": 14, + "pct_change": 1.82, + "main_net_yuan": "485456500.00", + "turnover_yuan": "2615472881.48000", + "daily_ratio": "0.1856094564915916866216729052", + "active_net_yuan": "-255455700.00", + "rows_by_api": { + "dc_index": 1, + "dc_member": 14, + "stock_basic": 5556, + "suspend_d": 8, + "trade_cal": 47, + "daily": 14, + "moneyflow_dc": 14, + "moneyflow": 14 + } +} \ No newline at end of file diff --git a/.trellis/tasks/09-06-capital-radar-daily-detail/research/replay_selftest.py b/.trellis/tasks/09-06-capital-radar-daily-detail/research/replay_selftest.py new file mode 100644 index 0000000..2b43434 --- /dev/null +++ b/.trellis/tasks/09-06-capital-radar-daily-detail/research/replay_selftest.py @@ -0,0 +1,77 @@ +"""Replay the persisted real SPD sample in an isolated database, with no network. + +The one-sector publication is exclusively a local integration fixture; its ranks +must never be interpreted as a market-wide ranking. +""" +import os +import json +from datetime import date +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit +import psycopg +from alembic import command +from alembic.config import Config +from zhixing_server.bootstrap.config import Settings, get_settings +from zhixing_server.modules.sector_radar.application.build import BuildSectorRadar, BuildSectorRadarCommand +from zhixing_server.modules.sector_radar.domain.models import SectorType +from zhixing_server.modules.sector_radar.domain.source import ( + SourceSnapshot, SourceResult, TradeCalendarRow, SectorIndexRow, SectorMemberRow, + StockBasicRow, SuspendRow, DailyRow, MoneyflowDcRow, MoneyflowRow, build_source_snapshot, +) +from zhixing_server.modules.sector_radar.infrastructure.postgres import PostgresSectorRadarRepository + + +def database_url(): + """Resolve only the named localhost fixture database without printing secrets.""" + s=Settings(_env_file='../.env'); u=urlsplit(s.database_url) + return urlunsplit((u.scheme,u.netloc.rsplit('@',1)[0]+'@127.0.0.1:5433','/radar_detail_selftest_0906',u.query,'')) + + +class StoredSource: + """Implement provider port using already committed raw snapshots only.""" + def __init__(self, dsn): + self.by_api={} + with psycopg.connect(dsn) as c: + for r in c.execute('SELECT id,api_name,normalized_params,target_trade_date,partition_key,observed_at,payload,row_count,returned_fields,content_sha256,row_limit,limit_reached FROM sector_radar_source_snapshot').fetchall(): + snap=SourceSnapshot(r[0],r[1],tuple(sorted(r[2].items())),r[3],r[4],r[5],tuple(r[6]),r[7],tuple(r[8]),r[9],r[10],r[11]) + self.by_api.setdefault(r[1],[]).append(snap) + + def result(self, api, parser): + snaps=tuple(self.by_api[api]) + return SourceResult(snaps,tuple(parser(row) for s in snaps for row in s.rows)) + + def fetch_trade_calendar(self,start,end): + return self.result('trade_cal',TradeCalendarRow.from_mapping) + def fetch_sector_indices(self,target,kind): + if kind is SectorType.INDUSTRY: + # Explicitly empty industry scope for this one-concept test fixture. + snap=build_source_snapshot(api_name='dc_index',params={'test_scope':'empty_industry'},rows=(),target_trade_date=target) + return SourceResult((snap,),()) + return self.result('dc_index',lambda row:SectorIndexRow.from_mapping(row,kind)) + def fetch_sector_members(self,target,codes): + return self.result('dc_member',SectorMemberRow.from_mapping) + def fetch_stock_basics(self): + return self.result('stock_basic',StockBasicRow.from_mapping) + def fetch_suspensions(self,target): + return self.result('suspend_d',SuspendRow.from_mapping) + def fetch_daily(self,target): + return self.result('daily',DailyRow.from_mapping) + def fetch_moneyflow_dc(self,target,codes): + return self.result('moneyflow_dc',MoneyflowDcRow.from_mapping) + def fetch_moneyflow(self,target): + return self.result('moneyflow',MoneyflowRow.from_mapping) + + +def main(): + """Run actual build and HTTP reads, checking independently recomputed facts.""" + dsn=database_url() + os.environ['ZHIXING_DATABASE_URL']=dsn + get_settings.cache_clear() + command.upgrade(Config('alembic.ini'),'head') + repository=PostgresSectorRadarRepository(dsn,max_connections=2) + result=BuildSectorRadar(StoredSource(dsn),repository,today=date(2026,9,6)).execute(BuildSectorRadarCommand(trade_date=date(2026,9,4))) + print(json.dumps(result.as_dict(),ensure_ascii=False)) + repository.close() + +if __name__=='__main__': + main() diff --git a/.trellis/tasks/09-06-capital-radar-daily-detail/research/verified-detail.json b/.trellis/tasks/09-06-capital-radar-daily-detail/research/verified-detail.json new file mode 100644 index 0000000..3c0b000 --- /dev/null +++ b/.trellis/tasks/09-06-capital-radar-daily-detail/research/verified-detail.json @@ -0,0 +1,1349 @@ +{ + "status": "success", + "requested_trade_date": "2026-09-04", + "trade_date": "2026-09-04", + "publication": { + "publication_id": "radar-20260904-running-e5b32e9769f74c439ebb6ae", + "target_trade_date": "2026-09-04", + "status": "success", + "source_version": "tushare-pro-v1", + "universe_version": "eastmoney-dc-dfa0eb4e7f8445dbc0126d570480479e", + "metric_versions": [ + "zhixing_amount_net_bn_v1", + "zhixing_ratio_turnover_v1", + "zhixing_swing_equal_3_10_v1" + ], + "input_hash": "dcbc48b6b64df695e91ddfa28ee1a5d201429d8c51de21b2df975d43eb9839a5", + "coverage": "1.000000", + "started_at": "2026-09-06T03:10:47.411490Z", + "finished_at": "2026-09-06T03:10:47.527281Z", + "error_summary": null + }, + "sector_type": "concept", + "sector_code": "BK1147.DC", + "sector_name": "SPD概念", + "pct_change": "1.82", + "leading_stock": { + "ts_code": "603122.SH", + "name": "合富中国" + }, + "summary": { + "amount": { + "rank_position": 1, + "rank_percentile": "100.000000000000", + "pool_size": 1, + "metric_value": "4.854565000000", + "missing": false, + "in_top": true, + "in_bottom": false + }, + "ratio": { + "rank_position": 1, + "rank_percentile": "100.000000000000", + "pool_size": 1, + "metric_value": "0.185609456492", + "missing": false, + "in_top": true, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + "history": { + "status": "success", + "requested_trade_date": "2026-09-04", + "trade_date": "2026-09-04", + "publication": { + "publication_id": "radar-20260904-running-e5b32e9769f74c439ebb6ae", + "target_trade_date": "2026-09-04", + "status": "success", + "source_version": "tushare-pro-v1", + "universe_version": "eastmoney-dc-dfa0eb4e7f8445dbc0126d570480479e", + "metric_versions": [ + "zhixing_amount_net_bn_v1", + "zhixing_ratio_turnover_v1", + "zhixing_swing_equal_3_10_v1" + ], + "input_hash": "dcbc48b6b64df695e91ddfa28ee1a5d201429d8c51de21b2df975d43eb9839a5", + "coverage": "1.000000", + "started_at": "2026-09-06T03:10:47.411490Z", + "finished_at": "2026-09-06T03:10:47.527281Z", + "error_summary": null + }, + "sector_type": "concept", + "sector_code": "BK1147.DC", + "sector_name": "SPD概念", + "points": [ + { + "trade_date": "2026-07-27", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-07-28", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-07-29", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-07-30", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-07-31", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-03", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-04", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-05", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-06", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-07", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-10", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-11", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-12", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-13", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-14", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-17", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-18", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-19", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-20", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-21", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-24", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-25", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-26", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-27", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-28", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-08-31", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-09-01", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-09-02", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-09-03", + "publication_id": null, + "amount": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "ratio": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + }, + { + "trade_date": "2026-09-04", + "publication_id": "radar-20260904-running-e5b32e9769f74c439ebb6ae", + "amount": { + "rank_position": 1, + "rank_percentile": "100.000000000000", + "pool_size": 1, + "metric_value": "4.854565000000", + "missing": false, + "in_top": true, + "in_bottom": false + }, + "ratio": { + "rank_position": 1, + "rank_percentile": "100.000000000000", + "pool_size": 1, + "metric_value": "0.185609456492", + "missing": false, + "in_top": true, + "in_bottom": false + }, + "swing": { + "rank_position": null, + "rank_percentile": null, + "pool_size": 0, + "metric_value": null, + "missing": true, + "in_top": false, + "in_bottom": false + } + } + ], + "window_size": 30, + "available_days": 1 + }, + "members": [ + { + "ts_code": "000028.SZ", + "name": "国药一致", + "pct_change": "0.6983", + "net_amount_yuan": "276300.00", + "active_buy_net_amount_yuan": "9302800.00" + }, + { + "ts_code": "000411.SZ", + "name": "英特集团", + "pct_change": "1.7928", + "net_amount_yuan": "2730500.00", + "active_buy_net_amount_yuan": "5216100.00" + }, + { + "ts_code": "000503.SZ", + "name": "国新健康", + "pct_change": "0.0", + "net_amount_yuan": "-10278700.00", + "active_buy_net_amount_yuan": "-923100.00" + }, + { + "ts_code": "000705.SZ", + "name": "浙江震元", + "pct_change": "1.5493", + "net_amount_yuan": "-6443600.00", + "active_buy_net_amount_yuan": "5217900.00" + }, + { + "ts_code": "000950.SZ", + "name": "重药控股", + "pct_change": "0.0", + "net_amount_yuan": "-7475800.00", + "active_buy_net_amount_yuan": "-8130100.00" + }, + { + "ts_code": "300026.SZ", + "name": "红日药业", + "pct_change": "-0.3021", + "net_amount_yuan": "3119300.00", + "active_buy_net_amount_yuan": "-12869500.00" + }, + { + "ts_code": "301370.SZ", + "name": "国科恒泰", + "pct_change": "0.0", + "net_amount_yuan": "201200.00", + "active_buy_net_amount_yuan": "2490600.00" + }, + { + "ts_code": "301408.SZ", + "name": "华人健康", + "pct_change": "-0.2079", + "net_amount_yuan": "-6544400.00", + "active_buy_net_amount_yuan": "1920300.00" + }, + { + "ts_code": "301584.SZ", + "name": "建发致新", + "pct_change": "-0.3352", + "net_amount_yuan": "-2647600.00", + "active_buy_net_amount_yuan": "-1211700.00" + }, + { + "ts_code": "600272.SH", + "name": "开开实业", + "pct_change": "7.6593", + "net_amount_yuan": "333185500.00", + "active_buy_net_amount_yuan": "-269292400.00" + }, + { + "ts_code": "601607.SH", + "name": "上海医药", + "pct_change": "1.0949", + "net_amount_yuan": "870700.00", + "active_buy_net_amount_yuan": "20566300.00" + }, + { + "ts_code": "603108.SH", + "name": "润达医疗", + "pct_change": "0.7143", + "net_amount_yuan": "5360100.00", + "active_buy_net_amount_yuan": "12470500.00" + }, + { + "ts_code": "603122.SH", + "name": "合富中国", + "pct_change": "9.9757", + "net_amount_yuan": "154226200.00", + "active_buy_net_amount_yuan": "-35589700.00" + }, + { + "ts_code": "603716.SH", + "name": "塞力医疗", + "pct_change": "2.9097", + "net_amount_yuan": "18876800.00", + "active_buy_net_amount_yuan": "15376300.00" + } + ], + "leaders": { + "pct_change": { + "top": [ + { + "ts_code": "603122.SH", + "name": "合富中国", + "pct_change": "9.9757", + "net_amount_yuan": "154226200.00", + "active_buy_net_amount_yuan": "-35589700.00" + }, + { + "ts_code": "600272.SH", + "name": "开开实业", + "pct_change": "7.6593", + "net_amount_yuan": "333185500.00", + "active_buy_net_amount_yuan": "-269292400.00" + }, + { + "ts_code": "603716.SH", + "name": "塞力医疗", + "pct_change": "2.9097", + "net_amount_yuan": "18876800.00", + "active_buy_net_amount_yuan": "15376300.00" + }, + { + "ts_code": "000411.SZ", + "name": "英特集团", + "pct_change": "1.7928", + "net_amount_yuan": "2730500.00", + "active_buy_net_amount_yuan": "5216100.00" + }, + { + "ts_code": "000705.SZ", + "name": "浙江震元", + "pct_change": "1.5493", + "net_amount_yuan": "-6443600.00", + "active_buy_net_amount_yuan": "5217900.00" + } + ], + "bottom": [ + { + "ts_code": "301584.SZ", + "name": "建发致新", + "pct_change": "-0.3352", + "net_amount_yuan": "-2647600.00", + "active_buy_net_amount_yuan": "-1211700.00" + }, + { + "ts_code": "300026.SZ", + "name": "红日药业", + "pct_change": "-0.3021", + "net_amount_yuan": "3119300.00", + "active_buy_net_amount_yuan": "-12869500.00" + }, + { + "ts_code": "301408.SZ", + "name": "华人健康", + "pct_change": "-0.2079", + "net_amount_yuan": "-6544400.00", + "active_buy_net_amount_yuan": "1920300.00" + }, + { + "ts_code": "000503.SZ", + "name": "国新健康", + "pct_change": "0.0", + "net_amount_yuan": "-10278700.00", + "active_buy_net_amount_yuan": "-923100.00" + }, + { + "ts_code": "000950.SZ", + "name": "重药控股", + "pct_change": "0.0", + "net_amount_yuan": "-7475800.00", + "active_buy_net_amount_yuan": "-8130100.00" + } + ] + }, + "net_amount_yuan": { + "top": [ + { + "ts_code": "600272.SH", + "name": "开开实业", + "pct_change": "7.6593", + "net_amount_yuan": "333185500.00", + "active_buy_net_amount_yuan": "-269292400.00" + }, + { + "ts_code": "603122.SH", + "name": "合富中国", + "pct_change": "9.9757", + "net_amount_yuan": "154226200.00", + "active_buy_net_amount_yuan": "-35589700.00" + }, + { + "ts_code": "603716.SH", + "name": "塞力医疗", + "pct_change": "2.9097", + "net_amount_yuan": "18876800.00", + "active_buy_net_amount_yuan": "15376300.00" + }, + { + "ts_code": "603108.SH", + "name": "润达医疗", + "pct_change": "0.7143", + "net_amount_yuan": "5360100.00", + "active_buy_net_amount_yuan": "12470500.00" + }, + { + "ts_code": "300026.SZ", + "name": "红日药业", + "pct_change": "-0.3021", + "net_amount_yuan": "3119300.00", + "active_buy_net_amount_yuan": "-12869500.00" + } + ], + "bottom": [ + { + "ts_code": "000503.SZ", + "name": "国新健康", + "pct_change": "0.0", + "net_amount_yuan": "-10278700.00", + "active_buy_net_amount_yuan": "-923100.00" + }, + { + "ts_code": "000950.SZ", + "name": "重药控股", + "pct_change": "0.0", + "net_amount_yuan": "-7475800.00", + "active_buy_net_amount_yuan": "-8130100.00" + }, + { + "ts_code": "301408.SZ", + "name": "华人健康", + "pct_change": "-0.2079", + "net_amount_yuan": "-6544400.00", + "active_buy_net_amount_yuan": "1920300.00" + }, + { + "ts_code": "000705.SZ", + "name": "浙江震元", + "pct_change": "1.5493", + "net_amount_yuan": "-6443600.00", + "active_buy_net_amount_yuan": "5217900.00" + }, + { + "ts_code": "301584.SZ", + "name": "建发致新", + "pct_change": "-0.3352", + "net_amount_yuan": "-2647600.00", + "active_buy_net_amount_yuan": "-1211700.00" + } + ] + }, + "active_buy_net_amount_yuan": { + "top": [ + { + "ts_code": "601607.SH", + "name": "上海医药", + "pct_change": "1.0949", + "net_amount_yuan": "870700.00", + "active_buy_net_amount_yuan": "20566300.00" + }, + { + "ts_code": "603716.SH", + "name": "塞力医疗", + "pct_change": "2.9097", + "net_amount_yuan": "18876800.00", + "active_buy_net_amount_yuan": "15376300.00" + }, + { + "ts_code": "603108.SH", + "name": "润达医疗", + "pct_change": "0.7143", + "net_amount_yuan": "5360100.00", + "active_buy_net_amount_yuan": "12470500.00" + }, + { + "ts_code": "000028.SZ", + "name": "国药一致", + "pct_change": "0.6983", + "net_amount_yuan": "276300.00", + "active_buy_net_amount_yuan": "9302800.00" + }, + { + "ts_code": "000705.SZ", + "name": "浙江震元", + "pct_change": "1.5493", + "net_amount_yuan": "-6443600.00", + "active_buy_net_amount_yuan": "5217900.00" + } + ], + "bottom": [ + { + "ts_code": "600272.SH", + "name": "开开实业", + "pct_change": "7.6593", + "net_amount_yuan": "333185500.00", + "active_buy_net_amount_yuan": "-269292400.00" + }, + { + "ts_code": "603122.SH", + "name": "合富中国", + "pct_change": "9.9757", + "net_amount_yuan": "154226200.00", + "active_buy_net_amount_yuan": "-35589700.00" + }, + { + "ts_code": "300026.SZ", + "name": "红日药业", + "pct_change": "-0.3021", + "net_amount_yuan": "3119300.00", + "active_buy_net_amount_yuan": "-12869500.00" + }, + { + "ts_code": "000950.SZ", + "name": "重药控股", + "pct_change": "0.0", + "net_amount_yuan": "-7475800.00", + "active_buy_net_amount_yuan": "-8130100.00" + }, + { + "ts_code": "301584.SZ", + "name": "建发致新", + "pct_change": "-0.3352", + "net_amount_yuan": "-2647600.00", + "active_buy_net_amount_yuan": "-1211700.00" + } + ] + } + }, + "similar_sectors": [] +} \ No newline at end of file diff --git a/.trellis/tasks/09-06-capital-radar-daily-detail/research/verify_http.py b/.trellis/tasks/09-06-capital-radar-daily-detail/research/verify_http.py new file mode 100644 index 0000000..2d1dbde --- /dev/null +++ b/.trellis/tasks/09-06-capital-radar-daily-detail/research/verify_http.py @@ -0,0 +1,27 @@ +"""Verify API facts against independently calculated persisted real data.""" +import json +from decimal import Decimal +from pathlib import Path +from urllib.request import urlopen + +root='http://127.0.0.1:8016/api/v1/sector-radar/' +d=json.load(urlopen(root+'sectors/concept/BK1147.DC/detail?trade_date=2026-09-04')) +assert d['status']=='success' +assert len(d['members'])==14 +assert Decimal(d['pct_change'])==Decimal('1.82') +assert sum(Decimal(m['net_amount_yuan']) for m in d['members'])==Decimal('485456500') +assert sum(Decimal(m['active_buy_net_amount_yuan']) for m in d['members'])==Decimal('-255455700') +assert Decimal(d['summary']['amount']['metric_value'])==Decimal('4.854565') +# Storage uses 12 fractional digits for persisted metric observations. +assert abs(Decimal(d['summary']['ratio']['metric_value'])-Decimal('0.18560945649159168662'))0` 的 ts_code,调端口聚合;`get_latest`/`get` 结果查询在 `query.sector` 有值时先调端口取成员代码,空则直接返回空页,否则把代码数组传入仓储。 + - `infrastructure/postgres_runs.py`:`_stock_filter` 增加子句 `item.ts_code = ANY(%s)`(参数由应用层传入)。 + - `presentation/http.py`: + - 新端点 `GET /api/v1/selection/sectors?strategy=&target_trade_date=§or_type=` → `{ sector_type, snapshot_trade_date, sectors: [{sector_code, sector_name, stock_count}] }` + - `GET /results` 与 `GET /runs/{id}` 增加 `sector` query 参数校验(去空格、限长)。 + - 组合根(router/依赖装配处)把 sector_radar 的读服务适配为 selection 的端口注入。 +3. **测试**:`tests/unit/sector_radar/`(新仓储方法)、`tests/unit/selection/test_postgres_runs.py`(sector 过滤、ANY 数组、空成员空页)、`tests/test_selection_http.py`(新端点契约 + results 带 sector)。跑 `./dev.sh check`(ruff/pyright/pytest)。 + +## 二、前端(zhixing-web) + +1. **布局重构** `selection-results-workbench.tsx`: + - 外层改为 `flex flex-col`:顶部一个全宽 section 放搜索栏(关键词 / 信号分类 / **板块(新增)** / 排序 / 筛选结果计数),带 `rounded-md border bg-card` 与现有一致; + - 下方 `md:grid md:grid-cols-[320px_minmax(0,1fr)]` 左列表右详情;移动端纵向堆叠为 搜索 → 列表 → 详情。 +2. **类型与 API**(`selection.types.ts` / `selection.api.ts`):新增 `SelectionSectorAggregate`;`SelectionResultsQuery` 加 `sector?`;`getSelectionResultSectors()`;`buildSelectionQueryParams` 带 sector。 +3. **查询层**(`selection.query.ts`):`useSelectionResultSectors`(key:strategy+date,结果就绪后启用);`selectionResultsQueryKey`/`selectionRunQueryKey` 加入 sector 使筛选变化触发重新请求。 +4. **路由**(`routes/route-tree.tsx`):selectionRoute `validateSearch` 增加 `sector`(string,限长,默认 undefined)。 +5. **页面接线**(`selection-results-page.tsx`):把 URL 中的 sector 传入 `useSelectionResults`;页面层调用 `useSelectionResultSectors` 并把聚合结果传给 workbench。 +6. **workbench 板块下拉**:选项 = "全部板块" + 聚合数据,item 渲染 `名称 + 数量徽标`(数量 tabular-nums,倒序由后端保证);选择写 URL;聚合加载后若当前 sector 不在列表中(如切换策略/日期)自动重置为全部;板块无数据时下拉仅剩"全部板块"并禁用。 + +## 三、执行方式 + +- 按仓库 Trellis 工作流建任务目录并加载 `.trellis/spec/backend`(selection.md 契约:查询不触发重算、错误矩阵等)与前端规范后再动手;先后端(接口+测试)再前端接线,最后 `./dev.sh check` + 前端 lint/tsc 全量质量门禁。 +- 语义说明:板块数量 = 当次 run 全部选中股票中归属该板块的数量(不随关键词/信号分类变化);列表"筛选结果 N 只" = 包含板块在内的全部过滤叠加后的 `stocks_total`。 \ No newline at end of file diff --git a/output/playwright/radar-detail.png b/output/playwright/radar-detail.png new file mode 100644 index 0000000..cc4d536 Binary files /dev/null and b/output/playwright/radar-detail.png differ diff --git a/output/playwright/radar-ratio-history.png b/output/playwright/radar-ratio-history.png new file mode 100644 index 0000000..b026404 Binary files /dev/null and b/output/playwright/radar-ratio-history.png differ diff --git a/zhixing-server/.vite/deps/_metadata.json b/zhixing-server/.vite/deps/_metadata.json new file mode 100644 index 0000000..569c5d5 --- /dev/null +++ b/zhixing-server/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "be29b65c", + "configHash": "93a0ae7b", + "lockfileHash": "e3b0c442", + "browserHash": "4df63514", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/zhixing-server/.vite/deps/package.json b/zhixing-server/.vite/deps/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/zhixing-server/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/zhixing-server/migrations/versions/0008_radar_detail.py b/zhixing-server/migrations/versions/0008_radar_detail.py new file mode 100644 index 0000000..0383166 --- /dev/null +++ b/zhixing-server/migrations/versions/0008_radar_detail.py @@ -0,0 +1,38 @@ +"""Preserve independent stock detail facts without invalidating old publications.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0008_radar_detail" +down_revision: str | None = "0007_selection_pattern_scoring" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add nullable facts and allow the optional raw moneyflow checkpoint.""" + for name in ("pct_change", "active_buy_net_amount_yuan"): + op.add_column("sector_radar_stock_fact", sa.Column(name, sa.Numeric(), nullable=True)) + op.create_check_constraint( + f"ck_radar_stock_{name}_finite", + "sector_radar_stock_fact", + f"{name} IS NULL OR {name} NOT IN " + "('NaN'::numeric, 'Infinity'::numeric, '-Infinity'::numeric)", + ) + op.drop_constraint( + "ck_sector_radar_publication_source_group", "sector_radar_publication_source" + ) + op.create_check_constraint( + "ck_sector_radar_publication_source_group", + "sector_radar_publication_source", + "source_group IN ('calendar', 'concept_indices', 'industry_indices', 'members', " + "'stock_basics', 'suspensions', 'daily', 'moneyflow_dc', 'moneyflow')", + ) + + +def downgrade() -> None: + """Drop added fact columns; retain optional checkpoint audit rows for older readers.""" + for name in ("pct_change", "active_buy_net_amount_yuan"): + op.drop_column("sector_radar_stock_fact", name) diff --git a/zhixing-server/migrations/versions/0009_radar_sector_detail.py b/zhixing-server/migrations/versions/0009_radar_sector_detail.py new file mode 100644 index 0000000..3f4708f --- /dev/null +++ b/zhixing-server/migrations/versions/0009_radar_sector_detail.py @@ -0,0 +1,33 @@ +"""Store source sector detail alongside immutable publication daily inputs.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0009_radar_sector_detail" +down_revision: str | None = "0008_radar_detail" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Keep all previous rows readable with nullable provider detail.""" + op.add_column( + "sector_radar_daily_aggregate", sa.Column("pct_change", sa.Numeric(), nullable=True) + ) + op.add_column( + "sector_radar_daily_aggregate", sa.Column("leading_code", sa.Text(), nullable=True) + ) + op.create_check_constraint( + "ck_radar_sector_pct_change_finite", + "sector_radar_daily_aggregate", + "pct_change IS NULL OR pct_change NOT IN " + "('NaN'::numeric, 'Infinity'::numeric, '-Infinity'::numeric)", + ) + + +def downgrade() -> None: + """Remove only the nullable projection; original provider snapshots remain intact.""" + op.drop_column("sector_radar_daily_aggregate", "leading_code") + op.drop_column("sector_radar_daily_aggregate", "pct_change") 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 index a53a1ef..984d05e 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py @@ -14,6 +14,8 @@ from typing import Literal from uuid import uuid4 from zoneinfo import ZoneInfo +from zhixing_server.shared.request_coordinator import TushareSourceError + from ..domain.facts import aggregate_sector_snapshot from ..domain.metrics import ( AmountNetStrategy, @@ -47,11 +49,12 @@ from ..domain.persistence import ( SectorRadarRepository, StockFactRecord, ) -from ..domain.ports import SectorRadarSource +from ..domain.ports import ActiveMoneyflowSource, SectorRadarSource from ..domain.ranking import rank_metric_observations, with_rank_changes from ..domain.source import ( DailyRow, MoneyflowDcRow, + MoneyflowRow, SectorIndexRow, SectorMemberRow, SourceContractError, @@ -367,12 +370,21 @@ class BuildSectorRadar: ) ), ) + index_details = { + (index.sector_type, index.sector_code): index for index in collected.indices + } self.repository.finalize_publication( finished, memberships=collected.memberships, stock_facts=collected.stock_facts, daily_aggregates=( - DailyAggregateRecord(publication_id, aggregate) for aggregate in aggregates + DailyAggregateRecord( + publication_id, + aggregate, + index_details[(aggregate.sector_type, aggregate.sector_code)].pct_change, + index_details[(aggregate.sector_type, aggregate.sector_code)].leading_code, + ) + for aggregate in aggregates ), rankings=(RankingRecord(publication_id, ranking) for ranking in rankings), retry_source_groups=( @@ -511,7 +523,7 @@ class BuildSectorRadar: publication_id, PublicationSourceGroup.CALENDAR, reusable, - lambda: self.source.fetch_trade_calendar(target, target), + lambda: self.source.fetch_trade_calendar(target - timedelta(days=70), target), TradeCalendarRow.from_mapping, ) if target not in {row.cal_date for row in calendar.rows if row.is_open}: @@ -587,6 +599,23 @@ class BuildSectorRadar: ), ) + active_moneyflow: SourceResult[MoneyflowRow] | None = None + if isinstance(self.source, ActiveMoneyflowSource): + fetch_active = self.source.fetch_moneyflow + try: + active_moneyflow = self._fetch_group( + publication_id, + PublicationSourceGroup.MONEYFLOW, + reusable, + lambda: fetch_active(target), + MoneyflowRow.from_mapping, + ) + except (TushareSourceError, SourceContractError): + # Optional detail failure must not invalidate otherwise complete rankings. + logger.warning( + "sector_radar_optional_moneyflow_unavailable publication_id=%s", publication_id + ) + stock_facts = normalize_stock_facts( target_trade_date=target, candidate_codes=member_codes, @@ -594,6 +623,7 @@ class BuildSectorRadar: suspensions=suspensions, daily=daily, moneyflow=moneyflow, + active_moneyflow=active_moneyflow, ) snapshots = ( calendar.snapshots @@ -604,9 +634,11 @@ class BuildSectorRadar: + suspensions.snapshots + daily.snapshots + moneyflow.snapshots + + (active_moneyflow.snapshots if active_moneyflow else ()) ) return _CollectedInputs( target_trade_date=target, + indices=concepts.rows + industries.rows, snapshots=snapshots, membership_snapshots=members.snapshots, memberships=memberships, @@ -723,7 +755,7 @@ class BuildSectorRadar: { "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", + "normalizer": "zhixing_stock_fact_v2", }, sort_keys=True, separators=(",", ":"), @@ -753,6 +785,7 @@ class BuildSectorRadar: @dataclass(frozen=True, slots=True) class _CollectedInputs: target_trade_date: date + indices: tuple[SectorIndexRow, ...] snapshots: tuple[SourceSnapshot, ...] membership_snapshots: tuple[SourceSnapshot, ...] memberships: tuple[MembershipRecord, ...] diff --git a/zhixing-server/src/zhixing_server/modules/sector_radar/application/details.py b/zhixing-server/src/zhixing_server/modules/sector_radar/application/details.py new file mode 100644 index 0000000..e55f05f --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/application/details.py @@ -0,0 +1,445 @@ +"""Publication-scoped detail projections built exclusively from persisted inputs.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + +from ..domain.metrics import AmountNetStrategy, RatioTurnoverStrategy, SwingEqualThreeToTenStrategy +from ..domain.models import MetricKind, RadarPublication, RankedMetric, RankSide, SectorType +from ..domain.normalize import is_current_listed_stock +from ..domain.persistence import PublicationSourceGroup, SectorRadarRepository +from ..domain.source import ( + DailyRow, + MoneyflowDcRow, + MoneyflowRow, + SectorIndexRow, + SectorMemberRow, + SourceSnapshot, + StockBasicRow, + TradeCalendarRow, +) + +_METRIC_VERSIONS = { + MetricKind.AMOUNT: AmountNetStrategy.metric_version, + MetricKind.RATIO: RatioTurnoverStrategy.metric_version, + MetricKind.SWING: SwingEqualThreeToTenStrategy.metric_version, +} + + +@dataclass(frozen=True, slots=True) +class RankingExtras: + """Same-publication daily values and the selected side's historical appearances.""" + + pct_change: Decimal | None = None + daily_net_amount_yuan: Decimal | None = None + daily_ratio: Decimal | None = None + on_list_count: int | None = None + history_available_days: int = 0 + + +@dataclass(frozen=True, slots=True) +class HistoryMetric: + """One dated rank retaining its pool and explicit missing state.""" + + rank_position: int | None = None + rank_percentile: Decimal | None = None + pool_size: int = 0 + metric_value: Decimal | None = None + missing: bool = True + in_top: bool = False + in_bottom: bool = False + + +@dataclass(frozen=True, slots=True) +class HistoryPoint: + """Three comparable metric ranks on one observed trading day.""" + + trade_date: date + publication_id: str | None + amount: HistoryMetric + ratio: HistoryMetric + swing: HistoryMetric + + +@dataclass(frozen=True, slots=True) +class SectorHistory: + """A thirty-session ceiling with no invented pre-launch history.""" + + status: str + requested_trade_date: date + trade_date: date | None + publication: RadarPublication | None + sector_type: SectorType + sector_code: str + sector_name: str | None + points: tuple[HistoryPoint, ...] + available_days: int + window_size: int = 30 + + +@dataclass(frozen=True, slots=True) +class DetailMember: + """One confirmed current-listed member with independently nullable metrics.""" + + ts_code: str + name: str + pct_change: Decimal | None = None + net_amount_yuan: Decimal | None = None + active_buy_net_amount_yuan: Decimal | None = None + + +@dataclass(frozen=True, slots=True) +class LeadingStock: + """Provider-designated leading stock identity.""" + + ts_code: str + name: str | None + + +@dataclass(frozen=True, slots=True) +class MemberLeaders: + """Up to five finite observations per side, stably ordered by code on ties.""" + + top: tuple[DetailMember, ...] + bottom: tuple[DetailMember, ...] + + +@dataclass(frozen=True, slots=True) +class SimilarSector: + """Jaccard overlap of confirmed same-publication listed member sets.""" + + sector_type: SectorType + sector_code: str + sector_name: str + overlap_ratio: Decimal + intersection_count: int + union_count: int + + +@dataclass(frozen=True, slots=True) +class SectorDetail: + """Complete read-only detail for one immutable publication.""" + + history: SectorHistory + pct_change: Decimal | None + leading_stock: LeadingStock | None + summary: dict[str, HistoryMetric] + members: tuple[DetailMember, ...] + leaders: dict[str, MemberLeaders] + similar_sectors: tuple[SimilarSector, ...] + + +class ReadRadarDetails: + """Reuse batched history and exact publication raw snapshots across read views.""" + + def __init__(self, repository: SectorRadarRepository) -> None: + self.repository = repository + + def history_data( + self, publication: RadarPublication + ) -> tuple[dict[date, RadarPublication], dict[date, Sequence[RankedMetric]], tuple[date, ...]]: + """Load history in bounded batches; calendar holes remain explicit missing points.""" + publications = { + item.target_trade_date: item + for item in self.repository.load_history_publications(publication.target_trade_date) + } + # Pin the current date to the response's chosen publication if a rebuild finishes + # during this request. All history rows are then fetched by these exact IDs. + publications[publication.target_trade_date] = publication + by_id = dict( + self.repository.load_publication_rankings( + tuple(item.publication_id for item in publications.values()) + ) + ) + rows = { + day: by_id.get(item.publication_id, ()) + if item.source_version == publication.source_version + else () + for day, item in publications.items() + } + snapshots = self.snapshots(publication) + calendar = [ + TradeCalendarRow.from_mapping(row) + for snapshot in snapshots.get(PublicationSourceGroup.CALENDAR, ()) + for row in snapshot.rows + ] + dates = tuple( + sorted( + { + row.cal_date + for row in calendar + if row.is_open and row.cal_date <= publication.target_trade_date + } + | set(publications) + )[-30:] + ) + return publications, rows, dates + + def ranking_extras( + self, publication: RadarPublication, rankings: Sequence[RankedMetric], side: RankSide + ) -> dict[str, RankingExtras]: + """Enrich one page from one batched thirty-session history, with no per-sector IO.""" + if not rankings: + return {} + _, history, dates = self.history_data(publication) + snapshots = self.snapshots(publication) + indices = { + (item.sector_type, item.sector_code): item + for group, kind in ( + (PublicationSourceGroup.CONCEPT_INDICES, SectorType.CONCEPT), + (PublicationSourceGroup.INDUSTRY_INDICES, SectorType.INDUSTRY), + ) + for snapshot in snapshots.get(group, ()) + for row in snapshot.rows + for item in (SectorIndexRow.from_mapping(row, kind),) + if item.trade_date == publication.target_trade_date + } + result: dict[str, RankingExtras] = {} + for ranking in rankings: + observation = ranking.observation + key = (observation.sector_type, observation.sector_code) + index = indices.get(key) + current = history.get(publication.target_trade_date, ()) + amount = metric_at(current, *key, MetricKind.AMOUNT).metric_value + ratio = metric_at(current, *key, MetricKind.RATIO).metric_value + points = [ + metric_at(history.get(day, ()), *key, observation.metric_kind) for day in dates + ] + available_days = sum(not point.missing for point in points) + on_list_count = None + if available_days and side is not RankSide.ALL: + on_list_count = sum( + point.in_top if side is RankSide.TOP else point.in_bottom for point in points + ) + result[observation.sector_code] = RankingExtras( + index.pct_change if index else None, + amount * Decimal(100_000_000) if amount is not None else None, + ratio, + on_list_count, + available_days, + ) + return result + + def snapshots( + self, publication: RadarPublication + ) -> dict[PublicationSourceGroup, list[SourceSnapshot]]: + """Load exact source revisions, never global latest membership or provider data.""" + grouped: dict[PublicationSourceGroup, list[SourceSnapshot]] = {} + for record in self.repository.load_publication_sources(publication.publication_id): + grouped.setdefault(record.source_group, []).append(record.snapshot) + return grouped + + def history(self, target: date, sector_type: SectorType, sector_code: str) -> SectorHistory: + """Return an exact-date publication and its compatible, past-only rank trajectory.""" + publication = self.repository.get_successful_publication(target) + if publication is None: + return SectorHistory( + "no_data", target, None, None, sector_type, sector_code, None, (), 0 + ) + publications, rows, dates = self.history_data(publication) + current_rows = rows.get(target, ()) + name = next( + ( + row.observation.sector_name + for row in current_rows + if row.observation.sector_type is sector_type + and row.observation.sector_code == sector_code + ), + None, + ) + points = tuple( + HistoryPoint( + day, + publications[day].publication_id if day in publications else None, + metric_at(rows.get(day, ()), sector_type, sector_code, MetricKind.AMOUNT), + metric_at(rows.get(day, ()), sector_type, sector_code, MetricKind.RATIO), + metric_at(rows.get(day, ()), sector_type, sector_code, MetricKind.SWING), + ) + for day in dates + ) + return SectorHistory( + "success" if name is not None else "no_data", + target, + target, + publication, + sector_type, + sector_code, + name, + points, + sum( + not point.amount.missing or not point.ratio.missing or not point.swing.missing + for point in points + ), + ) + + def detail(self, target: date, sector_type: SectorType, sector_code: str) -> SectorDetail: + """Project independently sourced metrics and same-day overlap for confirmed members.""" + history = self.history(target, sector_type, sector_code) + keys = ("pct_change", "net_amount_yuan", "active_buy_net_amount_yuan") + empty = {key: MemberLeaders((), ()) for key in keys} + summary = {kind.value: HistoryMetric() for kind in MetricKind} + if history.publication is None or history.status == "no_data": + return SectorDetail(history, None, None, summary, (), empty, ()) + latest = next((point for point in history.points if point.trade_date == target), None) + if latest: + summary = {kind.value: getattr(latest, kind.value) for kind in MetricKind} + snapshots = self.snapshots(history.publication) + indices = [ + SectorIndexRow.from_mapping(row, kind) + for group, kind in ( + (PublicationSourceGroup.CONCEPT_INDICES, SectorType.CONCEPT), + (PublicationSourceGroup.INDUSTRY_INDICES, SectorType.INDUSTRY), + ) + for snapshot in snapshots.get(group, ()) + for row in snapshot.rows + ] + index = next( + ( + row + for row in indices + if row.sector_type is sector_type + and row.sector_code == sector_code + and row.trade_date == target + ), + None, + ) + basics = { + item.ts_code: item + for snapshot in snapshots.get(PublicationSourceGroup.STOCK_BASICS, ()) + for row in snapshot.rows + for item in (StockBasicRow.from_mapping(row),) + if is_current_listed_stock(item, target) + } + memberships: dict[str, dict[str, str]] = {} + for snapshot in snapshots.get(PublicationSourceGroup.MEMBERS, ()): + for row in snapshot.rows: + member = SectorMemberRow.from_mapping(row) + if member.trade_date == target and member.stock_code in basics: + memberships.setdefault(member.sector_code, {})[member.stock_code] = ( + member.stock_name + ) + daily = { + item.ts_code: item + for snapshot in snapshots.get(PublicationSourceGroup.DAILY, ()) + for row in snapshot.rows + for item in (DailyRow.from_mapping(row),) + if item.trade_date == target + } + main = { + item.ts_code: item + for snapshot in snapshots.get(PublicationSourceGroup.MONEYFLOW_DC, ()) + for row in snapshot.rows + for item in (MoneyflowDcRow.from_mapping(row),) + if item.trade_date == target + } + active = { + item.ts_code: item + for snapshot in snapshots.get(PublicationSourceGroup.MONEYFLOW, ()) + for row in snapshot.rows + for item in (MoneyflowRow.from_mapping(row),) + if item.trade_date == target + } + members = tuple( + DetailMember( + code, + name, + daily[code].pct_chg if code in daily else None, + main[code].net_amount_yuan if code in main else None, + active[code].active_buy_net_amount_yuan if code in active else None, + ) + for code, name in sorted(memberships.get(sector_code, {}).items()) + ) + leaders = {key: member_leaders(members, key) for key in keys} + current_set = set(memberships.get(sector_code, {})) + similar: list[SimilarSector] = [] + if current_set: + for candidate in indices: + if (candidate.sector_type, candidate.sector_code) == ( + sector_type, + sector_code, + ) or candidate.trade_date != target: + continue + other = set(memberships.get(candidate.sector_code, {})) + if not other: + continue + intersection, union = len(current_set & other), len(current_set | other) + if intersection: + similar.append( + SimilarSector( + candidate.sector_type, + candidate.sector_code, + candidate.name, + Decimal(intersection) / Decimal(union), + intersection, + union, + ) + ) + leading = None + if index is not None and index.leading_code: + basic = basics.get(index.leading_code) + leading = LeadingStock(index.leading_code, basic.name if basic else None) + return SectorDetail( + history, + index.pct_change if index else None, + leading, + summary, + members, + leaders, + tuple( + sorted( + similar, + key=lambda item: ( + -item.overlap_ratio, + item.sector_code, + item.sector_type.value, + ), + )[:4] + ), + ) + + +def metric_at( + rows: Sequence[RankedMetric], sector_type: SectorType, sector_code: str, kind: MetricKind +) -> HistoryMetric: + """Select compatible rank values; pool thresholds use each day's actual percentile.""" + pool = [ + row + for row in rows + if row.observation.sector_type is sector_type + and row.observation.metric_kind is kind + and row.observation.metric_version == _METRIC_VERSIONS[kind] + ] + size = sum(row.rank_position is not None for row in pool) + row = next((row for row in pool if row.observation.sector_code == sector_code), None) + if row is None: + return HistoryMetric(pool_size=size) + percentile = row.rank_percentile + return HistoryMetric( + row.rank_position, + percentile, + size, + row.observation.value, + row.rank_position is None, + percentile is not None and percentile >= 90, + percentile is not None and percentile <= 10, + ) + + +def member_leaders(members: Sequence[DetailMember], key: str) -> MemberLeaders: + """Exclude missing values and break metric ties by stock code on both sides.""" + values: list[tuple[DetailMember, Decimal]] = [] + for member in members: + value = { + "pct_change": member.pct_change, + "net_amount_yuan": member.net_amount_yuan, + "active_buy_net_amount_yuan": member.active_buy_net_amount_yuan, + }[key] + if value is not None: + values.append((member, value)) + return MemberLeaders( + tuple(item[0] for item in sorted(values, key=lambda item: (-item[1], item[0].ts_code))[:5]), + tuple(item[0] for item in sorted(values, key=lambda item: (item[1], item[0].ts_code))[:5]), + ) 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 index c6ec4fe..8622f44 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/application/read.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/application/read.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Iterator, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import date from enum import StrEnum from typing import Literal @@ -27,6 +27,7 @@ from ..domain.persistence import ( StockMembershipEntry, ) from ..domain.ranking import select_percentile_side, select_rank_change_side +from .details import RankingExtras, ReadRadarDetails ReadStatus = Literal["success", "no_data"] @@ -106,6 +107,7 @@ class RankingPage: definition: RadarMetricDefinition rows: tuple[RankedMetric, ...] total: int + extras: dict[str, RankingExtras] = field(default_factory=lambda: dict[str, RankingExtras]()) @dataclass(frozen=True, slots=True) @@ -281,6 +283,11 @@ class ReadSectorRadar: definition=definition, rows=searched[start : start + query.page_size], total=len(searched), + extras=ReadRadarDetails(self.repository).ranking_extras( + publication, searched[start : start + query.page_size], query.side + ) + if query.view in {RadarView.AMOUNT, RadarView.RATIO} + else {}, ) def stock_membership(self, query: StockSectorQuery) -> StockSectorMembership: 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 index 69a16d7..5737a5c 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/normalize.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/normalize.py @@ -12,6 +12,7 @@ from .persistence import MembershipRecord, StockFactRecord from .source import ( DailyRow, MoneyflowDcRow, + MoneyflowRow, SectorIndexRow, SectorMemberRow, SourceContractError, @@ -111,6 +112,7 @@ def normalize_stock_facts( suspensions: SourceResult[SuspendRow], daily: SourceResult[DailyRow], moneyflow: SourceResult[MoneyflowDcRow], + active_moneyflow: SourceResult[MoneyflowRow] | None = None, ) -> tuple[StockFactRecord, ...]: """Build normalized yuan facts without collapsing missing states into zero. @@ -121,6 +123,7 @@ def normalize_stock_facts( 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. + active_moneyflow: Optional independently sourced active-order flow in ten-thousand yuan. Returns: One deterministic fact per candidate code under a content-derived revision. @@ -145,11 +148,25 @@ def normalize_stock_facts( } ) ) + if active_moneyflow is not None: + source_snapshot_ids = tuple( + sorted( + set(source_snapshot_ids) + | {snapshot.snapshot_id for snapshot in active_moneyflow.snapshots} + ) + ) + if active_moneyflow is not None and any( + row.trade_date != target_trade_date for row in active_moneyflow.rows + ): + raise SourceContractError("moneyflow rows must match target trade date") + active_by_code = _unique_index( + active_moneyflow.rows if active_moneyflow else (), lambda row: row.ts_code, "moneyflow" + ) revision_payload = json.dumps( { "target_trade_date": target_trade_date.isoformat(), "source_snapshot_ids": source_snapshot_ids, - "normalizer": "zhixing_stock_fact_v1", + "normalizer": "zhixing_stock_fact_v2", }, sort_keys=True, separators=(",", ":"), @@ -192,6 +209,16 @@ def normalize_stock_facts( status=status, turnover_yuan=turnover_yuan, net_amount_yuan=net_amount_yuan, + pct_change=( + daily_row.pct_chg + if daily_row is not None and status is not StockFactStatus.LIFECYCLE_INVALID + else None + ), + active_buy_net_amount_yuan=( + active_by_code[ts_code].active_buy_net_amount_yuan + if ts_code in active_by_code and status is not StockFactStatus.LIFECYCLE_INVALID + else None + ), ) ) return tuple(records) 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 index 831df77..c26fac0 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/persistence.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/persistence.py @@ -109,6 +109,8 @@ class StockFactRecord: status: StockFactStatus turnover_yuan: Decimal | None = None net_amount_yuan: Decimal | None = None + pct_change: Decimal | None = None + active_buy_net_amount_yuan: Decimal | None = None def __post_init__(self) -> None: """Preserve source traceability and stock fact null semantics.""" @@ -122,6 +124,8 @@ class StockFactRecord: _validate_digest(value, "source_snapshot_id") if not self.ts_code.strip(): raise ValueError("ts_code must not be empty") + _validate_optional_decimal(self.pct_change, "pct_change") + _validate_optional_decimal(self.active_buy_net_amount_yuan, "active_buy_net_amount_yuan") _validate_optional_decimal(self.turnover_yuan, "turnover_yuan") _validate_optional_decimal(self.net_amount_yuan, "net_amount_yuan") if self.status is StockFactStatus.AVAILABLE: @@ -149,14 +153,17 @@ class RankingRecord: @dataclass(frozen=True, slots=True) class DailyAggregateRecord: - """One exact daily strategy input owned by a publication revision.""" + """One exact daily strategy input and optional source detail owned by a publication.""" publication_id: str aggregate: SectorDailyAggregate + pct_change: Decimal | None = None + leading_code: str | None = None def __post_init__(self) -> None: """Validate the publication foreign identity.""" + _validate_optional_decimal(self.pct_change, "pct_change") if not self.publication_id.strip(): raise ValueError("publication_id must not be empty") @@ -172,6 +179,7 @@ class PublicationSourceGroup(StrEnum): SUSPENSIONS = "suspensions" DAILY = "daily" MONEYFLOW_DC = "moneyflow_dc" + MONEYFLOW = "moneyflow" @dataclass(frozen=True, slots=True) @@ -291,8 +299,14 @@ class SectorRadarRepository(Protocol): def list_successful_dates(self) -> Sequence[date]: ... + def load_history_publications(self, target: date) -> Sequence[RadarPublication]: ... + def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]: ... + def load_publication_rankings( + self, publication_ids: Sequence[str] + ) -> Sequence[tuple[str, Sequence[RankedMetric]]]: ... + def load_daily_aggregate_history( self, target_trade_date: date, *, limit_dates: int ) -> Sequence[SectorDailyAggregate]: ... 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 index 9206963..7e18ea0 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/ports.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/ports.py @@ -5,13 +5,14 @@ from __future__ import annotations from collections.abc import Sequence from contextlib import AbstractContextManager from datetime import date -from typing import Protocol +from typing import Protocol, runtime_checkable from .models import SectorType from .source import ( CapabilityProbeResult, DailyRow, MoneyflowDcRow, + MoneyflowRow, SectorIndexRow, SectorMemberRow, SourceResult, @@ -51,6 +52,13 @@ class SectorRadarSource(Protocol): def probe(self, trade_date: date) -> CapabilityProbeResult: ... +@runtime_checkable +class ActiveMoneyflowSource(Protocol): + """Optional stock-detail capability; ranking-only sources remain valid.""" + + def fetch_moneyflow(self, trade_date: date) -> SourceResult[MoneyflowRow]: ... + + class SectorRadarLock(Protocol): """Repository seam for a target-date advisory lock.""" 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 index ea8d379..2aefa76 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/domain/source.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/domain/source.py @@ -463,6 +463,27 @@ class MoneyflowDcRow: ) +@dataclass(frozen=True, slots=True) +class MoneyflowRow: + """Active buy/sell net flow; Tushare documents net_mf_amount in ten-thousand yuan.""" + + trade_date: date + ts_code: str + net_mf_amount: Decimal | None + + @property + def active_buy_net_amount_yuan(self) -> Decimal | None: + """Return yuan while retaining missing observations.""" + return None if self.net_mf_amount is None else self.net_mf_amount * Decimal(10_000) + + @classmethod + def from_mapping(cls, row: Mapping[str, SourceScalar]) -> MoneyflowRow: + """Parse one dated active-flow row, rejecting non-finite amounts.""" + trade_date = _source_date(row, "trade_date") + assert trade_date is not None + return cls(trade_date, _required_text(row, "ts_code"), _decimal(row, "net_mf_amount")) + + class CapabilityStatus(StrEnum): """Safe capability outcomes that never expose provider error text.""" 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 index 10abaa1..fb9f75d 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/memory.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/memory.py @@ -435,6 +435,14 @@ class InMemorySectorRadarRepository: ) ) + def load_history_publications(self, target: date) -> Sequence[RadarPublication]: + """Return the latest successful revision per past date, newest first.""" + return tuple( + self._latest_success_for_date(day) + for day in self.list_successful_dates() + if day <= target + )[:30] + def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]: """Load every ranking projection owned by one publication.""" @@ -455,6 +463,12 @@ class InMemorySectorRadarRepository: ) ) + def load_publication_rankings( + self, publication_ids: Sequence[str] + ) -> Sequence[tuple[str, Sequence[RankedMetric]]]: + """Read exact immutable revisions selected by the history reader.""" + return tuple((key, self.load_rankings(key)) for key in publication_ids) + def load_daily_aggregate_history( self, target_trade_date: date, *, limit_dates: int ) -> Sequence[SectorDailyAggregate]: 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 index 7b949cb..1d47312 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py @@ -379,6 +379,8 @@ class PostgresSectorRadarRepository: item.status.value, item.turnover_yuan, item.net_amount_yuan, + item.pct_change, + item.active_buy_net_amount_yuan, ) for item in items ) @@ -392,6 +394,8 @@ class PostgresSectorRadarRepository: "status", "turnover_yuan", "net_amount_yuan", + "pct_change", + "active_buy_net_amount_yuan", ), ("fact_revision", "ts_code"), rows, @@ -422,6 +426,8 @@ class PostgresSectorRadarRepository: item.aggregate.turnover_yuan, item.aggregate.membership_coverage, item.aggregate.moneyflow_coverage, + item.pct_change, + item.leading_code, ) for item in items ) @@ -439,6 +445,8 @@ class PostgresSectorRadarRepository: "turnover_yuan", "membership_coverage", "moneyflow_coverage", + "pct_change", + "leading_code", ), ("publication_id", "sector_type", "sector_code"), rows, @@ -566,6 +574,8 @@ class PostgresSectorRadarRepository: "status", "turnover_yuan", "net_amount_yuan", + "pct_change", + "active_buy_net_amount_yuan", ), ("fact_revision", "ts_code"), tuple( @@ -577,6 +587,8 @@ class PostgresSectorRadarRepository: item.status.value, item.turnover_yuan, item.net_amount_yuan, + item.pct_change, + item.active_buy_net_amount_yuan, ) for item in stock_items ), @@ -596,6 +608,8 @@ class PostgresSectorRadarRepository: "turnover_yuan", "membership_coverage", "moneyflow_coverage", + "pct_change", + "leading_code", ), ("publication_id", "sector_type", "sector_code"), tuple( @@ -611,6 +625,8 @@ class PostgresSectorRadarRepository: item.aggregate.turnover_yuan, item.aggregate.membership_coverage, item.aggregate.moneyflow_coverage, + item.pct_change, + item.leading_code, ) for item in aggregate_items ), @@ -798,6 +814,19 @@ class PostgresSectorRadarRepository: ).fetchall() return tuple(row[0] for row in rows) + def load_history_publications(self, target: date) -> Sequence[RadarPublication]: + """Batch-load at most thirty latest successful date revisions, excluding future data.""" + with self._connection() as connection: + rows = connection.execute( + self._publication_select().replace( + "SELECT ", "SELECT DISTINCT ON (target_trade_date) ", 1 + ) + + " WHERE status = 'success' AND target_trade_date <= %s " + + "ORDER BY target_trade_date DESC, finished_at DESC, id DESC LIMIT 30", + (target,), + ).fetchall() + return tuple(self._publication_from_row(row) for row in rows) + def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]: """Load all ranking projections for one publication in deterministic order.""" @@ -816,6 +845,31 @@ class PostgresSectorRadarRepository: ).fetchall() return tuple(self._ranking_from_row(row) for row in rows) + def load_publication_rankings( + self, publication_ids: Sequence[str] + ) -> Sequence[tuple[str, Sequence[RankedMetric]]]: + """Read all requested immutable revisions in one query, avoiding date races.""" + if not publication_ids: + return () + with self._connection() as connection: + rows = connection.execute( + """ + SELECT 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 + FROM sector_radar_ranking + WHERE publication_id = ANY(%s) + ORDER BY publication_id, sector_type, metric_kind, rank_position NULLS LAST, + sector_code + """, + (list(publication_ids),), + ).fetchall() + grouped: dict[str, list[RankedMetric]] = {} + for row in rows: + grouped.setdefault(str(row[0]), []).append(self._ranking_from_row(row[1:])) + return tuple((key, tuple(grouped.get(key, ()))) for key in publication_ids) + def load_daily_aggregate_history( self, target_trade_date: date, *, limit_dates: int ) -> Sequence[SectorDailyAggregate]: 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 index c2a8dc3..a664cb0 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py @@ -22,6 +22,7 @@ from ..domain.source import ( CapabilityStatus, DailyRow, MoneyflowDcRow, + MoneyflowRow, SectorIndexRow, SectorMemberRow, SourceContractError, @@ -61,6 +62,7 @@ FIELDS: dict[str, tuple[str, ...]] = { ), "suspend_d": ("ts_code", "trade_date", "suspend_timing", "suspend_type"), "daily": ("ts_code", "trade_date", "close", "pre_close", "pct_chg", "vol", "amount"), + "moneyflow": ("trade_date", "ts_code", "net_mf_amount"), "moneyflow_dc": ( "trade_date", "ts_code", @@ -80,6 +82,7 @@ ROW_LIMITS: dict[str, int | None] = { "suspend_d": None, "daily": 6_000, "moneyflow_dc": 6_000, + "moneyflow": 6_000, } _SECTOR_TYPE_PARAM = { @@ -312,6 +315,19 @@ class TushareSectorRadarAdapter: 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(self, trade_date: date) -> SourceResult[MoneyflowRow]: + """Fetch optional active-order flow, fail closed on truncation or wrong dates.""" + snapshot = self._fetch_snapshot( + "moneyflow", + {"trade_date": trade_date.strftime("%Y%m%d")}, + target_trade_date=trade_date, + ) + self._reject_limit(snapshot) + rows = tuple(MoneyflowRow.from_mapping(row) for row in snapshot.rows) + self._require_target_date(rows, trade_date, "moneyflow") + self._require_unique(rows, key=lambda row: row.ts_code, api_name="moneyflow") + return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.ts_code))) + def fetch_moneyflow_dc( self, trade_date: date, 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 index bd4ac85..faf7440 100644 --- a/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/http.py +++ b/zhixing-server/src/zhixing_server/modules/sector_radar/presentation/http.py @@ -9,9 +9,15 @@ from decimal import Decimal from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, Path, Query -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from ....bootstrap.config import Settings, get_settings +from ..application.details import ( + RankingExtras, + ReadRadarDetails, + SectorDetail, + SectorHistory, +) from ..application.read import ( STOCK_SECTOR_CONCEPT_LIMIT, RadarDateIndex, @@ -101,6 +107,106 @@ class RadarRankingRowResponse(BaseModel): rank_percentile: Decimal | None = Field(default=None, gt=0, le=100) rank_change_days: int = Field(ge=1, le=5) rank_change: int | None + pct_change: Decimal | None = None + daily_net_amount_yuan: Decimal | None = None + daily_ratio: Decimal | None = None + on_list_count: int | None = Field(default=None, ge=0, le=30) + history_available_days: int = Field(default=0, ge=0, le=30) + + +class RadarDetailModel(BaseModel): + """Validate typed application projections without coupling them to Pydantic.""" + + model_config = ConfigDict(from_attributes=True) + + +class RadarHistoryMetricResponse(RadarDetailModel): + """One comparable rank with the actual daily pool and missing state.""" + + rank_position: int | None + rank_percentile: Decimal | None + pool_size: int + metric_value: Decimal | None + missing: bool + in_top: bool + in_bottom: bool + + +class RadarHistoryPointResponse(RadarDetailModel): + """Three independent ranks for one trading date.""" + + trade_date: date + publication_id: str | None + amount: RadarHistoryMetricResponse + ratio: RadarHistoryMetricResponse + swing: RadarHistoryMetricResponse + + +class RadarSectorIdentityResponse(RadarDetailModel): + """Exact-date identity; a missing date never borrows a nearby publication.""" + + status: Literal["success", "no_data"] + requested_trade_date: date + trade_date: date | None + publication: RadarPublicationResponse | None + sector_type: SectorType + sector_code: str + sector_name: str | None + + +class RadarHistoryResponse(RadarSectorIdentityResponse): + """At most thirty trading dates, with explicit unavailable points.""" + + points: list[RadarHistoryPointResponse] + window_size: int + available_days: int + + +class RadarMemberResponse(RadarDetailModel): + """A listed member's independently nullable stock metrics, in yuan.""" + + ts_code: str + name: str + pct_change: Decimal | None + net_amount_yuan: Decimal | None + active_buy_net_amount_yuan: Decimal | None + + +class RadarLeadingStockResponse(RadarDetailModel): + """Provider-designated leader; its name may be absent from the listed universe.""" + + ts_code: str + name: str | None + + +class RadarMemberLeadersResponse(RadarDetailModel): + """Up to five observations per side, excluding missing values.""" + + top: list[RadarMemberResponse] + bottom: list[RadarMemberResponse] + + +class RadarSimilarSectorResponse(RadarDetailModel): + """Same-publication Jaccard overlap, including cross-type candidates.""" + + sector_type: SectorType + sector_code: str + sector_name: str + overlap_ratio: Decimal + intersection_count: int + union_count: int + + +class RadarDetailResponse(RadarSectorIdentityResponse): + """Persisted sector detail with exact-version ranks, members and overlap.""" + + pct_change: Decimal | None + leading_stock: RadarLeadingStockResponse | None + summary: dict[str, RadarHistoryMetricResponse] + history: RadarHistoryResponse + members: list[RadarMemberResponse] + leaders: dict[str, RadarMemberLeadersResponse] + similar_sectors: list[RadarSimilarSectorResponse] def _empty_ranking_rows() -> list[RadarRankingRowResponse]: @@ -143,8 +249,8 @@ class StockSectorMembershipResponse(BaseModel): ts_code: str requested_trade_date: date trade_date: date | None - industries: list[SectorRefResponse] = Field(default_factory=list) - concepts: list[SectorRefResponse] = Field(default_factory=list) + industries: list[SectorRefResponse] = Field(default_factory=lambda: list[SectorRefResponse]()) + concepts: list[SectorRefResponse] = Field(default_factory=lambda: list[SectorRefResponse]()) concept_total: int = Field(ge=0) concept_limit: int = Field(ge=1, le=100) @@ -223,6 +329,91 @@ def get_sector_radar_rankings( raise _storage_error() from exc +@sector_radar_router.get( + "/sectors/{sector_type}/{sector_code}/history", response_model=RadarHistoryResponse +) +def get_sector_history( + reader: Annotated[ReadSectorRadar, Depends(get_sector_radar_reader)], + sector_type: SectorType, + sector_code: Annotated[str, Path(min_length=1, max_length=32, pattern=r"\S")], + trade_date: date, +) -> RadarHistoryResponse: + """Read an exact-date sector history without provider IO or future fallback.""" + try: + return _history_response( + ReadRadarDetails(reader.repository).history( + trade_date, sector_type, sector_code.strip() + ) + ) + except SectorRadarRepositoryError as exc: + raise _storage_error() from exc + + +@sector_radar_router.get( + "/sectors/{sector_type}/{sector_code}/detail", response_model=RadarDetailResponse +) +def get_sector_detail( + reader: Annotated[ReadSectorRadar, Depends(get_sector_radar_reader)], + sector_type: SectorType, + sector_code: Annotated[str, Path(min_length=1, max_length=32, pattern=r"\S")], + trade_date: date, +) -> RadarDetailResponse: + """Read independently sourced stock metrics and confirmed member-set overlap.""" + try: + return _detail_response( + ReadRadarDetails(reader.repository).detail(trade_date, sector_type, sector_code.strip()) + ) + except SectorRadarRepositoryError as exc: + raise _storage_error() from exc + + +def _history_response(history: SectorHistory) -> RadarHistoryResponse: + """Convert publication metadata explicitly at the HTTP boundary.""" + return RadarHistoryResponse( + status="success" if history.status == "success" else "no_data", + requested_trade_date=history.requested_trade_date, + trade_date=history.trade_date, + publication=_publication_response(history.publication) if history.publication else None, + sector_type=history.sector_type, + sector_code=history.sector_code, + sector_name=history.sector_name, + points=[RadarHistoryPointResponse.model_validate(point) for point in history.points], + window_size=history.window_size, + available_days=history.available_days, + ) + + +def _detail_response(detail: SectorDetail) -> RadarDetailResponse: + """Keep the same resolved publication identity in detail and nested history.""" + history = _history_response(detail.history) + return RadarDetailResponse( + status=history.status, + requested_trade_date=history.requested_trade_date, + trade_date=history.trade_date, + publication=history.publication, + sector_type=history.sector_type, + sector_code=history.sector_code, + sector_name=history.sector_name, + pct_change=detail.pct_change, + leading_stock=RadarLeadingStockResponse.model_validate(detail.leading_stock) + if detail.leading_stock + else None, + summary={ + key: RadarHistoryMetricResponse.model_validate(value) + for key, value in detail.summary.items() + }, + history=history, + members=[RadarMemberResponse.model_validate(member) for member in detail.members], + leaders={ + key: RadarMemberLeadersResponse.model_validate(value) + for key, value in detail.leaders.items() + }, + similar_sectors=[ + RadarSimilarSectorResponse.model_validate(value) for value in detail.similar_sectors + ], + ) + + @sector_radar_router.get( "/stocks/{ts_code}/membership", response_model=StockSectorMembershipResponse ) @@ -276,7 +467,12 @@ def _rankings_response(page: RankingPage) -> RadarRankingsResponse: page=query.page, page_size=query.page_size, total=page.total, - rows=[_ranking_response(row, query.rank_change_days) for row in page.rows], + rows=[ + _ranking_response( + row, query.rank_change_days, page.extras.get(row.observation.sector_code) + ) + for row in page.rows + ], ) @@ -328,8 +524,11 @@ def _definition_response( ) -def _ranking_response(row: RankedMetric, rank_change_days: int) -> RadarRankingRowResponse: +def _ranking_response( + row: RankedMetric, rank_change_days: int, extras: RankingExtras | None = None +) -> RadarRankingRowResponse: observation = row.observation + extras = extras or RankingExtras() return RadarRankingRowResponse( trade_date=observation.trade_date, sector_type=observation.sector_type, @@ -349,6 +548,11 @@ def _ranking_response(row: RankedMetric, rank_change_days: int) -> RadarRankingR rank_percentile=row.rank_percentile, rank_change_days=rank_change_days, rank_change=row.rank_change(rank_change_days), + pct_change=extras.pct_change, + daily_net_amount_yuan=extras.daily_net_amount_yuan, + daily_ratio=extras.daily_ratio, + on_list_count=extras.on_list_count, + history_available_days=extras.history_available_days, ) diff --git a/zhixing-server/src/zhixing_server/modules/selection/application/run.py b/zhixing-server/src/zhixing_server/modules/selection/application/run.py index 457c521..057dd32 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/application/run.py +++ b/zhixing-server/src/zhixing_server/modules/selection/application/run.py @@ -646,11 +646,7 @@ class RunZhixingB1: if self.sector_reader is None: return None - return self.sector_reader.sector_member_codes( - target_trade_date, - sector_code, - sector_type="industry", - ) + return self.sector_reader.sector_member_codes(target_trade_date, sector_code) def list_sector_counts( self, @@ -684,11 +680,7 @@ class RunZhixingB1: if self.sector_reader is None or not stock_codes: return SelectionSectorMembership(snapshot_trade_date=None, sector_counts=()) - return self.sector_reader.sector_counts( - stock_codes, - target_trade_date, - sector_type="industry", - ) + return self.sector_reader.sector_counts(stock_codes, target_trade_date) def _to_item( diff --git a/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py b/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py index 2219a0d..7ffaca1 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py +++ b/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py @@ -66,16 +66,12 @@ class SelectionSectorReader(Protocol): self, stock_codes: Sequence[str], target_trade_date: date, - *, - sector_type: str = "industry", ) -> SelectionSectorMembership: ... def sector_member_codes( self, target_trade_date: date, sector_code: str, - *, - sector_type: str = "industry", ) -> tuple[str, ...]: ... diff --git a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/sector_membership.py b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/sector_membership.py index 83772d0..b33b769 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/sector_membership.py +++ b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/sector_membership.py @@ -2,7 +2,8 @@ The selection context owns no sector-membership storage. This adapter keeps the port contract local to selection while delegating point-in-time reads to -the sector-radar application service in the composition root. +the sector-radar application service in the composition root. Selection only +speaks the sub-industry vocabulary, so the radar sector type is fixed here. """ from __future__ import annotations @@ -17,20 +18,6 @@ from zhixing_server.modules.selection.domain.runs import ( SelectionSectorMembership, ) -SELECTION_SECTOR_TYPES: dict[str, SectorType] = { - "concept": SectorType.CONCEPT, - "industry": SectorType.INDUSTRY, -} - - -def _sector_type(value: str) -> SectorType: - """Map the public sector-type vocabulary onto the radar domain enum.""" - - try: - return SELECTION_SECTOR_TYPES[value] - except KeyError: - raise ValueError(f"unsupported sector type: {value}") from None - class SectorRadarSelectionReader: """Resolve selection sector aggregates through the sector-radar context.""" @@ -42,15 +29,13 @@ class SectorRadarSelectionReader: self, stock_codes: Sequence[str], target_trade_date: date, - *, - sector_type: str = "industry", ) -> SelectionSectorMembership: """Return per-sector stock counts for one run's selected stocks.""" snapshot = self._reader.sector_counts( stock_codes, target_trade_date, - sector_type=_sector_type(sector_type), + sector_type=SectorType.INDUSTRY, ) return SelectionSectorMembership( snapshot_trade_date=snapshot.trade_date, @@ -68,17 +53,15 @@ class SectorRadarSelectionReader: self, target_trade_date: date, sector_code: str, - *, - sector_type: str = "industry", ) -> tuple[str, ...]: """Return one sector's member stock codes on the aligned snapshot.""" snapshot = self._reader.sector_member_codes( target_trade_date, sector_code, - sector_type=_sector_type(sector_type), + sector_type=SectorType.INDUSTRY, ) return snapshot.stock_codes -__all__ = ["SELECTION_SECTOR_TYPES", "SectorRadarSelectionReader"] +__all__ = ["SectorRadarSelectionReader"] diff --git a/zhixing-server/tests/integration/test_sector_radar_repository.py b/zhixing-server/tests/integration/test_sector_radar_repository.py index 3d0e163..5dfa65d 100644 --- a/zhixing-server/tests/integration/test_sector_radar_repository.py +++ b/zhixing-server/tests/integration/test_sector_radar_repository.py @@ -3,13 +3,14 @@ from dataclasses import replace from datetime import UTC, date, datetime, timedelta from decimal import Decimal from pathlib import Path +from unittest.mock import patch 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.bootstrap.config import Settings, sqlalchemy_database_url from zhixing_server.modules.sector_radar.domain.models import ( MembershipStatus, PublicationStatus, @@ -35,7 +36,12 @@ def prepare_database(database_url: str) -> None: 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") + config.config_file_name = None + with patch( + "zhixing_server.bootstrap.config.get_settings", + return_value=Settings(database_url=database_url), + ): + command.upgrade(config, "head") @pytest.mark.integration @@ -111,12 +117,22 @@ def test_postgres_sector_radar_revisions_and_last_good() -> None: status=StockFactStatus.AVAILABLE, turnover_yuan=Decimal("1000"), net_amount_yuan=Decimal("100"), + pct_change=Decimal("1.25"), + active_buy_net_amount_yuan=Decimal("-25000"), ), ) ).inserted == 1 ) + with psycopg.connect(database_url) as connection: + detail_fact = connection.execute( + "SELECT pct_change, active_buy_net_amount_yuan " + "FROM sector_radar_stock_fact WHERE fact_revision = %s", + (fact_revision,), + ).fetchone() + assert detail_fact == (Decimal("1.25"), Decimal("-25000")) + running = RadarPublication( publication_id=publication_ids[0], target_trade_date=TARGET_DATE, diff --git a/zhixing-server/tests/unit/sector_radar/test_build.py b/zhixing-server/tests/unit/sector_radar/test_build.py index c1b981d..375b5fb 100644 --- a/zhixing-server/tests/unit/sector_radar/test_build.py +++ b/zhixing-server/tests/unit/sector_radar/test_build.py @@ -639,3 +639,229 @@ def test_history_uses_latest_successful_input_revision_for_a_date() -> None: assert second.status == "success" assert len(history) == 2 assert all(item.net_amount_yuan == Decimal(300_000) for item in history) + + +def test_detail_history_is_thirty_sessions_past_only_and_latest_revision() -> None: + from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails + + repository = InMemorySectorRadarRepository() + end = TARGET_DATE + timedelta(days=33) + summary = BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute( + BuildSectorRadarCommand(start_date=TARGET_DATE, end_date=end) + ) + assert summary.status == "success" + target = end - timedelta(days=1) + replacement = BuildSectorRadar( + FakeRadarSource(net_scale=Decimal(2)), + repository, + now_fn=lambda: NOW + timedelta(hours=1), + ).execute(BuildSectorRadarCommand(trade_date=target)) + reader = ReadRadarDetails(repository) + history = reader.history(target, SectorType.CONCEPT, "BK0001.DC") + assert len(history.points) == 30 + assert history.points[0].trade_date == target - timedelta(days=29) + assert history.points[-1].trade_date == target + assert len({point.trade_date for point in history.points}) == 30 + assert history.points[-1].publication_id == replacement.outcomes[0].publication_id + assert history.points[-1].amount.metric_value == Decimal("0.003") + assert history.available_days == 30 + assert ( + reader.history(TARGET_DATE - timedelta(days=1), SectorType.CONCEPT, "BK0001.DC").status + == "no_data" + ) + + old = repository.get_successful_publication(target - timedelta(days=1)) + assert old is not None + repository.publications[old.publication_id] = replace(old, source_version="incompatible-v2") + isolated = reader.history(target, SectorType.CONCEPT, "BK0001.DC") + assert isolated.points[-2].amount.missing + assert isolated.available_days == 29 + + +def test_optional_moneyflow_failure_keeps_main_rankings_and_nullable_details() -> None: + from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails + from zhixing_server.modules.sector_radar.domain.source import MoneyflowRow + + class UnavailableActiveSource(FakeRadarSource): + def fetch_moneyflow(self, trade_date: date) -> SourceResult[MoneyflowRow]: + raise SourceContractError("optional provider unavailable") + + repository = InMemorySectorRadarRepository() + summary = BuildSectorRadar(UnavailableActiveSource(), repository, now_fn=lambda: NOW).execute( + BuildSectorRadarCommand(trade_date=TARGET_DATE) + ) + assert summary.status == "success" + detail = ReadRadarDetails(repository).detail(TARGET_DATE, SectorType.CONCEPT, "BK0001.DC") + assert detail.pct_change == Decimal(1) + assert len(detail.members) == 5 + assert all(member.active_buy_net_amount_yuan is None for member in detail.members) + assert detail.members[0].net_amount_yuan == Decimal(10000) + assert detail.members[0].pct_change == Decimal(0) + assert detail.leaders["active_buy_net_amount_yuan"].top == () + assert detail.summary["amount"].metric_value == Decimal("0.0015") + + +def test_detail_jaccard_uses_same_day_current_listed_members_and_independent_values() -> None: + from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails + from zhixing_server.modules.sector_radar.domain.source import MoneyflowRow + + class DetailSource(FakeRadarSource): + def fetch_sector_members( + self, trade_date: date, sector_codes: Sequence[str] + ) -> SourceResult[SectorMemberRow]: + rows = tuple( + SectorMemberRow(trade_date, code, f"00000{index}.SZ", f"股票{index}") + for code in sector_codes + for index in ((1, 2, 6) if code == "BK0001.DC" else (2, 3)) + ) + return self._result("dc_member", trade_date, rows) + + def fetch_moneyflow(self, trade_date: date) -> SourceResult[MoneyflowRow]: + raw = ( + { + "trade_date": trade_date.isoformat(), + "ts_code": "000001.SZ", + "net_mf_amount": "-2.5", + }, + ) + snapshot = build_source_snapshot( + api_name="moneyflow", + params={}, + rows=raw, + target_trade_date=trade_date, + observed_at=NOW, + ) + return SourceResult((snapshot,), tuple(MoneyflowRow.from_mapping(row) for row in raw)) + + repository = InMemorySectorRadarRepository() + result = BuildSectorRadar(DetailSource(), repository, now_fn=lambda: NOW).execute( + BuildSectorRadarCommand(trade_date=TARGET_DATE) + ) + assert result.status == "success" + reader = ReadRadarDetails(repository) + detail = reader.detail(TARGET_DATE, SectorType.CONCEPT, "BK0001.DC") + assert [member.ts_code for member in detail.members] == ["000001.SZ", "000002.SZ"] + assert detail.members[0].active_buy_net_amount_yuan == Decimal(-25000) + assert detail.members[0].net_amount_yuan == Decimal(10000) + assert detail.members[1].active_buy_net_amount_yuan is None + assert len(detail.leaders["active_buy_net_amount_yuan"].top) == 1 + assert detail.similar_sectors[0].intersection_count == 1 + assert detail.similar_sectors[0].union_count == 3 + assert detail.similar_sectors[0].overlap_ratio == Decimal(1) / Decimal(3) + assert detail.similar_sectors[0].sector_type is SectorType.INDUSTRY + # A later build with different membership cannot alter the older detail. + BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute( + BuildSectorRadarCommand(trade_date=TARGET_DATE + timedelta(days=1)) + ) + assert reader.detail(TARGET_DATE, SectorType.CONCEPT, "BK0001.DC") == detail + + +def test_detail_history_and_ranking_extras_http_use_the_same_publication() -> None: + from fastapi.testclient import TestClient + + from zhixing_server.bootstrap.app import create_app + from zhixing_server.modules.sector_radar.application.read import ReadSectorRadar + from zhixing_server.modules.sector_radar.presentation.http import get_sector_radar_reader + + repository = InMemorySectorRadarRepository() + result = BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute( + BuildSectorRadarCommand(trade_date=TARGET_DATE) + ) + app = create_app() + app.dependency_overrides[get_sector_radar_reader] = lambda: ReadSectorRadar(repository) + with TestClient(app) as client: + params = {"trade_date": TARGET_DATE.isoformat()} + base = "/api/v1/sector-radar/sectors/concept/BK0001.DC" + history = client.get(base + "/history", params=params) + detail = client.get(base + "/detail", params=params) + ranking = client.get( + "/api/v1/sector-radar/rankings", params={**params, "view": "amount", "side": "top"} + ) + assert history.status_code == detail.status_code == ranking.status_code == 200 + payload = detail.json() + assert payload["publication"]["publication_id"] == result.outcomes[0].publication_id + assert payload["history"] == history.json() + assert payload["pct_change"] == "1" + assert payload["members"][0]["active_buy_net_amount_yuan"] is None + row = ranking.json()["rows"][0] + assert row["pct_change"] == "1" + assert Decimal(row["daily_net_amount_yuan"]) == 150000 + assert Decimal(row["daily_ratio"]) == Decimal("0.03") + assert row["on_list_count"] == row["history_available_days"] == 1 + assert client.get(base + "/detail").status_code == 422 + absent = client.get(base + "/detail", params={"trade_date": "2020-01-01"}) + assert absent.json()["status"] == "no_data" + assert absent.json()["members"] == [] + + +@pytest.mark.integration +def test_postgres_detail_migration_and_build_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None: + import os + from pathlib import Path + + import psycopg + from alembic import command + from alembic.config import Config + + from zhixing_server.bootstrap.config import sqlalchemy_database_url + from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails + from zhixing_server.modules.sector_radar.infrastructure.postgres import ( + PostgresSectorRadarRepository, + ) + + database_url = os.getenv("ZHIXING_TEST_DATABASE_URL") + if not database_url: + pytest.skip("set ZHIXING_TEST_DATABASE_URL to run PostgreSQL integration tests") + config = Config(str(Path(__file__).parents[3] / "alembic.ini")) + config.set_main_option( + "sqlalchemy.url", sqlalchemy_database_url(database_url).replace("%", "%%") + ) + # Alembic intentionally reads Settings; bind the explicit test DSN and avoid + # fileConfig disabling unrelated test loggers in the same pytest process. + config.config_file_name = None + with monkeypatch.context() as context: + context.setenv("ZHIXING_DATABASE_URL", database_url) + from zhixing_server.bootstrap.config import get_settings + + get_settings.cache_clear() + try: + command.upgrade(config, "head") + finally: + get_settings.cache_clear() + target = date(2098, 12, 1) + repository = PostgresSectorRadarRepository(database_url, max_connections=2) + try: + result = BuildSectorRadar(FakeRadarSource(), repository, now_fn=lambda: NOW).execute( + BuildSectorRadarCommand(trade_date=target) + ) + assert result.status in ("success", "unchanged") + detail = ReadRadarDetails(repository).detail(target, SectorType.CONCEPT, "BK0001.DC") + assert detail.history.status == "success" + assert detail.pct_change == Decimal(1) + assert len(detail.members) == 5 + assert detail.members[0].active_buy_net_amount_yuan is None + assert detail.summary["amount"].metric_value == Decimal("0.0015") + with psycopg.connect(database_url) as connection: + assert connection.execute("SELECT version_num FROM alembic_version").fetchone() == ( + "0009_radar_sector_detail", + ) + row = connection.execute( + "SELECT pct_change, leading_code FROM sector_radar_daily_aggregate " + "WHERE publication_id = %s AND sector_type = 'concept'", + (result.outcomes[0].publication_id,), + ).fetchone() + assert row == (Decimal(1), "000001.SZ") + facts = connection.execute( + "SELECT pct_change, active_buy_net_amount_yuan " + "FROM sector_radar_stock_fact WHERE trade_date = %s", + (target,), + ).fetchall() + assert facts and all(row == (Decimal(0), None) for row in facts) + with pytest.raises(psycopg.errors.CheckViolation), connection.transaction(): + connection.execute( + "UPDATE sector_radar_stock_fact " + "SET active_buy_net_amount_yuan = 'NaN'::numeric WHERE trade_date = %s", + (target,), + ) + finally: + repository.close() diff --git a/zhixing-server/tests/unit/sector_radar/test_read.py b/zhixing-server/tests/unit/sector_radar/test_read.py index cdd7fd9..b00c309 100644 --- a/zhixing-server/tests/unit/sector_radar/test_read.py +++ b/zhixing-server/tests/unit/sector_radar/test_read.py @@ -402,3 +402,40 @@ def test_sector_member_codes_rejects_blank_sector_code() -> None: with pytest.raises(ValueError): reader.sector_member_codes(TARGET_DATE, " ") + + +def test_history_appearance_counts_use_each_days_pool_and_metric_version() -> None: + from zhixing_server.modules.sector_radar.application.details import ReadRadarDetails + + repository = InMemorySectorRadarRepository() + current_rows = _amount_rankings() + for offset, size in ((-1, 5), (0, 10), (1, 20)): + day = TARGET_DATE + timedelta(days=offset) + running = _running(f"pool-{offset}", day) + repository.create_publication(running) + repository.finish_publication(_finish(running, PublicationStatus.SUCCESS)) + observations = tuple( + replace(row.observation, trade_date=day) for row in current_rows[:size] + ) + repository.save_rankings( + tuple( + RankingRecord(running.publication_id, row) + for row in rank_metric_observations(observations) + ) + ) + reader = ReadRadarDetails(repository) + history = reader.history(TARGET_DATE, SectorType.CONCEPT, "BK0002.DC") + assert [point.amount.pool_size for point in history.points] == [5, 10] + assert [point.amount.in_top for point in history.points] == [False, True] + publication = repository.get_successful_publication(TARGET_DATE) + assert publication is not None + extra = reader.ranking_extras(publication, (current_rows[1],), RankSide.TOP)["BK0002.DC"] + assert extra.on_list_count == 1 + assert extra.history_available_days == 2 + old_version = replace( + current_rows[1], + observation=replace(current_rows[1].observation, metric_version="older-version"), + ) + from zhixing_server.modules.sector_radar.application.details import metric_at + + assert metric_at((old_version,), SectorType.CONCEPT, "BK0002.DC", MetricKind.AMOUNT).missing diff --git a/zhixing-server/tests/unit/selection/test_sector_filter.py b/zhixing-server/tests/unit/selection/test_sector_filter.py index 3a8fd03..f0c8b83 100644 --- a/zhixing-server/tests/unit/selection/test_sector_filter.py +++ b/zhixing-server/tests/unit/selection/test_sector_filter.py @@ -41,17 +41,15 @@ class FakeSectorReader: ) -> None: self.counts = counts self.member_codes = member_codes - self.count_calls: list[tuple[tuple[str, ...], date, str]] = [] - self.member_calls: list[tuple[date, str, str]] = [] + self.count_calls: list[tuple[tuple[str, ...], date]] = [] + self.member_calls: list[tuple[date, str]] = [] def sector_counts( self, stock_codes: Sequence[str], target_trade_date: date, - *, - sector_type: str = "industry", ) -> SelectionSectorMembership: - self.count_calls.append((tuple(stock_codes), target_trade_date, sector_type)) + self.count_calls.append((tuple(stock_codes), target_trade_date)) return SelectionSectorMembership( snapshot_trade_date=target_trade_date, sector_counts=self.counts, @@ -61,10 +59,8 @@ class FakeSectorReader: self, target_trade_date: date, sector_code: str, - *, - sector_type: str = "industry", ) -> tuple[str, ...]: - self.member_calls.append((target_trade_date, sector_code, sector_type)) + self.member_calls.append((target_trade_date, sector_code)) return self.member_codes @@ -161,7 +157,7 @@ def test_list_sector_counts_aggregates_only_selected_stocks() -> None: aggregates = service.list_sector_counts("zhixing_b1") assert isinstance(aggregates, SelectionSectorAggregates) - assert sector_reader.count_calls == [(("000001.SZ", "000002.SZ"), TARGET, "industry")] + assert sector_reader.count_calls == [(("000001.SZ", "000002.SZ"), TARGET)] assert aggregates.snapshot_trade_date == TARGET assert aggregates.sector_type == "industry" assert aggregates.sectors == sector_reader.counts @@ -198,7 +194,7 @@ def test_get_latest_resolves_sector_filter_against_run_snapshot() -> None: ) assert run is store.run - assert sector_reader.member_calls == [(TARGET, "BK0001.DC", "industry")] + assert sector_reader.member_calls == [(TARGET, "BK0001.DC")] assert store.sector_codes_seen == ("000001.SZ", "000002.SZ") assert store.queries[-1].sector is None assert store.queries[-1].page == 2 @@ -239,7 +235,7 @@ def test_get_run_resolves_sector_filter_against_run_snapshot() -> None: run = service.get_run("run-1", query=SelectionResultQuery(sector="BK0001.DC")) assert run is store.run - assert sector_reader.member_calls == [(TARGET, "BK0001.DC", "industry")] + assert sector_reader.member_calls == [(TARGET, "BK0001.DC")] assert store.sector_codes_seen == ("000001.SZ",) 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 index f20223b..b451c9f 100644 --- 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 @@ -5,6 +5,9 @@ const requestJson = vi.hoisted(() => vi.fn()) vi.mock("@/shared/api/request-json", () => ({ requestJson })) import { + getSectorRadarHistory, + getSectorRadarDetail, + parseRadarDetailResponse, getSectorRadarDates, getSectorRadarRankings, getStockSectorMembership, @@ -69,11 +72,106 @@ const rankingPayload = { ], } +const historyMetric = { + rank_position: 1, + rank_percentile: "100", + pool_size: 10, + metric_value: "0.125", + missing: false, + in_top: true, + in_bottom: false, +} +const historyPayload = { + status: "success", + requested_trade_date: "2026-08-28", + trade_date: "2026-08-28", + sector_type: "concept", + sector_code: "BK01", + sector_name: "机器人", + publication, + window_size: 30, + available_days: 1, + points: [ + { + trade_date: "2026-08-28", + publication_id: "publication-1", + swing: historyMetric, + ratio: historyMetric, + amount: historyMetric, + }, + ], +} +const detailPayload = { + ...historyPayload, + pct_change: "-1.25", + leading_stock: { ts_code: "000001.SZ", name: null }, + summary: { + swing: historyMetric, + ratio: historyMetric, + amount: historyMetric, + }, + history: historyPayload, + members: [ + { + ts_code: "000001.SZ", + name: "测试成员", + pct_change: "-1.5", + net_amount_yuan: "120000000", + active_buy_net_amount_yuan: null, + }, + ], + leaders: { + pct_change: { top: [], bottom: [] }, + net_amount_yuan: { top: [], bottom: [] }, + active_buy_net_amount_yuan: { top: [], bottom: [] }, + }, + similar_sectors: [], +} + describe("sector radar API adapters", () => { beforeEach(() => { requestJson.mockReset() }) + it("reads persisted sector resources and preserves nullable independent metrics", async () => { + const signal = new AbortController().signal + const query = { + sectorType: "concept" as const, + sectorCode: "BK01", + tradeDate: "2026-08-28", + } + requestJson.mockResolvedValue(historyPayload) + expect( + (await getSectorRadarHistory(query, signal)).points[0]?.ratio + .metric_value, + ).toBe(0.125) + expect(requestJson).toHaveBeenLastCalledWith( + "/api/v1/sector-radar/sectors/concept/BK01/history?trade_date=2026-08-28", + { signal }, + ) + requestJson.mockResolvedValue(detailPayload) + const detail = await getSectorRadarDetail(query, signal) + expect(requestJson).toHaveBeenLastCalledWith( + "/api/v1/sector-radar/sectors/concept/BK01/detail?trade_date=2026-08-28", + { signal }, + ) + expect(detail.pct_change).toBe(-1.25) + expect(detail.leading_stock?.name).toBeNull() + expect(detail.members[0]).toMatchObject({ + net_amount_yuan: 120000000, + active_buy_net_amount_yuan: null, + }) + }) + + it.each(["NaN", "Infinity", "0x10", "", true])( + "rejects malformed detail Decimal %s", + (value) => { + expect(() => + parseRadarDetailResponse({ ...detailPayload, pct_change: value }), + ).toThrow("detail.pct_change") + }, + ) + it("forwards AbortSignal and normalizes decimal publication fields", async () => { const signal = new AbortController().signal requestJson.mockResolvedValue({ @@ -192,12 +290,6 @@ describe("stock sector membership adapter", () => { requested_trade_date: "2026-08-28", trade_date: "2026-08-28", industries: [{ code: "BK0901.DC", name: "银行" }], - concepts: [ - { code: "BK0001.DC", name: "机器人" }, - { code: "BK0002.DC", name: "人工智能" }, - ], - concept_total: 2, - concept_limit: 20, } it("requests the snake-case endpoint and validates the payload", async () => { @@ -218,8 +310,6 @@ describe("stock sector membership adapter", () => { status: "no_data", trade_date: null, industries: [], - concepts: [], - concept_total: 0, }) const result = await getStockSectorMembership("000001.SZ", "2026-08-28") @@ -237,6 +327,8 @@ describe("stock sector membership adapter", () => { await expect( getStockSectorMembership("000001.SZ", "2026-08-28"), - ).rejects.toThrow("membership.industries[0].name must be a non-empty string") + ).rejects.toThrow( + "membership.industries[0].name must be a non-empty string", + ) }) }) 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 index 4dd0336..269ff41 100644 --- a/zhixing-web/src/features/sector-radar/api/sector-radar.api.ts +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.api.ts @@ -9,6 +9,11 @@ import { radarViews, sectorTypes, type RadarDatesResponse, + type RadarSectorQuery, + type RadarHistoryResponse, + type RadarHistoryMetric, + type RadarDetailResponse, + type RadarMember, type RadarMetricDefinition, type RadarPublication, type RadarRankingRow, @@ -148,7 +153,7 @@ export function parseRadarRankingsResponse( } /** - * Fetch one stock's industries and concepts from the last-good build snapshot. + * Fetch one stock's sub-industries from the last-good build snapshot. * * @param tsCode - Stock code such as `000001.SZ`. * @param tradeDate - Requested trade date; the backend answers with the @@ -176,7 +181,11 @@ export function parseStockSectorMembershipResponse( ): StockSectorMembershipResponse { const record = readRecord(value, "membership") return { - status: readEnum(record.status, ["success", "no_data"], "membership.status"), + status: readEnum( + record.status, + ["success", "no_data"], + "membership.status", + ), ts_code: readNonEmptyString(record.ts_code, "membership.ts_code"), requested_trade_date: readDate( record.requested_trade_date, @@ -184,19 +193,6 @@ export function parseStockSectorMembershipResponse( ), trade_date: readNullableDate(record.trade_date, "membership.trade_date"), industries: readSectorRefs(record.industries, "membership.industries"), - concepts: readSectorRefs(record.concepts, "membership.concepts"), - concept_total: readIntegerInRange( - record.concept_total, - 0, - Number.MAX_SAFE_INTEGER, - "membership.concept_total", - ), - concept_limit: readIntegerInRange( - record.concept_limit, - 1, - 100, - "membership.concept_limit", - ), } } @@ -309,6 +305,33 @@ function readRankingRow(value: unknown, index: number): RadarRankingRow { record.metric_value, `${path}.metric_value`, ), + pct_change: readNullableFiniteNumber( + record.pct_change ?? null, + `${path}.pct_change`, + ), + daily_net_amount_yuan: readNullableFiniteNumber( + record.daily_net_amount_yuan ?? null, + `${path}.daily_net_amount_yuan`, + ), + daily_ratio: readNullableFiniteNumber( + record.daily_ratio ?? null, + `${path}.daily_ratio`, + ), + on_list_count: + record.on_list_count == null + ? null + : readIntegerInRange( + record.on_list_count, + 0, + 30, + `${path}.on_list_count`, + ), + history_available_days: readIntegerInRange( + record.history_available_days ?? 0, + 0, + 30, + `${path}.history_available_days`, + ), quality: readEnum(record.quality, radarMetricQualities, `${path}.quality`), member_count: readIntegerInRange( record.member_count, @@ -427,7 +450,8 @@ function readFiniteNumber(value: unknown, path: string): number { const number = typeof value === "number" ? value - : typeof value === "string" && value.trim().length > 0 + : typeof value === "string" && + /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(value.trim()) ? Number(value) : Number.NaN if (!Number.isFinite(number)) throw contractError(path, "must be finite") @@ -483,3 +507,240 @@ function readNullablePositiveNumber( function contractError(path: string, reason: string): Error { return new Error(`Invalid sector radar response: ${path} ${reason}`) } + +/** Fetch persisted history for a sector/date; forward cancellation and reject malformed decimals. */ +export async function getSectorRadarHistory( + query: RadarSectorQuery, + signal?: AbortSignal, +) { + const payload = await requestJson( + sectorResourceUrl(query, "history"), + { signal }, + ) + return parseRadarHistoryResponse(payload) +} + +/** Fetch persisted detail on demand; no supplier calls originate from the browser. */ +export async function getSectorRadarDetail( + query: RadarSectorQuery, + signal?: AbortSignal, +) { + const payload = await requestJson( + sectorResourceUrl(query, "detail"), + { signal }, + ) + return parseRadarDetailResponse(payload) +} + +function sectorResourceUrl( + query: RadarSectorQuery, + resource: "history" | "detail", +) { + const params = new URLSearchParams({ trade_date: query.tradeDate }) + return `/api/v1/sector-radar/sectors/${query.sectorType}/${encodeURIComponent(query.sectorCode)}/${resource}?${params}` +} + +function readSectorIdentity(record: JsonRecord, path: string) { + return { + status: readEnum(record.status, ["success", "no_data"], `${path}.status`), + requested_trade_date: readNullableDate( + record.requested_trade_date, + `${path}.requested_trade_date`, + ), + trade_date: readNullableDate(record.trade_date, `${path}.trade_date`), + publication: readNullablePublication( + record.publication, + `${path}.publication`, + ), + sector_type: readEnum( + record.sector_type, + sectorTypes, + `${path}.sector_type`, + ), + sector_code: readNonEmptyString(record.sector_code, `${path}.sector_code`), + sector_name: readNullableString(record.sector_name, `${path}.sector_name`), + } +} + +/** Normalize the finite Decimal fields while preserving missing ranks as null. */ +export function parseRadarHistoryResponse( + value: unknown, +): RadarHistoryResponse { + const record = readRecord(value, "history") + return { + ...readSectorIdentity(record, "history"), + window_size: readIntegerInRange( + record.window_size, + 1, + 30, + "history.window_size", + ), + available_days: readIntegerInRange( + record.available_days, + 0, + 30, + "history.available_days", + ), + points: readArray(record.points, "history.points").map((value, index) => { + const path = `history.points[${index}]` + const point = readRecord(value, path) + return { + trade_date: readDate(point.trade_date, `${path}.trade_date`), + publication_id: readNullableString( + point.publication_id, + `${path}.publication_id`, + ), + ...readMetricSummary(point, path), + } + }), + } +} + +function readMetricSummary(record: JsonRecord, path: string) { + return { + amount: readHistoryMetric(record.amount, `${path}.amount`), + ratio: readHistoryMetric(record.ratio, `${path}.ratio`), + swing: readHistoryMetric(record.swing, `${path}.swing`), + } +} + +function readHistoryMetric(value: unknown, path: string): RadarHistoryMetric { + const record = readRecord(value, path) + return { + rank_position: readNullableInteger( + record.rank_position, + 1, + `${path}.rank_position`, + ), + rank_percentile: readNullablePositiveNumber( + record.rank_percentile, + 100, + `${path}.rank_percentile`, + ), + pool_size: readIntegerInRange( + record.pool_size, + 0, + Number.MAX_SAFE_INTEGER, + `${path}.pool_size`, + ), + metric_value: readNullableFiniteNumber( + record.metric_value, + `${path}.metric_value`, + ), + missing: readBoolean(record.missing, `${path}.missing`), + in_top: readBoolean(record.in_top, `${path}.in_top`), + in_bottom: readBoolean(record.in_bottom, `${path}.in_bottom`), + } +} + +function readBoolean(value: unknown, path: string): boolean { + if (typeof value !== "boolean") throw contractError(path, "must be a boolean") + return value +} + +function readMembers(value: unknown, path: string): RadarMember[] { + return readArray(value, path).map((value, index) => { + const itemPath = `${path}[${index}]` + const record = readRecord(value, itemPath) + return { + ts_code: readNonEmptyString(record.ts_code, `${itemPath}.ts_code`), + name: readNonEmptyString(record.name, `${itemPath}.name`), + pct_change: readNullableFiniteNumber( + record.pct_change, + `${itemPath}.pct_change`, + ), + net_amount_yuan: readNullableFiniteNumber( + record.net_amount_yuan, + `${itemPath}.net_amount_yuan`, + ), + active_buy_net_amount_yuan: readNullableFiniteNumber( + record.active_buy_net_amount_yuan, + `${itemPath}.active_buy_net_amount_yuan`, + ), + } + }) +} + +/** Validate the full detail payload, including independent member metrics and overlaps. */ +export function parseRadarDetailResponse(value: unknown): RadarDetailResponse { + const record = readRecord(value, "detail") + const leading = + record.leading_stock === null + ? null + : readRecord(record.leading_stock, "detail.leading_stock") + const leaders = readRecord(record.leaders, "detail.leaders") + function readLeaders(key: string) { + const path = `detail.leaders.${key}` + const group = readRecord(leaders[key], path) + return { + top: readMembers(group.top, `${path}.top`), + bottom: readMembers(group.bottom, `${path}.bottom`), + } + } + return { + ...readSectorIdentity(record, "detail"), + pct_change: readNullableFiniteNumber( + record.pct_change, + "detail.pct_change", + ), + leading_stock: + leading === null + ? null + : { + ts_code: readNonEmptyString( + leading.ts_code, + "detail.leading_stock.ts_code", + ), + name: readNullableString(leading.name, "detail.leading_stock.name"), + }, + summary: readMetricSummary( + readRecord(record.summary, "detail.summary"), + "detail.summary", + ), + history: parseRadarHistoryResponse(record.history), + members: readMembers(record.members, "detail.members"), + leaders: { + pct_change: readLeaders("pct_change"), + net_amount_yuan: readLeaders("net_amount_yuan"), + active_buy_net_amount_yuan: readLeaders("active_buy_net_amount_yuan"), + }, + similar_sectors: readArray( + record.similar_sectors, + "detail.similar_sectors", + ).map((value, index) => { + const path = `detail.similar_sectors[${index}]` + const sector = readRecord(value, path) + return { + sector_type: readEnum( + sector.sector_type, + sectorTypes, + `${path}.sector_type`, + ), + sector_code: readNonEmptyString( + sector.sector_code, + `${path}.sector_code`, + ), + sector_name: readNonEmptyString( + sector.sector_name, + `${path}.sector_name`, + ), + overlap_ratio: readFraction( + sector.overlap_ratio, + `${path}.overlap_ratio`, + ), + intersection_count: readIntegerInRange( + sector.intersection_count, + 0, + Number.MAX_SAFE_INTEGER, + `${path}.intersection_count`, + ), + union_count: readIntegerInRange( + sector.union_count, + 1, + Number.MAX_SAFE_INTEGER, + `${path}.union_count`, + ), + } + }), + } +} 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 index f872b26..89ed354 100644 --- 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 @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const useQuery = vi.hoisted(() => vi.fn()) const useInfiniteQuery = vi.hoisted(() => vi.fn()) const api = vi.hoisted(() => ({ + getSectorRadarHistory: vi.fn(), + getSectorRadarDetail: vi.fn(), getSectorRadarDates: vi.fn(), getSectorRadarRankings: vi.fn(), })) @@ -11,6 +13,9 @@ vi.mock("@tanstack/react-query", () => ({ useInfiniteQuery, useQuery })) vi.mock("./sector-radar.api", () => api) import { + sectorRadarSectorQueryKey, + useSectorRadarDetail, + useSectorRadarHistory, sectorRadarDatesQueryKey, sectorRadarRankingsQueryKey, useSectorRadarDates, @@ -36,6 +41,42 @@ describe("sector radar query hooks", () => { useInfiniteQuery.mockImplementation((options) => options) }) + it("isolates sector resources by type, code and date and forwards cancellation", () => { + const sector = { + sectorType: "concept" as const, + sectorCode: "BK01", + tradeDate: "2026-08-28", + } + const signal = new AbortController().signal + useSectorRadarDetail(sector) + const options = useQuery.mock.calls.at(-1)?.[0] + expect(options.queryKey).toEqual([ + "sectorRadar", + "detail", + "concept", + "BK01", + "2026-08-28", + ]) + expect(options.placeholderData).toBeUndefined() + options.queryFn({ signal }) + expect(api.getSectorRadarDetail).toHaveBeenCalledWith(sector, signal) + expect( + sectorRadarSectorQueryKey("detail", { + ...sector, + tradeDate: "2026-08-31", + }), + ).not.toEqual(options.queryKey) + expect( + sectorRadarSectorQueryKey("detail", { + ...sector, + sectorType: "industry", + }), + ).not.toEqual(options.queryKey) + useSectorRadarHistory(sector) + useQuery.mock.calls.at(-1)?.[0].queryFn({ signal }) + expect(api.getSectorRadarHistory).toHaveBeenCalledWith(sector, signal) + }) + it("keeps filters in one ranking key without splitting cached pages", () => { expect(sectorRadarDatesQueryKey).toEqual(["sectorRadar", "dates"]) expect(sectorRadarRankingsQueryKey(query)).toEqual([ 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 index fc49abf..5af3644 100644 --- a/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts @@ -4,10 +4,17 @@ import { type InfiniteData, } from "@tanstack/react-query" -import { getSectorRadarDates, getSectorRadarRankings, getStockSectorMembership } from "./sector-radar.api" +import { + getSectorRadarDates, + getSectorRadarRankings, + getStockSectorMembership, + getSectorRadarHistory, + getSectorRadarDetail, +} from "./sector-radar.api" import type { RadarRankingsQuery, RadarRankingsResponse, + RadarSectorQuery, } from "./sector-radar.types" export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const @@ -24,7 +31,7 @@ export const stockSectorMembershipQueryKey = ( ] as const /** - * Read one stock's point-in-time industries and concepts for a detail view. + * Read one stock's point-in-time sub-industries for a detail view. * * Disabled until both the stock code and target trade date are known; the * query stays cached per code-date pair so switching selections refetches @@ -92,3 +99,31 @@ export function useSectorRadarRankings(query: RadarRankingsQuery) { queryKey: sectorRadarRankingsQueryKey(query), }) } + +export const sectorRadarSectorQueryKey = ( + resource: "history" | "detail", + query: RadarSectorQuery, +) => + [ + "sectorRadar", + resource, + query.sectorType, + query.sectorCode, + query.tradeDate, + ] as const + +/** Keep on-demand history isolated by sector type, code and requested date. */ +export function useSectorRadarHistory(query: RadarSectorQuery) { + return useQuery({ + queryKey: sectorRadarSectorQueryKey("history", query), + queryFn: ({ signal }) => getSectorRadarHistory(query, signal), + }) +} + +/** Mount only while the detail dialog is open; never reuse another date as placeholder data. */ +export function useSectorRadarDetail(query: RadarSectorQuery) { + return useQuery({ + queryKey: sectorRadarSectorQueryKey("detail", query), + queryFn: ({ signal }) => getSectorRadarDetail(query, signal), + }) +} 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 index 51253ac..e78d5bc 100644 --- a/zhixing-web/src/features/sector-radar/api/sector-radar.types.ts +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.types.ts @@ -77,6 +77,11 @@ export interface RadarRankingRow { rank_percentile: number | null rank_change_days: number rank_change: number | null + pct_change: number | null + daily_net_amount_yuan: number | null + daily_ratio: number | null + on_list_count: number | null + history_available_days: number } export interface RadarRankingsResponse { @@ -123,7 +128,69 @@ export interface StockSectorMembershipResponse { requested_trade_date: string trade_date: string | null industries: StockSectorRef[] - concepts: StockSectorRef[] - concept_total: number - concept_limit: number +} + +export interface RadarSectorQuery { + sectorType: SectorType + sectorCode: string + tradeDate: string +} + +export interface RadarHistoryMetric { + rank_position: number | null + rank_percentile: number | null + pool_size: number + metric_value: number | null + missing: boolean + in_top: boolean + in_bottom: boolean +} + +export type RadarMetricSummary = Record +export interface RadarHistoryPoint extends RadarMetricSummary { + trade_date: string + publication_id: string | null +} +export interface RadarHistoryResponse { + status: "success" | "no_data" + requested_trade_date: string | null + trade_date: string | null + publication: RadarPublication | null + sector_type: SectorType + sector_code: string + sector_name: string | null + points: RadarHistoryPoint[] + window_size: number + available_days: number +} +export interface RadarMember { + ts_code: string + name: string + pct_change: number | null + net_amount_yuan: number | null + active_buy_net_amount_yuan: number | null +} +export type RadarMemberMetric = + "pct_change" | "net_amount_yuan" | "active_buy_net_amount_yuan" +export interface RadarDetailResponse extends Omit< + RadarHistoryResponse, + "points" | "window_size" | "available_days" +> { + pct_change: number | null + leading_stock: { ts_code: string; name: string | null } | null + summary: RadarMetricSummary + history: RadarHistoryResponse + members: RadarMember[] + leaders: Record< + RadarMemberMetric, + { top: RadarMember[]; bottom: RadarMember[] } + > + similar_sectors: { + sector_type: SectorType + sector_code: string + sector_name: string + overlap_ratio: number + intersection_count: number + union_count: number + }[] } diff --git a/zhixing-web/src/features/sector-radar/components/radar-detail-dialog.tsx b/zhixing-web/src/features/sector-radar/components/radar-detail-dialog.tsx new file mode 100644 index 0000000..ffadce9 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/components/radar-detail-dialog.tsx @@ -0,0 +1,331 @@ +import { lazy, Suspense, useState } from "react" +import { Button } from "@/shared/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from "@/shared/ui/dialog" +import { useSectorRadarDetail } from "../api/sector-radar.query" +import type { + RadarDetailResponse, + RadarMemberMetric, + RadarMetricKind, + RadarRankingRow, + RadarSectorQuery, +} from "../api/sector-radar.types" +import { + formatRadarValue, + radarMembersCsv, + radarValueTone, +} from "./radar-format" +import { RadarHistoryGrid } from "./radar-history" +const RadarRankChart = lazy(() => + import("./radar-rank-chart").then((module) => ({ + default: module.RadarRankChart, + })), +) + +const metricLabels = { + swing: "波段资金率", + ratio: "单日流入率", + amount: "单日净额", +} as const +const memberLabels = { + pct_change: "涨跌幅", + net_amount_yuan: "主力净额", + active_buy_net_amount_yuan: "主买净额", +} as const + +/** Keep the trigger inside the dialog primitive so Escape and close restore its focus. */ +export function RadarDetailDialog({ row }: { row: RadarRankingRow }) { + const [open, setOpen] = useState(false) + return ( + + + {row.sector_name} + + + {row.sector_name} + + {row.sector_type === "concept" ? "概念" : "行业"} · {row.sector_code}{" "} + · {row.trade_date} + + {open ? ( + + ) : null} + + + ) +} + +function RadarDetail({ + query, + initialMetric, +}: { + query: RadarSectorQuery + initialMetric: RadarMetricKind +}) { + const detail = useSectorRadarDetail(query) + if (detail.isPending) return

正在加载板块详情…

+ if (detail.isError) + return ( +
+ 板块详情加载失败。 + +
+ ) + if (!detail.data || detail.data.status === "no_data") + return

暂无板块详情数据。

+ return +} + +function RadarDetailBody({ + data, + initialMetric, +}: { + data: RadarDetailResponse + initialMetric: RadarMetricKind +}) { + const [metric, setMetric] = useState(initialMetric) + const [memberMetric, setMemberMetric] = + useState("pct_change") + const [memberSide, setMemberSide] = useState<"top" | "bottom">("top") + const [exportStatus, setExportStatus] = useState("") + const leaders = data.leaders[memberMetric] + async function copyMembers() { + try { + await navigator.clipboard.writeText( + data.members + .map((member) => `${member.ts_code}\t${member.name}`) + .join("\n"), + ) + setExportStatus(`已复制 ${data.members.length} 只成员。`) + } catch { + setExportStatus("复制失败,请使用导出成员。") + } + } + function exportMembers() { + const url = URL.createObjectURL( + new Blob(["\ufeff", radarMembersCsv(data.members)], { + type: "text/csv;charset=utf-8", + }), + ) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = `${data.sector_code}-${data.trade_date}-members.csv` + anchor.click() + URL.revokeObjectURL(url) + setExportStatus(`已导出 ${data.members.length} 只成员。`) + } + return ( +
+
+ + +
+ {exportStatus ?

{exportStatus}

: null} +
+

实际交易日:{data.trade_date ?? "—"}

+

+ 当日涨跌幅: + + {formatRadarValue(data.pct_change, "percent")} + +

+

+ 领涨股: + {data.leading_stock + ? `${data.leading_stock.name ?? ""} ${data.leading_stock.ts_code}` + : "暂无数据"} +

+
+
+
+ {(["swing", "ratio", "amount"] as const).map((key) => ( + + ))} +
+ {data.history.points.some( + (point) => + !point[metric].missing && point[metric].rank_position !== null, + ) ? ( + 正在加载排名曲线…

}> + +
+ ) : ( +

+ 暂无{metricLabels[metric]}排名轨迹,当前历史记录均缺失。 +

+ )} + +
+
+
+

成分强弱

+ {(Object.keys(memberLabels) as RadarMemberMetric[]).map((key) => ( + + ))} +
+
+ {(["top", "bottom"] as const).map((side) => ( + + ))} +
+
+ {[memberSide].map((side) => ( +
+

+ {side === "top" ? "前 5 名" : "后 5 名"} +

+ {leaders[side].length ? ( +
    + {leaders[side].map((member) => ( +
  1. + + {member.name}{" "} + + {member.ts_code} + + + + {formatRadarValue( + member[memberMetric], + memberMetric === "pct_change" ? "percent" : "yuan", + )} + +
  2. + ))} +
+ ) : ( +

暂无有效数据

+ )} +
+ ))} +
+

+ 仅展示有该指标的成员,不足 5 + 只按实际数量显示。主买净额与主力净额为不同来源指标。 +

+
+
+

相似板块

+

+ 本系统口径:同日可确认成员交集 / 并集(Jaccard 重合度)。 +

+ {data.similar_sectors.length ? ( +
+ {data.similar_sectors.map((sector) => ( +
+ + {sector.sector_name}{" "} + + {sector.sector_type === "concept" ? "概念" : "行业"} + + + + {(sector.overlap_ratio * 100).toFixed(1)}%{" "} + + ({sector.intersection_count}/{sector.union_count}) + + +
+ ))} +
+ ) : ( +

暂无相似板块数据

+ )} +
+
+
+

+ 板块成员({data.members.length}) +

+
+
+

+ {data.members.length + ? data.members + .map((member) => `${member.name} (${member.ts_code})`) + .join("、") + : "暂无成员数据"} +

+
+
+
+ ) +} diff --git a/zhixing-web/src/features/sector-radar/components/radar-detail.test.tsx b/zhixing-web/src/features/sector-radar/components/radar-detail.test.tsx new file mode 100644 index 0000000..6e0224a --- /dev/null +++ b/zhixing-web/src/features/sector-radar/components/radar-detail.test.tsx @@ -0,0 +1,301 @@ +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { + RadarDetailResponse, + RadarRankingRow, +} from "../api/sector-radar.types" +import { RadarDetailDialog } from "./radar-detail-dialog" +import { RadarHistoryGrid } from "./radar-history" +import { + formatRadarValue, + radarMembersCsv, + radarValueTone, +} from "./radar-format" +import { buildRadarRankChartOption } from "./radar-rank-chart-option" + +const detailQuery = vi.hoisted(() => vi.fn()) +vi.mock("../api/sector-radar.query", () => ({ + useSectorRadarDetail: detailQuery, +})) +vi.mock("./radar-rank-chart", () => ({ + RadarRankChart: ({ label }: { label: string }) => ( +
+ ), +})) +const metric = { + rank_position: 2, + rank_percentile: 90, + pool_size: 20, + metric_value: 0.02, + missing: false, + in_top: true, + in_bottom: false, +} +const absent = { + ...metric, + rank_position: null, + metric_value: null, + missing: true, + in_top: false, +} +const member = { + ts_code: "000001.SZ", + name: "测试成员", + pct_change: -1.2, + net_amount_yuan: 2e8, + active_buy_net_amount_yuan: null, +} +const data: RadarDetailResponse = { + status: "success", + requested_trade_date: "2026-08-28", + trade_date: "2026-08-28", + publication: null, + sector_type: "concept", + sector_code: "BK01", + sector_name: "机器人", + pct_change: -2, + leading_stock: { ts_code: "000001.SZ", name: null }, + summary: { + swing: metric, + ratio: metric, + amount: { ...metric, metric_value: 2 }, + }, + history: { + status: "success", + requested_trade_date: "2026-08-28", + trade_date: "2026-08-28", + publication: null, + sector_type: "concept", + sector_code: "BK01", + sector_name: "机器人", + window_size: 30, + available_days: 1, + points: [ + { + trade_date: "2026-08-28", + publication_id: "p1", + swing: metric, + ratio: metric, + amount: metric, + }, + { + trade_date: "2026-08-27", + publication_id: null, + swing: absent, + ratio: absent, + amount: absent, + }, + ], + }, + members: [member], + leaders: { + pct_change: { top: [member], bottom: [member] }, + net_amount_yuan: { top: [member], bottom: [member] }, + active_buy_net_amount_yuan: { top: [], bottom: [] }, + }, + similar_sectors: [], +} +const row: RadarRankingRow = { + trade_date: "2026-08-28", + sector_type: "concept", + sector_code: "BK01", + sector_name: "机器人", + metric_kind: "ratio", + metric_version: "v1", + implementation_kind: "independent", + unit: "ratio", + metric_value: 0.02, + quality: "available", + member_count: 1, + valid_sample_count: 1, + membership_coverage: 1, + moneyflow_coverage: 1, + rank_position: 2, + rank_percentile: 90, + rank_change_days: 1, + rank_change: null, + pct_change: -2, + daily_net_amount_yuan: 2e8, + daily_ratio: 0.02, + on_list_count: 1, + history_available_days: 1, +} + +describe("radar detail interactions", () => { + beforeEach(() => { + detailQuery.mockReset() + detailQuery.mockReturnValue({ data, isPending: false, isError: false }) + }) + it("loads only after opening, switches both metrics and copies only returned members", async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }) + render() + expect(detailQuery).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole("button", { name: "机器人" })) + const dialog = await screen.findByRole("dialog") + expect(detailQuery).toHaveBeenCalledWith({ + sectorType: "concept", + sectorCode: "BK01", + tradeDate: "2026-08-28", + }) + expect(within(dialog).getByText("当日涨跌幅:")).toHaveTextContent("-2%") + expect(within(dialog).getByText(/领涨股:/)).toHaveTextContent("000001.SZ") + expect( + await within(dialog).findByRole("img", { name: "单日流入率" }), + ).toBeInTheDocument() + fireEvent.click(within(dialog).getByRole("button", { name: /单日净额/ })) + expect( + within(dialog).getByRole("img", { name: "单日净额" }), + ).toBeInTheDocument() + const strength = within(dialog).getByRole("region", { name: "成分强弱" }) + expect(within(strength).getAllByRole("listitem")).toHaveLength(1) + fireEvent.click(within(strength).getByRole("button", { name: "后 5" })) + expect( + within(strength).getByRole("heading", { name: "后 5 名" }), + ).toBeInTheDocument() + expect( + within(strength).getByRole("button", { name: "后 5" }), + ).toHaveAttribute("aria-pressed", "true") + fireEvent.click(within(strength).getByRole("button", { name: "主买净额" })) + expect(within(strength).getAllByText("暂无有效数据")).toHaveLength(1) + fireEvent.click(within(dialog).getByRole("button", { name: "复制成员" })) + await waitFor(() => + expect(writeText).toHaveBeenCalledWith("000001.SZ\t测试成员"), + ) + expect(await screen.findByText("已复制 1 只成员。")).toBeInTheDocument() + }) + it("starts with the clicked ranking metric and labels entirely missing histories", async () => { + detailQuery.mockReturnValue({ + data: { + ...data, + history: { + ...data.history, + points: data.history.points.map((point) => ({ + ...point, + amount: absent, + })), + }, + }, + isPending: false, + isError: false, + }) + render() + fireEvent.click(screen.getByRole("button", { name: "机器人" })) + const dialog = await screen.findByRole("dialog") + expect( + within(dialog).getByRole("button", { name: /单日净额/ }), + ).toHaveAttribute("aria-pressed", "true") + expect( + within(dialog).getByText("暂无单日净额排名轨迹,当前历史记录均缺失。"), + ).toBeInTheDocument() + expect(within(dialog).queryByRole("img")).not.toBeInTheDocument() + }) + + it("exports the API member CSV and releases its object URL", async () => { + const create = vi.fn().mockReturnValue("blob:members") + const revoke = vi.fn() + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: create, + }) + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: revoke, + }) + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined) + render() + fireEvent.click(screen.getByRole("button", { name: "机器人" })) + await screen.findByRole("dialog") + fireEvent.click(screen.getByRole("button", { name: "导出成员" })) + expect(create).toHaveBeenCalledWith(expect.any(Blob)) + const blob = create.mock.calls[0]?.[0] as Blob + const text = await new Promise((resolve) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result)) + reader.readAsText(blob) + }) + expect(text).toContain('"000001.SZ","测试成员","-1.2","200000000",""') + expect(click.mock.instances[0]).toHaveAttribute( + "download", + "BK01-2026-08-28-members.csv", + ) + expect(revoke).toHaveBeenCalledWith("blob:members") + click.mockRestore() + }) + + it("closes with Escape and restores focus to the trigger", async () => { + render() + const trigger = screen.getByRole("button", { name: "机器人" }) + trigger.focus() + fireEvent.click(trigger) + await screen.findByRole("dialog") + fireEvent.keyDown(document.activeElement ?? document.body, { + key: "Escape", + }) + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ) + await waitFor(() => expect(trigger).toHaveFocus()) + }) + it("reports pending, error and unavailable detail without inventing values", async () => { + detailQuery.mockReturnValue({ isPending: true }) + const { rerender } = render() + fireEvent.click(screen.getByRole("button", { name: "机器人" })) + expect(await screen.findByRole("status")).toHaveTextContent( + "正在加载板块详情", + ) + const retry = vi.fn() + detailQuery.mockReturnValue({ + isPending: false, + isError: true, + refetch: retry, + }) + rerender() + fireEvent.click(screen.getByRole("button", { name: "重试详情" })) + expect(retry).toHaveBeenCalledOnce() + detailQuery.mockReturnValue({ + data: { status: "no_data" }, + isPending: false, + isError: false, + }) + rerender() + expect(screen.getByText("暂无板块详情数据。")).toBeInTheDocument() + }) + it("keeps newest-first history, missing cells, and oldest-first disconnected chart ranks", () => { + render( + , + ) + expect(screen.getByText(/历史不足 30 日/)).toBeInTheDocument() + expect(screen.getByText("缺失")).toBeInTheDocument() + expect( + screen + .getByText("08-28") + .compareDocumentPosition(screen.getByText("08-27")) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + const option = buildRadarRankChartOption(data.history.points, "ratio") + expect(option.xAxis.data).toEqual(["2026-08-27", "2026-08-28"]) + expect(option.series[0]?.data).toEqual([null, 2]) + expect(option.series[0]?.connectNulls).toBe(false) + expect(option.yAxis.inverse).toBe(true) + }) + it("preserves signed amounts and escapes supplier strings in local CSV", () => { + expect(formatRadarValue(-2e8, "yuan")).toBe("-2 亿元") + expect(formatRadarValue(null, "percent")).toBe("—") + expect(radarValueTone(-1)).toContain("emerald") + const csv = radarMembersCsv([{ ...member, name: '=SUM(1,2)"\nhello' }]) + expect(csv).toContain('"\'=SUM(1,2)""\nhello"') + expect(csv).toContain('"-1.2","200000000",""') + }) +}) diff --git a/zhixing-web/src/features/sector-radar/components/radar-format.ts b/zhixing-web/src/features/sector-radar/components/radar-format.ts new file mode 100644 index 0000000..b063858 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/components/radar-format.ts @@ -0,0 +1,46 @@ +import type { RadarMember } from "../api/sector-radar.types" + +/** Format signed values without changing their sign to match a ranking side. */ +export function formatRadarValue( + value: number | null | undefined, + unit: "percent" | "yuan" | "ratio" | "CNY_100M", +) { + if (value == null || !Number.isFinite(value)) return "—" + const scaled = + unit === "yuan" ? value / 1e8 : unit === "ratio" ? value * 100 : value + const suffix = unit === "yuan" || unit === "CNY_100M" ? " 亿元" : "%" + return `${scaled > 0 ? "+" : ""}${scaled.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}${suffix}` +} + +export function radarValueTone(value: number | null | undefined) { + return value == null || !Number.isFinite(value) || value === 0 + ? "text-muted-foreground" + : value > 0 + ? "text-red-600 dark:text-red-400" + : "text-emerald-600 dark:text-emerald-400" +} + +/** Serialize only API members; quote every field and neutralize spreadsheet formula prefixes. */ +export function radarMembersCsv(members: RadarMember[]) { + function cell(value: string | number | null) { + const text = value === null ? "" : String(value) + // Untrusted supplier names/codes must never execute as spreadsheet formulas. + const safe = + typeof value === "string" && /^[\s]*[=+@\-\t\r]/.test(text) + ? `'${text}` + : text + return `"${safe.replaceAll('"', '""')}"` + } + return [ + ["股票代码", "股票名称", "涨跌幅(%)", "主力净额(元)", "主买净额(元)"], + ...members.map((member) => [ + member.ts_code, + member.name, + member.pct_change, + member.net_amount_yuan, + member.active_buy_net_amount_yuan, + ]), + ] + .map((row) => row.map(cell).join(",")) + .join("\r\n") +} diff --git a/zhixing-web/src/features/sector-radar/components/radar-history.tsx b/zhixing-web/src/features/sector-radar/components/radar-history.tsx new file mode 100644 index 0000000..6d611f1 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/components/radar-history.tsx @@ -0,0 +1,97 @@ +import { useSectorRadarHistory } from "../api/sector-radar.query" +import type { + RadarHistoryResponse, + RadarMetricKind, + RadarSectorQuery, +} from "../api/sector-radar.types" +import { Button } from "@/shared/ui/button" + +export function RadarHistory({ + query, + metric, + side, +}: { + query: RadarSectorQuery + metric: RadarMetricKind + side: "top" | "bottom" +}) { + const history = useSectorRadarHistory(query) + if (history.isPending) return

正在加载在榜历史…

+ if (history.isError) + return ( +
+ 在榜历史加载失败。 + +
+ ) + if (!history.data || history.data.status === "no_data") + return

暂无在榜历史数据。

+ return +} + +/** Render newest first and trust each day's pool-based membership flags, preserving missing days. */ +export function RadarHistoryGrid({ + history, + metric, + side, +}: { + history: RadarHistoryResponse + metric: RadarMetricKind + side?: "top" | "bottom" +}) { + const points = [...history.points].sort((a, b) => + b.trade_date.localeCompare(a.trade_date), + ) + return ( +
+

+ 近 {history.window_size} 个交易日,已有 {history.available_days} 日发布 + {history.available_days < history.window_size + ? "(历史不足 30 日)" + : ""} + 。 + {points.length + ? `覆盖 ${points.at(-1)?.trade_date} 至 ${points[0]?.trade_date}。` + : ""} + 在榜为进入对应榜单的累计次数,并非连续天数。 +

+
+ {points.map((point) => { + const value = point[metric] + const inList = + side === "top" + ? value.in_top + : side === "bottom" + ? value.in_bottom + : value.in_top || value.in_bottom + return ( +
+
+ {point.trade_date.slice(5)} +
+
+ {value.missing || value.rank_position === null + ? "缺失" + : `第 ${value.rank_position} 名`} +
+ + {point.trade_date} 排名池 {value.pool_size}{" "} + {value.missing ? "缺失" : inList ? "在榜" : "未在榜"} + +
+ ) + })} +
+
+ ) +} diff --git a/zhixing-web/src/features/sector-radar/components/radar-rank-chart-option.ts b/zhixing-web/src/features/sector-radar/components/radar-rank-chart-option.ts new file mode 100644 index 0000000..7b4123d --- /dev/null +++ b/zhixing-web/src/features/sector-radar/components/radar-rank-chart-option.ts @@ -0,0 +1,51 @@ +import type { + RadarHistoryPoint, + RadarMetricKind, +} from "../api/sector-radar.types" + +/** Produce a chronological ranking series; missing observations break the line and rank 1 stays on top. */ +export function buildRadarRankChartOption( + points: RadarHistoryPoint[], + metric: RadarMetricKind, +) { + const ordered = [...points].sort((a, b) => + a.trade_date.localeCompare(b.trade_date), + ) + return { + animation: false, + grid: { left: 48, right: 20, top: 24, bottom: 36 }, + tooltip: { + trigger: "axis" as const, + renderMode: "richText" as const, + valueFormatter: (value: unknown) => + value == null ? "缺失" : `第 ${String(value)} 名`, + }, + xAxis: { + type: "category" as const, + data: ordered.map((point) => point.trade_date), + boundaryGap: false, + }, + yAxis: { + type: "value" as const, + inverse: true, + min: 1, + max: Math.max(2, ...ordered.map((point) => point[metric].pool_size)), + minInterval: 1, + name: "排名", + }, + series: [ + { + name: "排名", + type: "line" as const, + connectNulls: false, + showSymbol: true, + showAllSymbol: true, + data: ordered.map((point) => + point[metric].missing ? null : point[metric].rank_position, + ), + lineStyle: { color: "#2563eb" }, + itemStyle: { color: "#2563eb" }, + }, + ], + } +} diff --git a/zhixing-web/src/features/sector-radar/components/radar-rank-chart.tsx b/zhixing-web/src/features/sector-radar/components/radar-rank-chart.tsx new file mode 100644 index 0000000..e165941 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/components/radar-rank-chart.tsx @@ -0,0 +1,46 @@ +import { useEffect, useRef } from "react" +import { LineChart } from "echarts/charts" +import { GridComponent, TooltipComponent } from "echarts/components" +import * as echarts from "echarts/core" +import { CanvasRenderer } from "echarts/renderers" +import type { + RadarHistoryPoint, + RadarMetricKind, +} from "../api/sector-radar.types" + +echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]) + +import { buildRadarRankChartOption } from "./radar-rank-chart-option" + +export function RadarRankChart({ + points, + metric, + label, +}: { + points: RadarHistoryPoint[] + metric: RadarMetricKind + label: string +}) { + const containerRef = useRef(null) + useEffect(() => { + if (!containerRef.current) return + const chart = echarts.init(containerRef.current, undefined, { + renderer: "canvas", + }) + chart.setOption(buildRadarRankChartOption(points, metric)) + const observer = new ResizeObserver(() => chart.resize()) + observer.observe(containerRef.current) + return () => { + observer.disconnect() + chart.dispose() + } + }, [points, metric]) + return ( +
+ ) +} 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 index 10488cc..177c48c 100644 --- 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 @@ -12,6 +12,7 @@ import { SectorRadarPage } from "./sector-radar-page" const navigate = vi.hoisted(() => vi.fn()) const useSectorRadarDates = vi.fn() const useSectorRadarRankings = vi.fn() +const useSectorRadarHistory = vi.fn() const refetchDates = vi.fn() const refetchRankings = vi.fn() const fetchNextPage = vi.fn() @@ -24,6 +25,7 @@ vi.mock("@tanstack/react-router", () => ({ })) vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({ + useSectorRadarHistory: (...args: unknown[]) => useSectorRadarHistory(...args), useSectorRadarDates: () => useSectorRadarDates(), useSectorRadarRankings: (...args: unknown[]) => useSectorRadarRankings(...args), @@ -91,6 +93,11 @@ const rankingsResponse: RadarRankingsResponse = { rank_percentile: 100, rank_change_days: 1, rank_change: 3, + pct_change: -1.25, + daily_net_amount_yuan: 1250000000, + daily_ratio: -0.02, + on_list_count: 3, + history_available_days: 10, }, { trade_date: "2026-08-28", @@ -111,6 +118,11 @@ const rankingsResponse: RadarRankingsResponse = { rank_percentile: null, rank_change_days: 1, rank_change: null, + pct_change: null, + daily_net_amount_yuan: null, + daily_ratio: null, + on_list_count: null, + history_available_days: 0, }, ], } @@ -163,6 +175,11 @@ describe("SectorRadarPage", () => { refetch: refetchDates, }) useSectorRadarRankings.mockReturnValue(rankingQueryResult()) + useSectorRadarHistory.mockReturnValue({ + isPending: false, + isError: false, + data: { status: "no_data" }, + }) navigate.mockReset() refetchDates.mockReset() refetchRankings.mockReset() @@ -175,14 +192,21 @@ describe("SectorRadarPage", () => { expect(screen.getAllByText("+12.5 亿元")[0]).toBeInTheDocument() expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() - expect(screen.getAllByText("19/20")[0]).toBeInTheDocument() - expect(screen.getAllByText("100%")[0]).toBeInTheDocument() + expect(screen.queryByText("19/20")).not.toBeInTheDocument() + expect(screen.queryByText("BK0001.DC")).not.toBeInTheDocument() expect( - screen.getAllByRole("columnheader", { name: "排名百分位" }), + screen.queryByRole("columnheader", { name: "排名百分位" }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole("columnheader", { name: "样本" }), + ).not.toBeInTheDocument() + expect( + screen.getAllByRole("columnheader", { name: "涨跌幅" }), ).toHaveLength(2) - expect(screen.getAllByRole("columnheader", { name: "样本" })).toHaveLength( - 2, - ) + expect( + screen.getAllByRole("columnheader", { name: "单日流入率" }), + ).toHaveLength(2) + expect(screen.getAllByText("-2%")).toHaveLength(2) expect( screen.queryByRole("columnheader", { name: "资金覆盖率" }), ).not.toBeInTheDocument() @@ -206,6 +230,66 @@ describe("SectorRadarPage", () => { ).not.toBeInTheDocument() }) + it("mirrors ratio columns and expands only the selected sector history, resetting on date change", () => { + routeSearch = { ...routeSearch, view: "ratio" } + const ratioPage = { + ...rankingsResponse, + view: "ratio", + rows: rankingsResponse.rows.map((row) => ({ + ...row, + unit: "ratio", + metric_value: -0.01, + })), + } + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ data: { pages: [ratioPage], pageParams: [1] } }), + ) + const { rerender } = render() + expect(screen.getAllByRole("columnheader", { name: "在榜" })).toHaveLength( + 2, + ) + expect(screen.getAllByText("-1%")).toHaveLength(4) + const trigger = screen.getAllByRole("button", { + name: "机器人展开在榜历史", + })[0]! + fireEvent.click(trigger) + expect(trigger).toHaveAttribute("aria-expanded", "true") + expect(useSectorRadarHistory).toHaveBeenLastCalledWith({ + sectorType: "concept", + sectorCode: "BK0001.DC", + tradeDate: "2026-08-28", + }) + expect(screen.getByText("暂无在榜历史数据。")).toBeInTheDocument() + fireEvent.click(trigger) + expect(screen.queryByText("暂无在榜历史数据。")).not.toBeInTheDocument() + fireEvent.click(trigger) + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ + data: { + pages: [ + { + ...ratioPage, + publication: { + ...successPublication, + publication_id: "next-day", + }, + rows: ratioPage.rows.map((row) => ({ + ...row, + trade_date: "2026-08-31", + })), + }, + ], + pageParams: [1], + }, + }), + ) + rerender() + expect(screen.queryByText("暂无在榜历史数据。")).not.toBeInTheDocument() + expect( + screen.getAllByRole("button", { name: "机器人展开在榜历史" })[0], + ).toHaveAttribute("aria-expanded", "false") + }) + it("resets filters to the first batch in router search state", () => { routeSearch = { ...routeSearch, page: 3 } render() 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 index 0e78337..9b02f67 100644 --- a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx +++ b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx @@ -1,5 +1,5 @@ import { AlertTriangle, Database, RefreshCw } from "lucide-react" -import { useRef } from "react" +import { useRef, useState } from "react" import { useNavigate, useSearch } from "@tanstack/react-router" import { PageLayout } from "@/app/layout/page-layout" @@ -9,7 +9,6 @@ import { } from "@/features/sector-radar/api/sector-radar.query" import type { RadarMetricKind, - RadarMetricUnit, RadarPublication, RadarRankingRow, RadarRankingsQuery, @@ -36,19 +35,23 @@ import { } from "@/shared/ui/select" import { Skeleton } from "@/shared/ui/skeleton" +import { RadarDetailDialog } from "../components/radar-detail-dialog" +import { RadarHistory } from "../components/radar-history" +import { formatRadarValue, radarValueTone } from "../components/radar-format" + const sectorTypeOptions = [ { label: "概念", value: "concept" }, { label: "行业", value: "industry" }, ] as const const viewOptions = [ { label: "波段资金率", value: "swing" }, - { label: "单日资金率", value: "ratio" }, + { label: "单日流入率", value: "ratio" }, { label: "单日净额", value: "amount" }, { label: "排名变化", value: "rank_change" }, ] as const const metricOptions = [ { label: "主力净额", value: "amount" }, - { label: "单日资金率", value: "ratio" }, + { label: "单日流入率", value: "ratio" }, { label: "波段资金率", value: "swing" }, ] as const const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({ @@ -366,6 +369,8 @@ function RadarTable({ const bottomRows = bottom.data?.pages.flatMap((page) => page.rows) ?? [] const rowCount = Math.max(topRows.length, bottomRows.length) const rankChangeView = response.view === "rank_change" + const dailyView = response.view === "amount" || response.view === "ratio" + const columns = dailyView && response.view === "ratio" ? 10 : 8 const loadMoreRequestPending = useRef({ top: false, bottom: false }) function requestLoadMore(query: RankingQueryResult) { const side = query === top ? "top" : "bottom" @@ -401,7 +406,7 @@ function RadarTable({ {response.sector_type === "concept" ? "概念" : "行业"}板块资金双榜 - {Array.from({ length: 8 }, (_, i) => ( + {Array.from({ length: columns }, (_, i) => ( 资金进攻榜 TOP 10% @@ -426,49 +431,89 @@ function RadarTable({ BOTTOM 10% 资金撤离榜 - - - 排名 / 板块 - - - 排名百分位 - - - 样本 - - - {rankChangeView ? "排名上升" : "流入"} - - - {rankChangeView ? "排名下降" : "流出"} - - - 样本 - - - 排名百分位 - - - 排名 / 板块 - - + {dailyView ? ( + + {(response.view === "ratio" + ? [ + "排名 / 板块", + "涨跌幅", + "净额", + "在榜", + "流入率", + "流入率", + "在榜", + "净额", + "涨跌幅", + "排名 / 板块", + ] + : [ + "排名 / 板块", + "涨跌幅", + "单日流入率", + "净额", + "净额", + "单日流入率", + "涨跌幅", + "排名 / 板块", + ] + ).map((label, index) => ( + + {label} + + ))} + + ) : ( + <> + + + 排名 / 板块 + + + 排名百分位 + + + 样本 + + + {rankChangeView ? "排名上升" : "流入"} + + + {rankChangeView ? "排名下降" : "流出"} + + + 样本 + + + 排名百分位 + + + 排名 / 板块 + + + + )} {Array.from({ length: rowCount }, (_, i) => ( 没有符合当前筛选条件的板块。 @@ -543,11 +588,52 @@ function RadarMirrorRow({ top, bottom, rankChangeView, + view, }: { + view: RadarView top?: RadarRankingRow bottom?: RadarRankingRow rankChangeView: boolean }) { + const [expanded, setExpanded] = useState<"top" | "bottom" | null>(null) + if (view === "ratio" || view === "amount") + return ( + <> + + setExpanded(expanded === "top" ? null : "top")} + /> + + setExpanded(expanded === "bottom" ? null : "bottom") + } + /> + + {expanded && (expanded === "top" ? top : bottom) ? ( + + + + + + ) : null} + + ) return ( @@ -561,6 +647,76 @@ function RadarMirrorRow({ ) } +/** Mirror daily fields without borrowing values from the opposite sector. */ +function DailyCells({ + row, + side, + view, + expanded, + onToggle, +}: { + row?: RadarRankingRow + side: "top" | "bottom" + view: "ratio" | "amount" + expanded: boolean + onToggle: () => void +}) { + if (!row) + return ( + + — + + ) + const fields = [ + + + {row.rank_position ?? "—"} + + + , + + {formatRadarValue(row.pct_change, "percent")} + , + + {formatRadarValue( + view === "ratio" ? row.daily_net_amount_yuan : row.daily_ratio, + view === "ratio" ? "yuan" : "ratio", + )} + , + ...(view === "ratio" + ? [ + + + , + ] + : []), + + {formatRadarValue(row.metric_value, row.unit)} + , + ] + return <>{side === "top" ? fields : fields.reverse()} +} + function RankCells({ row, align, @@ -626,23 +782,10 @@ function MetricCell({ const value = row ? rankChangeView ? formatRankChange(row.rank_change) - : formatDirectionalMetric(row.metric_value, row.unit, direction) + : formatRadarValue(row.metric_value, row.unit) : "—" - const positive = - row?.rank_change !== null && - row?.rank_change !== undefined && - row.rank_change > 0 - const negative = - row?.rank_change !== null && - row?.rank_change !== undefined && - row.rank_change < 0 const borderClass = direction === "in" ? "border-l" : "border-r" - const toneClass = - positive || (direction === "in" && !rankChangeView) - ? "text-red-600 dark:text-red-400 bg-red-500/10" - : negative || (direction === "out" && !rankChangeView) - ? "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10" - : "text-foreground bg-muted/30" + const toneClass = `${radarValueTone(rankChangeView ? row?.rank_change : row?.metric_value)} bg-muted/30` return ( diff --git a/zhixing-web/src/features/selection/components/signal-detail-panel.test.tsx b/zhixing-web/src/features/selection/components/signal-detail-panel.test.tsx index 74dbf25..57945e5 100644 --- a/zhixing-web/src/features/selection/components/signal-detail-panel.test.tsx +++ b/zhixing-web/src/features/selection/components/signal-detail-panel.test.tsx @@ -56,12 +56,6 @@ describe("SignalDetailPanel", () => { requested_trade_date: "2026-08-28", trade_date: "2026-08-28", industries: [{ code: "BK0901.DC", name: "银行" }], - concepts: [ - { code: "BK0001.DC", name: "机器人" }, - { code: "BK0002.DC", name: "人工智能" }, - ], - concept_total: 2, - concept_limit: 20, }, isError: false, isPending: false, @@ -82,9 +76,6 @@ describe("SignalDetailPanel", () => { requested_trade_date: "2026-08-28", trade_date: null, industries: [], - concepts: [], - concept_total: 0, - concept_limit: 20, }, isError: false, isPending: false,