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.
This commit is contained in:
yuxuanhui
2026-09-06 16:06:17 +08:00
parent 68282f5d46
commit 7f93d6b0f5
48 changed files with 4883 additions and 95 deletions
@@ -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": "本次数据及交互设计"}
@@ -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。原站私有算法不构成本次完成条件。真实全池历史不足仅报告覆盖,不人为补齐。
@@ -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": "本次数据及交互设计"}
@@ -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;勾选代表本步骤已执行,不表示全仓检查全部通过。
@@ -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 读取并核对金额、涨跌幅和成员。单板块数据不能验证全市场排名或相似度,不得发布为完整排名池;全池排名/历史边界使用受控数据验证。缺权限或历史数据不足时记录实际限制,不伪造通过。
## 验证与风险
后端验证单位、成员日期、历史截止日、独立发布版本、缺失和在榜计数;前端验证两面板、展开、弹窗、切换和导出交互。真实调用前核实环境与权限,密钥不进入日志。新增详情来源失败不得让已有完整榜单丢失;旧发布缺字段可读且明确为空。当前仍处规划阶段,尚未进行产品修改或真实采集。
@@ -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()
@@ -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"
}
]
}
@@ -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
}
}
@@ -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()
File diff suppressed because it is too large Load Diff
@@ -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'))<Decimal('1e-12')
assert d['history']['available_days']==1
assert len(d['history']['points'])==30
assert d['summary']['swing']['missing'] is True
assert len(d['leaders']['pct_change']['top'])==5
for view in ['amount','ratio']:
r=json.load(urlopen(root+'rankings?sector_type=concept&view='+view+'&side=top&trade_date=2026-09-04'))['rows'][0]
assert Decimal(r['pct_change'])==Decimal('1.82')
assert Decimal(r['daily_net_amount_yuan'])==Decimal('485456500')
assert r['on_list_count']==1
Path(__file__).with_name('verified-detail.json').write_text(json.dumps(d,ensure_ascii=False,indent=2))
print('PASS: real persisted SPD data matches detail and both ranking APIs; 14 members, 30 slots / 1 available day, absent swing remains null.')
@@ -0,0 +1,26 @@
{
"id": "capital-radar-daily-detail",
"name": "capital-radar-daily-detail",
"title": "资金雷达:单日榜单与板块详情",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-06",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "实施及本地验证完成,详见 verification.md。代码按用户边界保持未提交,未自动归档;正常业务库尚未应用本次迁移。",
"meta": {}
}
@@ -0,0 +1,25 @@
# 本地实施验证 · 2026-09-06
本次已实现单日流入率/净额两种表头、板块名称入口、30 交易日在榜展开、详情三指标排名曲线、成员三指标前后 5 名切换、Jaccard 相似板块和成员复制/CSV 导出。沿用当前项目主题,参考站点私有加权评分未作为此次范围。前端读取同源 API;详情和历史仅从已持久化的发布及原始快照分析,不在读请求中调用 Tushare。
## 真实数据核验
使用独立数据库 `radar_detail_selftest_0906`,未覆盖正常业务库。采集 SPD概念 `BK1147.DC` 在 2026-09-04 的 Tushare 数据,14 只成员,原始响应先入库,再从已保存快照重放构建。数据库已升级至 `0009_radar_sector_detail`。采集、独立计算和 API 核对脚本在本任务 `research/`,不包含凭据。
数据库独立汇总与最新 HTTP 返回一致:板块涨跌幅 1.82%,主力净额 485456500 元,成交额 2615472881.48 元,单日流入率 18.560945649159…%,主买净额 -255455700 元。两种资金指标分别保留,未互相替代。`research/verify_http.py` 在重启最新服务后通过,验证 14 个成员、三指标摘要及两种榜单附加字段。
该自测只有 1 个板块、1 日有效发布;近 30 个交易日中的其余日期显式缺失,波段指标保持空值。单板块第 1 名仅验证功能链路,不代表全市场排名,也不能用于检验参考站点全池历史的数值一致性。
## 检查结果
- 后端相关单元、HTTP、PostgreSQL 测试:96 passed。隔离审查库 `radar_detail_review_0906` 从空库完成迁移;覆盖最新发布、30 日边界、未来隔离、每日池变化、版本隔离、可选来源失败、历史成员、Jaccard 和字段持久化。
- 后端全量 Ruff lint/format:通过,129 个文件格式符合。资金雷达源码及相关测试 Pyright:0 errors。
- 前端资金雷达测试:41 passed。全量 ESLint、TypeScript、生产构建通过;资金雷达范围 Prettier 通过。
- 浏览器已核验两个面板、名称入口、在榜展开、默认指标跟随当前面板、成员指标与前后 5 切换、复制/导出 14 名成员及弹窗下部。390px 窄屏发现的 grid/canvas 撑宽已修复,弹窗 clientWidth 和 scrollWidth 均为 358px。单点历史保留可见圆点。
- `git diff --check` 通过。代码未提交,未执行自动归档或修改 specs。
## 已有全局检查问题与使用边界
全量后端 Pyright 仍报告 14 个错误,范围仅在未修改的 selection/application/chart.py、selection/domain/gold_brick.py 和 tests/unit/selection/test_run.py。全量前端 format:check 仍被两份已有 `.playwright-cli/page-2026-09-01T14-54-32-480Z.yml`、`page-2026-09-05T07-35-10-548Z.yml` 阻断。未扩大修复这些文件。构建另有大 chunk 提示,Alembic 有既有 path_separator 弃用提示。
正式业务数据库使用前需应用新迁移并按原构建流程采集/构建目标日;既有发布缺少可选主买净额时显示缺失,不通过实时请求补值。当前本地预览为 `http://127.0.0.1:5516/sector-radar`,连接上述单板块隔离库,后端端口 8016。最新截图在 `output/playwright/radar-detail.png`。
@@ -0,0 +1,35 @@
## 调研结论(可行性:具备)
**现状**
- 详情面板 `zhixing-web/src/features/selection/components/signal-detail-panel.tsx` 只展示名称/代码/价格/指标,无行业板块;`SelectionStockResult` 类型也无相关字段。
- 后端 `market_stock` 表没同步 tushare `stock_basic.industry`;但项目已有 **sector_radar 模块**,通过 tushare `dc_index`/`dc_member` 每日同步"行业板块 + 概念板块"的成分股快照到 PostgreSQL 表 `sector_radar_membership`(trade_date + sector_type=industry/concept + sector_code/name + stock_code),只是目前只暴露了板块级 rankings 端点,**没有"按股票反查所属板块"的接口**——这就是缺口的全部。
- 已确认决策:数据口径复用 sector_radar(DC 行业+概念);仅详情面板展示,列表行不动。
## 实施方案
### 第 0 步:数据验证(前置)
启动 docker compose 的 PostgreSQL,检查 `sector_radar_membership` 按交易日的覆盖情况。若选股目标日期无快照,运行现有 CLI `sector-radar-build` 回补。若 dc_member 接口因 tushare 积分不足失败,回退方案是给 `market_stock` 加 `stock_basic.industry` 列(Alembic 迁移),届时向你说明。
### 后端(sector_radar 模块内新增"按股票反查",不跨模块读表)
1. `modules/sector_radar/infrastructure/postgres.py`:新增读查询——按 `stock_code` 查 `membership_status='available'` 且 `trade_date <= 目标日` 的最近可用快照(带出实际 membership 的 trade_date,point-in-time 语义,不用最新日期冒充历史)。
2. `modules/sector_radar/application/read.py`:新增用例,输入 `ts_code + trade_date`,返回按类型分组的板块列表(industry 全部、concept 排序后默认截断 20 个)。
3. `modules/sector_radar/presentation/http.py`:新增端点
`GET /api/v1/sector-radar/stocks/{ts_code}/membership?trade_date=YYYY-MM-DD&limit=20`
响应:`{ ts_code, trade_date, membership_trade_date, industries: [{code,name}], concepts: [{code,name}], concept_total }`,遵循 http-api-contracts spec(Pydantic 边界模型)。
### 前端(selection feature)
4. 新增 API 调用 + TanStack Query hook(`enabled: !!stock`,按 `stock.target_trade_date` 查询,随详情面板选中切换)。
5. `signal-detail-panel.tsx`:股票名/代码/价格下方新增两行展示——
- **行业**:行业标签(通常 1 个)
- **所属板块**:概念板块 chips,最多显示 8 个 + "等 N 个"
加载中显示占位文案,无数据时静默隐藏(不打扰无板块数据的股票)。
### 测试与验证
- 后端:读用例单测(命中/无快照/截断)+ http 端点测试,参照 sector_radar 现有测试。
- 前端:vitest 组件测试 + typecheck/lint。
- 端到端:起 server + web,在 `/selection` 选一只股票确认行业与板块正确显示、无数据股票正常降级。
### 涉及文件
- `zhixing-server/.../modules/sector_radar/{application/read.py, infrastructure/postgres.py, presentation/http.py}`
- `zhixing-web/src/features/selection/api/*`(新 query)
- `zhixing-web/src/features/selection/components/signal-detail-panel.tsx`
@@ -0,0 +1,42 @@
# 选股页面迭代:上搜索 + 左列表/右详情布局 & 板块筛选
## 现状与关键结论
- 布局:`selection-results-workbench.tsx` 目前是左右两栏 grid(左 320px 筛选+列表 / 右详情),筛选栏嵌在左栏顶部。
- 板块数据:选股结果表(`selection_run_item`)没有板块字段;板块归属在 `sector_radar_membership` 表,现有 membership 接口只支持单股查询。**板块聚合必须由后端新增**(前端逐股请求既慢又只覆盖已加载分页,计数不准)。
- 已确认口径(用户未答,按推荐执行):概念板块(`sector_type='concept'`,与详情面板"板块"标签一致),单选;接口保留 `sector_type` 参数,后续扩展行业零成本。排序 = 按 `stock_count` 倒序(选项旁展示数量)。
## 一、后端(zhixing-server)
遵循 bounded-context-first(ADR 0001):selection 不直接 join sector_radar 表,通过应用层端口调用 sector_radar 的读服务。
1. **sector_radar 模块**(`application/read.py` + `infrastructure/postgres.py`):
- 仓储新增两个只读方法(复用已有的 `get_last_good_publication` 解析快照日):
- `load_sector_counts(stock_codes, snapshot_date, sector_type)`:`SELECT sector_code, sector_name, COUNT(*) FROM sector_radar_membership WHERE stock_code = ANY(%s) AND trade_date = %s AND sector_type = %s AND membership_status='available' GROUP BY 1,2`
- `load_sector_member_codes(snapshot_date, sector_code, sector_type)`:返回该板块成员股票代码列表
- 读服务 `ReadSectorRadar` 新增:`sector_counts(stock_codes, trade_date, sector_type)`(内部解析 last-good publication,返回按 stock_count 降序、名称升序)和 `sector_member_codes(trade_date, sector_code, sector_type)`。
2. **selection 模块**:
- `domain/runs.py`:`SelectionResultQuery` 增加 `sector: str | None`;新增 `SelectionSectorCount(sector_code, sector_name, stock_count)` 值对象;定义端口协议 `SectorMembershipReader`(`sector_counts` / `sector_member_codes` 两个方法)。
- `application/run.py`:新增 `list_sector_counts(strategy, target_trade_date, sector_type)` —— 取 latest run,收集 `status='selected' AND signal_count>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=&sector_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`。
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+8
View File
@@ -0,0 +1,8 @@
{
"hash": "be29b65c",
"configHash": "93a0ae7b",
"lockfileHash": "e3b0c442",
"browserHash": "4df63514",
"optimized": {},
"chunks": {}
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
@@ -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)
@@ -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")
@@ -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, ...]
@@ -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]),
)
@@ -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:
@@ -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)
@@ -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]: ...
@@ -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."""
@@ -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."""
@@ -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]:
@@ -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]:
@@ -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,
@@ -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,
)
@@ -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,6 +36,11 @@ 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("%", "%%"))
config.config_file_name = None
with patch(
"zhixing_server.bootstrap.config.get_settings",
return_value=Settings(database_url=database_url),
):
command.upgrade(config, "head")
@@ -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,
@@ -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()
@@ -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
@@ -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",
)
})
})
@@ -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<unknown>(
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<unknown>(
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`,
),
}
}),
}
}
@@ -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([
@@ -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),
})
}
@@ -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<RadarMetricKind, RadarHistoryMetric>
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
}[]
}
@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="font-medium text-foreground underline-offset-4 hover:text-primary hover:underline">
{row.sector_name}
</DialogTrigger>
<DialogContent className="max-h-[90dvh] grid-cols-1 overflow-y-auto sm:max-w-4xl">
<DialogTitle>{row.sector_name}</DialogTitle>
<DialogDescription>
{row.sector_type === "concept" ? "概念" : "行业"} · {row.sector_code}{" "}
· {row.trade_date}
</DialogDescription>
{open ? (
<RadarDetail
initialMetric={row.metric_kind}
query={{
sectorType: row.sector_type,
sectorCode: row.sector_code,
tradeDate: row.trade_date,
}}
/>
) : null}
</DialogContent>
</Dialog>
)
}
function RadarDetail({
query,
initialMetric,
}: {
query: RadarSectorQuery
initialMetric: RadarMetricKind
}) {
const detail = useSectorRadarDetail(query)
if (detail.isPending) return <p role="status">正在加载板块详情…</p>
if (detail.isError)
return (
<div role="alert">
板块详情加载失败。
<Button onClick={() => void detail.refetch()} variant="outline">
重试详情
</Button>
</div>
)
if (!detail.data || detail.data.status === "no_data")
return <p>暂无板块详情数据。</p>
return <RadarDetailBody data={detail.data} initialMetric={initialMetric} />
}
function RadarDetailBody({
data,
initialMetric,
}: {
data: RadarDetailResponse
initialMetric: RadarMetricKind
}) {
const [metric, setMetric] = useState<RadarMetricKind>(initialMetric)
const [memberMetric, setMemberMetric] =
useState<RadarMemberMetric>("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 (
<div className="min-w-0 space-y-5">
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={!data.members.length}
onClick={() => void copyMembers()}
>
复制成员
</Button>
<Button
variant="outline"
size="sm"
disabled={!data.members.length}
onClick={exportMembers}
>
导出成员
</Button>
</div>
{exportStatus ? <p role="status">{exportStatus}</p> : null}
<div className="flex flex-wrap gap-x-6 gap-y-2 rounded-md bg-muted/50 p-3">
<p>实际交易日:{data.trade_date ?? "—"}</p>
<p>
当日涨跌幅:
<span className={radarValueTone(data.pct_change)}>
{formatRadarValue(data.pct_change, "percent")}
</span>
</p>
<p>
领涨股:
{data.leading_stock
? `${data.leading_stock.name ?? ""} ${data.leading_stock.ts_code}`
: "暂无数据"}
</p>
</div>
<section aria-label="三指标排名" className="space-y-3">
<div className="grid grid-cols-3 gap-2">
{(["swing", "ratio", "amount"] as const).map((key) => (
<button
type="button"
key={key}
onClick={() => setMetric(key)}
aria-pressed={metric === key}
className={`min-w-0 rounded-md border p-2 text-left break-words sm:p-3 ${metric === key ? "border-primary bg-primary/5" : "border-border"}`}
>
<span className="block text-xs text-muted-foreground">
{metricLabels[key]}
</span>
<span className="mt-1 block font-semibold">
{data.summary[key].missing ||
data.summary[key].rank_position === null
? "暂无排名"
: `第 ${data.summary[key].rank_position} 名 / ${data.summary[key].pool_size}`}
</span>
<span className={radarValueTone(data.summary[key].metric_value)}>
{formatRadarValue(
data.summary[key].metric_value,
key === "amount" ? "CNY_100M" : "ratio",
)}
</span>
</button>
))}
</div>
{data.history.points.some(
(point) =>
!point[metric].missing && point[metric].rank_position !== null,
) ? (
<Suspense fallback={<p role="status">正在加载排名曲线…</p>}>
<RadarRankChart
points={data.history.points}
metric={metric}
label={metricLabels[metric]}
/>
</Suspense>
) : (
<p className="rounded-md bg-muted/30 p-4 text-muted-foreground">
暂无{metricLabels[metric]}排名轨迹,当前历史记录均缺失。
</p>
)}
<RadarHistoryGrid history={data.history} metric={metric} />
</section>
<section aria-label="成分强弱" className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="mr-auto font-medium">成分强弱</h3>
{(Object.keys(memberLabels) as RadarMemberMetric[]).map((key) => (
<Button
key={key}
variant={key === memberMetric ? "default" : "outline"}
size="sm"
aria-pressed={key === memberMetric}
onClick={() => setMemberMetric(key)}
>
{memberLabels[key]}
</Button>
))}
</div>
<div aria-label="成分排名方向" className="flex justify-end gap-1">
{(["top", "bottom"] as const).map((side) => (
<Button
key={side}
size="sm"
variant={memberSide === side ? "default" : "outline"}
aria-pressed={memberSide === side}
onClick={() => setMemberSide(side)}
>
{side === "top" ? "前 5" : "后 5"}
</Button>
))}
</div>
<div>
{[memberSide].map((side) => (
<div key={side} className="rounded-md border border-border p-3">
<h4 className="mb-2 font-medium">
{side === "top" ? "前 5 名" : "后 5 名"}
</h4>
{leaders[side].length ? (
<ol className="space-y-2">
{leaders[side].map((member) => (
<li
key={member.ts_code}
className="flex justify-between gap-3"
>
<span>
{member.name}{" "}
<span className="text-xs text-muted-foreground">
{member.ts_code}
</span>
</span>
<span
className={`tabular-nums ${radarValueTone(member[memberMetric])}`}
>
{formatRadarValue(
member[memberMetric],
memberMetric === "pct_change" ? "percent" : "yuan",
)}
</span>
</li>
))}
</ol>
) : (
<p className="text-muted-foreground">暂无有效数据</p>
)}
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
仅展示有该指标的成员,不足 5
只按实际数量显示。主买净额与主力净额为不同来源指标。
</p>
</section>
<section aria-label="相似板块" className="space-y-2">
<h3 className="font-medium">相似板块</h3>
<p className="text-xs text-muted-foreground">
本系统口径:同日可确认成员交集 / 并集(Jaccard 重合度)。
</p>
{data.similar_sectors.length ? (
<div className="grid gap-2 sm:grid-cols-2">
{data.similar_sectors.map((sector) => (
<div
className="flex justify-between rounded-md border border-border p-3"
key={`${sector.sector_type}:${sector.sector_code}`}
>
<span>
{sector.sector_name}{" "}
<span className="text-xs text-muted-foreground">
{sector.sector_type === "concept" ? "概念" : "行业"}
</span>
</span>
<span>
{(sector.overlap_ratio * 100).toFixed(1)}%{" "}
<span className="text-xs text-muted-foreground">
({sector.intersection_count}/{sector.union_count})
</span>
</span>
</div>
))}
</div>
) : (
<p className="text-muted-foreground">暂无相似板块数据</p>
)}
</section>
<section aria-label="板块成员" className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="mr-auto font-medium">
板块成员({data.members.length})
</h3>
</div>
<div className="max-h-44 overflow-auto rounded-md border border-border p-3">
<p className="leading-7 text-muted-foreground">
{data.members.length
? data.members
.map((member) => `${member.name} (${member.ts_code})`)
.join("、")
: "暂无成员数据"}
</p>
</div>
</section>
</div>
)
}
@@ -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 }) => (
<div role="img" aria-label={label} />
),
}))
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(<RadarDetailDialog row={row} />)
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(<RadarDetailDialog row={{ ...row, metric_kind: "amount" }} />)
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(<RadarDetailDialog row={row} />)
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<string>((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(<RadarDetailDialog row={row} />)
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(<RadarDetailDialog row={row} />)
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(<RadarDetailDialog row={row} />)
fireEvent.click(screen.getByRole("button", { name: "重试详情" }))
expect(retry).toHaveBeenCalledOnce()
detailQuery.mockReturnValue({
data: { status: "no_data" },
isPending: false,
isError: false,
})
rerender(<RadarDetailDialog row={row} />)
expect(screen.getByText("暂无板块详情数据。")).toBeInTheDocument()
})
it("keeps newest-first history, missing cells, and oldest-first disconnected chart ranks", () => {
render(
<RadarHistoryGrid history={data.history} metric="ratio" side="top" />,
)
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",""')
})
})
@@ -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")
}
@@ -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 <p role="status">正在加载在榜历史…</p>
if (history.isError)
return (
<div role="alert">
在榜历史加载失败。
<Button
variant="outline"
size="sm"
onClick={() => void history.refetch()}
>
重试历史
</Button>
</div>
)
if (!history.data || history.data.status === "no_data")
return <p>暂无在榜历史数据。</p>
return <RadarHistoryGrid history={history.data} metric={metric} side={side} />
}
/** 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 (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
近 {history.window_size} 个交易日,已有 {history.available_days} 日发布
{history.available_days < history.window_size
? "(历史不足 30 日)"
: ""}
。
{points.length
? `覆盖 ${points.at(-1)?.trade_date} 至 ${points[0]?.trade_date}。`
: ""}
在榜为进入对应榜单的累计次数,并非连续天数。
</p>
<div className="grid grid-cols-5 gap-1 sm:grid-cols-10">
{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 (
<div
key={point.trade_date}
className={`rounded-md border p-1.5 text-center text-xs ${!value.missing && inList ? "border-primary/40 bg-primary/10" : "border-border bg-muted/30"}`}
title={`${point.trade_date} · 排名池 ${value.pool_size} · ${value.missing ? "缺失" : inList ? "在榜" : "未在榜"}`}
>
<div className="text-muted-foreground">
{point.trade_date.slice(5)}
</div>
<div className="mt-1 font-medium tabular-nums">
{value.missing || value.rank_position === null
? "缺失"
: `第 ${value.rank_position} 名`}
</div>
<span className="sr-only">
{point.trade_date} 排名池 {value.pool_size}{" "}
{value.missing ? "缺失" : inList ? "在榜" : "未在榜"}
</span>
</div>
)
})}
</div>
</div>
)
}
@@ -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" },
},
],
}
}
@@ -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<HTMLDivElement>(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 (
<div
ref={containerRef}
role="img"
aria-label={`${label}近30交易日排名曲线,排名1在上方,缺失处断线`}
className="h-64 w-full"
/>
)
}
@@ -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(<SectorRadarPage />)
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(<SectorRadarPage />)
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(<SectorRadarPage />)
@@ -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" ? "概念" : "行业"}板块资金双榜
</caption>
<colgroup>
{Array.from({ length: 8 }, (_, i) => (
{Array.from({ length: columns }, (_, i) => (
<col
key={i}
className={i === 3 || i === 4 ? "w-[120px]" : "w-[100px]"}
@@ -412,7 +417,7 @@ function RadarTable({
<tr>
<th
className="px-3 py-1 text-left font-medium"
colSpan={3}
colSpan={(columns - 2) / 2}
scope="colgroup"
>
资金进攻榜 TOP 10%
@@ -426,12 +431,49 @@ function RadarTable({
</th>
<th
className="px-3 py-1 text-right font-medium"
colSpan={3}
colSpan={(columns - 2) / 2}
scope="colgroup"
>
BOTTOM 10% 资金撤离榜
</th>
</tr>
{dailyView ? (
<tr>
{(response.view === "ratio"
? [
"排名 / 板块",
"涨跌幅",
"净额",
"在榜",
"流入率",
"流入率",
"在榜",
"净额",
"涨跌幅",
"排名 / 板块",
]
: [
"排名 / 板块",
"涨跌幅",
"单日流入率",
"净额",
"净额",
"单日流入率",
"涨跌幅",
"排名 / 板块",
]
).map((label, index) => (
<th
key={index}
scope="col"
className="px-3 py-1 text-right font-medium"
>
{label}
</th>
))}
</tr>
) : (
<>
<tr>
<th className="px-3 py-1 text-left font-medium" scope="col">
排名 / 板块
@@ -464,11 +506,14 @@ function RadarTable({
排名 / 板块
</th>
</tr>
</>
)}
</thead>
<tbody className="divide-y divide-border/60">
{Array.from({ length: rowCount }, (_, i) => (
<RadarMirrorRow
key={i}
key={`${response.publication?.publication_id}:${response.view}:${topRows[i]?.sector_code}:${bottomRows[i]?.sector_code}`}
view={response.view}
rankChangeView={rankChangeView}
top={topRows[i]}
bottom={bottomRows[i]}
@@ -478,7 +523,7 @@ function RadarTable({
<tr>
<td
className="px-3 py-8 text-center text-muted-foreground"
colSpan={8}
colSpan={columns}
>
没有符合当前筛选条件的板块。
</td>
@@ -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 (
<>
<tr className="hover:bg-muted/40">
<DailyCells
row={top}
side="top"
view={view}
expanded={expanded === "top"}
onToggle={() => setExpanded(expanded === "top" ? null : "top")}
/>
<DailyCells
row={bottom}
side="bottom"
view={view}
expanded={expanded === "bottom"}
onToggle={() =>
setExpanded(expanded === "bottom" ? null : "bottom")
}
/>
</tr>
{expanded && (expanded === "top" ? top : bottom) ? (
<tr>
<td colSpan={view === "ratio" ? 10 : 8} className="bg-muted/20 p-3">
<RadarHistory
query={{
sectorType: (expanded === "top" ? top : bottom)!.sector_type,
sectorCode: (expanded === "top" ? top : bottom)!.sector_code,
tradeDate: (expanded === "top" ? top : bottom)!.trade_date,
}}
metric={view}
side={expanded}
/>
</td>
</tr>
) : null}
</>
)
return (
<tr className="hover:bg-muted/40">
<RankCells row={top} align="left" />
@@ -561,6 +647,76 @@ function RadarMirrorRow({
</tr>
)
}
/** 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 (
<td
colSpan={view === "ratio" ? 5 : 4}
className="p-3 text-center text-muted-foreground"
>
—
</td>
)
const fields = [
<td key="name" className="px-3 py-2 whitespace-nowrap">
<span className="mr-2 text-muted-foreground tabular-nums">
{row.rank_position ?? "—"}
</span>
<RadarDetailDialog row={row} />
</td>,
<td
key="pct"
className={`px-3 py-2 text-right tabular-nums ${radarValueTone(row.pct_change)}`}
>
{formatRadarValue(row.pct_change, "percent")}
</td>,
<td
key="extra"
className={`px-3 py-2 text-right tabular-nums ${radarValueTone(view === "ratio" ? row.daily_net_amount_yuan : row.daily_ratio)}`}
>
{formatRadarValue(
view === "ratio" ? row.daily_net_amount_yuan : row.daily_ratio,
view === "ratio" ? "yuan" : "ratio",
)}
</td>,
...(view === "ratio"
? [
<td key="history" className="px-3 py-2 text-right">
<button
type="button"
aria-expanded={expanded}
aria-label={`${row.sector_name}${expanded ? "收起" : "展开"}在榜历史`}
onClick={onToggle}
className="text-primary underline underline-offset-4"
>
{row.on_list_count == null ? "—" : `${row.on_list_count} 次`}
</button>
</td>,
]
: []),
<td
key="metric"
className={`border-x border-border bg-muted/30 px-3 py-2 text-right font-medium tabular-nums ${radarValueTone(row.metric_value)}`}
>
{formatRadarValue(row.metric_value, row.unit)}
</td>,
]
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 (
<td
className={`${borderClass} border-border px-3 py-1.5 ${direction === "in" ? "text-right" : "text-left"} font-medium tabular-nums ${toneClass}`}
@@ -652,20 +795,6 @@ function MetricCell({
)
}
function formatDirectionalMetric(
value: number | null,
unit: RadarMetricUnit,
direction: "in" | "out",
) {
if (value === null) return "—"
const absoluteValue = Math.abs(value)
const formatted =
unit === "CNY_100M"
? `${formatNumber(absoluteValue, 2)} 亿元`
: `${formatNumber(absoluteValue * 100, 2)}%`
return `${direction === "in" ? "+" : "−"}${formatted}`
}
function RadarLoading() {
return (
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">