From 7f93d6b0f543e7660051cfe2ce5dcac2628d3507 Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Sun, 6 Sep 2026 16:06:17 +0800 Subject: [PATCH 1/2] feat(sector_radar): enhance sector radar functionality with active moneyflow and detailed metrics - Introduced ActiveMoneyflowSource to fetch optional active-order flow, enhancing the sector radar's data capabilities. - Updated StockFactRecord and DailyAggregateRecord to include pct_change and active_buy_net_amount_yuan for improved financial insights. - Modified the build process to incorporate active moneyflow data without invalidating main rankings on failure. - Enhanced the HTTP API to return detailed sector history and metrics, including pct_change and active buy metrics for members. - Updated tests to validate the new functionality and ensure data integrity across various scenarios. --- .../check.jsonl | 6 + .../design.md | 21 + .../implement.jsonl | 6 + .../implement.md | 17 + .../09-06-capital-radar-daily-detail/prd.md | 23 + .../research/collect_selftest.py | 62 + .../research/collection-result.json | 243 +++ .../research/persisted-baseline.json | 20 + .../research/replay_selftest.py | 77 + .../research/verified-detail.json | 1349 +++++++++++++++++ .../research/verify_http.py | 27 + .../task.json | 26 + .../verification.md | 25 + ...ss_24b98073-00f4-4b58-a624-fbfe486bcf2a.md | 35 + ...ss_55e3d246-e875-4283-bdfc-929f8f26a73f.md | 42 + output/playwright/radar-detail.png | Bin 0 -> 56384 bytes output/playwright/radar-ratio-history.png | Bin 0 -> 103925 bytes zhixing-server/.vite/deps/_metadata.json | 8 + zhixing-server/.vite/deps/package.json | 3 + .../migrations/versions/0008_radar_detail.py | 38 + .../versions/0009_radar_sector_detail.py | 33 + .../modules/sector_radar/application/build.py | 41 +- .../sector_radar/application/details.py | 445 ++++++ .../modules/sector_radar/application/read.py | 9 +- .../modules/sector_radar/domain/normalize.py | 29 +- .../sector_radar/domain/persistence.py | 16 +- .../modules/sector_radar/domain/ports.py | 10 +- .../modules/sector_radar/domain/source.py | 21 + .../sector_radar/infrastructure/memory.py | 14 + .../sector_radar/infrastructure/postgres.py | 54 + .../sector_radar/infrastructure/tushare.py | 16 + .../modules/sector_radar/presentation/http.py | 214 ++- .../test_sector_radar_repository.py | 20 +- .../tests/unit/sector_radar/test_build.py | 226 +++ .../tests/unit/sector_radar/test_read.py | 37 + .../sector-radar/api/sector-radar.api.test.ts | 102 +- .../sector-radar/api/sector-radar.api.ts | 278 +++- .../api/sector-radar.query.test.ts | 41 + .../sector-radar/api/sector-radar.query.ts | 37 +- .../sector-radar/api/sector-radar.types.ts | 70 + .../components/radar-detail-dialog.tsx | 331 ++++ .../components/radar-detail.test.tsx | 301 ++++ .../sector-radar/components/radar-format.ts | 46 + .../sector-radar/components/radar-history.tsx | 97 ++ .../components/radar-rank-chart-option.ts | 51 + .../components/radar-rank-chart.tsx | 46 + .../pages/sector-radar-page.test.tsx | 96 +- .../sector-radar/pages/sector-radar-page.tsx | 269 +++- 48 files changed, 4883 insertions(+), 95 deletions(-) create mode 100644 .trellis/tasks/09-06-capital-radar-daily-detail/check.jsonl create mode 100644 .trellis/tasks/09-06-capital-radar-daily-detail/design.md create mode 100644 .trellis/tasks/09-06-capital-radar-daily-detail/implement.jsonl create mode 100644 .trellis/tasks/09-06-capital-radar-daily-detail/implement.md create mode 100644 .trellis/tasks/09-06-capital-radar-daily-detail/prd.md create mode 100644 .trellis/tasks/09-06-capital-radar-daily-detail/research/collect_selftest.py create mode 100644 .trellis/tasks/09-06-capital-radar-daily-detail/research/collection-result.json create mode 100644 .trellis/tasks/09-06-capital-radar-daily-detail/research/persisted-baseline.json create mode 100644 .trellis/tasks/09-06-capital-radar-daily-detail/research/replay_selftest.py create mode 100644 .trellis/tasks/09-06-capital-radar-daily-detail/research/verified-detail.json create mode 100644 .trellis/tasks/09-06-capital-radar-daily-detail/research/verify_http.py create mode 100644 .trellis/tasks/09-06-capital-radar-daily-detail/task.json create mode 100644 .trellis/tasks/09-06-capital-radar-daily-detail/verification.md create mode 100644 .zcode/plans/plan-sess_24b98073-00f4-4b58-a624-fbfe486bcf2a.md create mode 100644 .zcode/plans/plan-sess_55e3d246-e875-4283-bdfc-929f8f26a73f.md create mode 100644 output/playwright/radar-detail.png create mode 100644 output/playwright/radar-ratio-history.png create mode 100644 zhixing-server/.vite/deps/_metadata.json create mode 100644 zhixing-server/.vite/deps/package.json create mode 100644 zhixing-server/migrations/versions/0008_radar_detail.py create mode 100644 zhixing-server/migrations/versions/0009_radar_sector_detail.py create mode 100644 zhixing-server/src/zhixing_server/modules/sector_radar/application/details.py create mode 100644 zhixing-web/src/features/sector-radar/components/radar-detail-dialog.tsx create mode 100644 zhixing-web/src/features/sector-radar/components/radar-detail.test.tsx create mode 100644 zhixing-web/src/features/sector-radar/components/radar-format.ts create mode 100644 zhixing-web/src/features/sector-radar/components/radar-history.tsx create mode 100644 zhixing-web/src/features/sector-radar/components/radar-rank-chart-option.ts create mode 100644 zhixing-web/src/features/sector-radar/components/radar-rank-chart.tsx 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 0000000000000000000000000000000000000000..cc4d53673f98dede2d8da58e78754cf6393ce045 GIT binary patch literal 56384 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 literal 0 HcmV?d00001 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/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-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..3f817eb 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({ @@ -237,6 +335,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..cde85a8 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, @@ -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, @@ -309,6 +318,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 +463,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 +520,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..149abce 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 @@ -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..7fb05cc 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 { @@ -127,3 +132,68 @@ export interface StockSectorMembershipResponse { 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 ( From 07c5b25043da7f8ad00b85dd07ba13b63db8ef24 Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Sun, 6 Sep 2026 16:15:08 +0800 Subject: [PATCH 2/2] refactor(selection): drop residual concept-sector surface from sub-industry path - remove the sector_type parameter from the selection port, adapter, and use case; the adapter now passes SectorType.INDUSTRY directly, deleting the dead concept vocabulary mapping - stop parsing concepts/concept_total/concept_limit from the sector-radar membership response in the web API layer and narrow the response type to industries only - update tests and the task PRD accordingly; backend pyright errors drop from the 16 baseline to 14 --- .../prd.md | 3 ++- .../modules/selection/application/run.py | 12 ++------- .../modules/selection/domain/runs.py | 4 --- .../infrastructure/sector_membership.py | 27 ++++--------------- .../unit/selection/test_sector_filter.py | 18 +++++-------- .../sector-radar/api/sector-radar.api.test.ts | 8 ------ .../sector-radar/api/sector-radar.api.ts | 15 +---------- .../sector-radar/api/sector-radar.query.ts | 2 +- .../sector-radar/api/sector-radar.types.ts | 3 --- .../components/signal-detail-panel.test.tsx | 9 ------- 10 files changed, 18 insertions(+), 83 deletions(-) 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/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/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 3f817eb..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 @@ -290,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 () => { @@ -316,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") 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 cde85a8..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 @@ -153,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 @@ -193,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", - ), } } 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 149abce..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 @@ -31,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 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 7fb05cc..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 @@ -128,9 +128,6 @@ export interface StockSectorMembershipResponse { requested_trade_date: string trade_date: string | null industries: StockSectorRef[] - concepts: StockSectorRef[] - concept_total: number - concept_limit: number } export interface RadarSectorQuery { 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,