Develop #31
@@ -8,7 +8,8 @@
|
|||||||
|
|
||||||
## 关键决策
|
## 关键决策
|
||||||
|
|
||||||
- 板块口径:细分行业(sector_type=industry,即东财行业快照),selection 上下文固定为 industry——HTTP `/sectors` 不再接受 sector_type 参数,端口/适配器默认值均为 industry;「细分行业」仍是 sector 词汇的一种,内部参数名保持 `sector` 不变。
|
- 板块口径:细分行业(sector_type=industry,即东财行业快照),selection 上下文固定为 industry——HTTP `/sectors` 不再接受 sector_type 参数,端口/适配器方法签名不再携带 sector_type,适配器固定传 `SectorType.INDUSTRY`;「细分行业」仍是 sector 词汇的一种,内部参数名保持 `sector` 不变。
|
||||||
|
- 详情面板 membership 消费面收窄到 industries:前端 `sector-radar.api.ts` / 类型不再解析 concepts 字段(后端 `/sector-radar/stocks/{ts_code}/membership` 契约仍返回 concepts,供板块雷达等其余消费方使用)。
|
||||||
- 单选下拉;排序按 stock_count 倒序(后端保证),名称升序 tie-break。
|
- 单选下拉;排序按 stock_count 倒序(后端保证),名称升序 tie-break。
|
||||||
- 行业数据不在 selection 表中,按 ADR 0001 通过端口委托 sector_radar 读服务(不跨上下文 join SQL)。
|
- 行业数据不在 selection 表中,按 ADR 0001 通过端口委托 sector_radar 读服务(不跨上下文 join SQL)。
|
||||||
|
|
||||||
|
|||||||
@@ -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=§or_type=` → `{ sector_type, snapshot_trade_date, sectors: [{sector_code, sector_name, stock_count}] }`
|
||||||
|
- `GET /results` 与 `GET /runs/{id}` 增加 `sector` query 参数校验(去空格、限长)。
|
||||||
|
- 组合根(router/依赖装配处)把 sector_radar 的读服务适配为 selection 的端口注入。
|
||||||
|
3. **测试**:`tests/unit/sector_radar/`(新仓储方法)、`tests/unit/selection/test_postgres_runs.py`(sector 过滤、ANY 数组、空成员空页)、`tests/test_selection_http.py`(新端点契约 + results 带 sector)。跑 `./dev.sh check`(ruff/pyright/pytest)。
|
||||||
|
|
||||||
|
## 二、前端(zhixing-web)
|
||||||
|
|
||||||
|
1. **布局重构** `selection-results-workbench.tsx`:
|
||||||
|
- 外层改为 `flex flex-col`:顶部一个全宽 section 放搜索栏(关键词 / 信号分类 / **板块(新增)** / 排序 / 筛选结果计数),带 `rounded-md border bg-card` 与现有一致;
|
||||||
|
- 下方 `md:grid md:grid-cols-[320px_minmax(0,1fr)]` 左列表右详情;移动端纵向堆叠为 搜索 → 列表 → 详情。
|
||||||
|
2. **类型与 API**(`selection.types.ts` / `selection.api.ts`):新增 `SelectionSectorAggregate`;`SelectionResultsQuery` 加 `sector?`;`getSelectionResultSectors()`;`buildSelectionQueryParams` 带 sector。
|
||||||
|
3. **查询层**(`selection.query.ts`):`useSelectionResultSectors`(key:strategy+date,结果就绪后启用);`selectionResultsQueryKey`/`selectionRunQueryKey` 加入 sector 使筛选变化触发重新请求。
|
||||||
|
4. **路由**(`routes/route-tree.tsx`):selectionRoute `validateSearch` 增加 `sector`(string,限长,默认 undefined)。
|
||||||
|
5. **页面接线**(`selection-results-page.tsx`):把 URL 中的 sector 传入 `useSelectionResults`;页面层调用 `useSelectionResultSectors` 并把聚合结果传给 workbench。
|
||||||
|
6. **workbench 板块下拉**:选项 = "全部板块" + 聚合数据,item 渲染 `名称 + 数量徽标`(数量 tabular-nums,倒序由后端保证);选择写 URL;聚合加载后若当前 sector 不在列表中(如切换策略/日期)自动重置为全部;板块无数据时下拉仅剩"全部板块"并禁用。
|
||||||
|
|
||||||
|
## 三、执行方式
|
||||||
|
|
||||||
|
- 按仓库 Trellis 工作流建任务目录并加载 `.trellis/spec/backend`(selection.md 契约:查询不触发重算、错误矩阵等)与前端规范后再动手;先后端(接口+测试)再前端接线,最后 `./dev.sh check` + 前端 lint/tsc 全量质量门禁。
|
||||||
|
- 语义说明:板块数量 = 当次 run 全部选中股票中归属该板块的数量(不随关键词/信号分类变化);列表"筛选结果 N 只" = 包含板块在内的全部过滤叠加后的 `stocks_total`。
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "be29b65c",
|
||||||
|
"configHash": "93a0ae7b",
|
||||||
|
"lockfileHash": "e3b0c442",
|
||||||
|
"browserHash": "4df63514",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
@@ -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 uuid import uuid4
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from zhixing_server.shared.request_coordinator import TushareSourceError
|
||||||
|
|
||||||
from ..domain.facts import aggregate_sector_snapshot
|
from ..domain.facts import aggregate_sector_snapshot
|
||||||
from ..domain.metrics import (
|
from ..domain.metrics import (
|
||||||
AmountNetStrategy,
|
AmountNetStrategy,
|
||||||
@@ -47,11 +49,12 @@ from ..domain.persistence import (
|
|||||||
SectorRadarRepository,
|
SectorRadarRepository,
|
||||||
StockFactRecord,
|
StockFactRecord,
|
||||||
)
|
)
|
||||||
from ..domain.ports import SectorRadarSource
|
from ..domain.ports import ActiveMoneyflowSource, SectorRadarSource
|
||||||
from ..domain.ranking import rank_metric_observations, with_rank_changes
|
from ..domain.ranking import rank_metric_observations, with_rank_changes
|
||||||
from ..domain.source import (
|
from ..domain.source import (
|
||||||
DailyRow,
|
DailyRow,
|
||||||
MoneyflowDcRow,
|
MoneyflowDcRow,
|
||||||
|
MoneyflowRow,
|
||||||
SectorIndexRow,
|
SectorIndexRow,
|
||||||
SectorMemberRow,
|
SectorMemberRow,
|
||||||
SourceContractError,
|
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(
|
self.repository.finalize_publication(
|
||||||
finished,
|
finished,
|
||||||
memberships=collected.memberships,
|
memberships=collected.memberships,
|
||||||
stock_facts=collected.stock_facts,
|
stock_facts=collected.stock_facts,
|
||||||
daily_aggregates=(
|
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),
|
rankings=(RankingRecord(publication_id, ranking) for ranking in rankings),
|
||||||
retry_source_groups=(
|
retry_source_groups=(
|
||||||
@@ -511,7 +523,7 @@ class BuildSectorRadar:
|
|||||||
publication_id,
|
publication_id,
|
||||||
PublicationSourceGroup.CALENDAR,
|
PublicationSourceGroup.CALENDAR,
|
||||||
reusable,
|
reusable,
|
||||||
lambda: self.source.fetch_trade_calendar(target, target),
|
lambda: self.source.fetch_trade_calendar(target - timedelta(days=70), target),
|
||||||
TradeCalendarRow.from_mapping,
|
TradeCalendarRow.from_mapping,
|
||||||
)
|
)
|
||||||
if target not in {row.cal_date for row in calendar.rows if row.is_open}:
|
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(
|
stock_facts = normalize_stock_facts(
|
||||||
target_trade_date=target,
|
target_trade_date=target,
|
||||||
candidate_codes=member_codes,
|
candidate_codes=member_codes,
|
||||||
@@ -594,6 +623,7 @@ class BuildSectorRadar:
|
|||||||
suspensions=suspensions,
|
suspensions=suspensions,
|
||||||
daily=daily,
|
daily=daily,
|
||||||
moneyflow=moneyflow,
|
moneyflow=moneyflow,
|
||||||
|
active_moneyflow=active_moneyflow,
|
||||||
)
|
)
|
||||||
snapshots = (
|
snapshots = (
|
||||||
calendar.snapshots
|
calendar.snapshots
|
||||||
@@ -604,9 +634,11 @@ class BuildSectorRadar:
|
|||||||
+ suspensions.snapshots
|
+ suspensions.snapshots
|
||||||
+ daily.snapshots
|
+ daily.snapshots
|
||||||
+ moneyflow.snapshots
|
+ moneyflow.snapshots
|
||||||
|
+ (active_moneyflow.snapshots if active_moneyflow else ())
|
||||||
)
|
)
|
||||||
return _CollectedInputs(
|
return _CollectedInputs(
|
||||||
target_trade_date=target,
|
target_trade_date=target,
|
||||||
|
indices=concepts.rows + industries.rows,
|
||||||
snapshots=snapshots,
|
snapshots=snapshots,
|
||||||
membership_snapshots=members.snapshots,
|
membership_snapshots=members.snapshots,
|
||||||
memberships=memberships,
|
memberships=memberships,
|
||||||
@@ -723,7 +755,7 @@ class BuildSectorRadar:
|
|||||||
{
|
{
|
||||||
"snapshot_ids": sorted(snapshot.snapshot_id for snapshot in snapshots),
|
"snapshot_ids": sorted(snapshot.snapshot_id for snapshot in snapshots),
|
||||||
"metric_versions": sorted(strategy.metric_version for strategy in self.strategies),
|
"metric_versions": sorted(strategy.metric_version for strategy in self.strategies),
|
||||||
"normalizer": "zhixing_stock_fact_v1",
|
"normalizer": "zhixing_stock_fact_v2",
|
||||||
},
|
},
|
||||||
sort_keys=True,
|
sort_keys=True,
|
||||||
separators=(",", ":"),
|
separators=(",", ":"),
|
||||||
@@ -753,6 +785,7 @@ class BuildSectorRadar:
|
|||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class _CollectedInputs:
|
class _CollectedInputs:
|
||||||
target_trade_date: date
|
target_trade_date: date
|
||||||
|
indices: tuple[SectorIndexRow, ...]
|
||||||
snapshots: tuple[SourceSnapshot, ...]
|
snapshots: tuple[SourceSnapshot, ...]
|
||||||
membership_snapshots: tuple[SourceSnapshot, ...]
|
membership_snapshots: tuple[SourceSnapshot, ...]
|
||||||
memberships: tuple[MembershipRecord, ...]
|
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 __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterator, Sequence
|
from collections.abc import Iterator, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -27,6 +27,7 @@ from ..domain.persistence import (
|
|||||||
StockMembershipEntry,
|
StockMembershipEntry,
|
||||||
)
|
)
|
||||||
from ..domain.ranking import select_percentile_side, select_rank_change_side
|
from ..domain.ranking import select_percentile_side, select_rank_change_side
|
||||||
|
from .details import RankingExtras, ReadRadarDetails
|
||||||
|
|
||||||
ReadStatus = Literal["success", "no_data"]
|
ReadStatus = Literal["success", "no_data"]
|
||||||
|
|
||||||
@@ -106,6 +107,7 @@ class RankingPage:
|
|||||||
definition: RadarMetricDefinition
|
definition: RadarMetricDefinition
|
||||||
rows: tuple[RankedMetric, ...]
|
rows: tuple[RankedMetric, ...]
|
||||||
total: int
|
total: int
|
||||||
|
extras: dict[str, RankingExtras] = field(default_factory=lambda: dict[str, RankingExtras]())
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -281,6 +283,11 @@ class ReadSectorRadar:
|
|||||||
definition=definition,
|
definition=definition,
|
||||||
rows=searched[start : start + query.page_size],
|
rows=searched[start : start + query.page_size],
|
||||||
total=len(searched),
|
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:
|
def stock_membership(self, query: StockSectorQuery) -> StockSectorMembership:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .persistence import MembershipRecord, StockFactRecord
|
|||||||
from .source import (
|
from .source import (
|
||||||
DailyRow,
|
DailyRow,
|
||||||
MoneyflowDcRow,
|
MoneyflowDcRow,
|
||||||
|
MoneyflowRow,
|
||||||
SectorIndexRow,
|
SectorIndexRow,
|
||||||
SectorMemberRow,
|
SectorMemberRow,
|
||||||
SourceContractError,
|
SourceContractError,
|
||||||
@@ -111,6 +112,7 @@ def normalize_stock_facts(
|
|||||||
suspensions: SourceResult[SuspendRow],
|
suspensions: SourceResult[SuspendRow],
|
||||||
daily: SourceResult[DailyRow],
|
daily: SourceResult[DailyRow],
|
||||||
moneyflow: SourceResult[MoneyflowDcRow],
|
moneyflow: SourceResult[MoneyflowDcRow],
|
||||||
|
active_moneyflow: SourceResult[MoneyflowRow] | None = None,
|
||||||
) -> tuple[StockFactRecord, ...]:
|
) -> tuple[StockFactRecord, ...]:
|
||||||
"""Build normalized yuan facts without collapsing missing states into zero.
|
"""Build normalized yuan facts without collapsing missing states into zero.
|
||||||
|
|
||||||
@@ -121,6 +123,7 @@ def normalize_stock_facts(
|
|||||||
suspensions: Same-date suspend/resume events.
|
suspensions: Same-date suspend/resume events.
|
||||||
daily: Same-date stock turnover rows in source units.
|
daily: Same-date stock turnover rows in source units.
|
||||||
moneyflow: Same-date DC main-moneyflow 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:
|
Returns:
|
||||||
One deterministic fact per candidate code under a content-derived revision.
|
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(
|
revision_payload = json.dumps(
|
||||||
{
|
{
|
||||||
"target_trade_date": target_trade_date.isoformat(),
|
"target_trade_date": target_trade_date.isoformat(),
|
||||||
"source_snapshot_ids": source_snapshot_ids,
|
"source_snapshot_ids": source_snapshot_ids,
|
||||||
"normalizer": "zhixing_stock_fact_v1",
|
"normalizer": "zhixing_stock_fact_v2",
|
||||||
},
|
},
|
||||||
sort_keys=True,
|
sort_keys=True,
|
||||||
separators=(",", ":"),
|
separators=(",", ":"),
|
||||||
@@ -192,6 +209,16 @@ def normalize_stock_facts(
|
|||||||
status=status,
|
status=status,
|
||||||
turnover_yuan=turnover_yuan,
|
turnover_yuan=turnover_yuan,
|
||||||
net_amount_yuan=net_amount_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)
|
return tuple(records)
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ class StockFactRecord:
|
|||||||
status: StockFactStatus
|
status: StockFactStatus
|
||||||
turnover_yuan: Decimal | None = None
|
turnover_yuan: Decimal | None = None
|
||||||
net_amount_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:
|
def __post_init__(self) -> None:
|
||||||
"""Preserve source traceability and stock fact null semantics."""
|
"""Preserve source traceability and stock fact null semantics."""
|
||||||
@@ -122,6 +124,8 @@ class StockFactRecord:
|
|||||||
_validate_digest(value, "source_snapshot_id")
|
_validate_digest(value, "source_snapshot_id")
|
||||||
if not self.ts_code.strip():
|
if not self.ts_code.strip():
|
||||||
raise ValueError("ts_code must not be empty")
|
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.turnover_yuan, "turnover_yuan")
|
||||||
_validate_optional_decimal(self.net_amount_yuan, "net_amount_yuan")
|
_validate_optional_decimal(self.net_amount_yuan, "net_amount_yuan")
|
||||||
if self.status is StockFactStatus.AVAILABLE:
|
if self.status is StockFactStatus.AVAILABLE:
|
||||||
@@ -149,14 +153,17 @@ class RankingRecord:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class DailyAggregateRecord:
|
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
|
publication_id: str
|
||||||
aggregate: SectorDailyAggregate
|
aggregate: SectorDailyAggregate
|
||||||
|
pct_change: Decimal | None = None
|
||||||
|
leading_code: str | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
"""Validate the publication foreign identity."""
|
"""Validate the publication foreign identity."""
|
||||||
|
|
||||||
|
_validate_optional_decimal(self.pct_change, "pct_change")
|
||||||
if not self.publication_id.strip():
|
if not self.publication_id.strip():
|
||||||
raise ValueError("publication_id must not be empty")
|
raise ValueError("publication_id must not be empty")
|
||||||
|
|
||||||
@@ -172,6 +179,7 @@ class PublicationSourceGroup(StrEnum):
|
|||||||
SUSPENSIONS = "suspensions"
|
SUSPENSIONS = "suspensions"
|
||||||
DAILY = "daily"
|
DAILY = "daily"
|
||||||
MONEYFLOW_DC = "moneyflow_dc"
|
MONEYFLOW_DC = "moneyflow_dc"
|
||||||
|
MONEYFLOW = "moneyflow"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -291,8 +299,14 @@ class SectorRadarRepository(Protocol):
|
|||||||
|
|
||||||
def list_successful_dates(self) -> Sequence[date]: ...
|
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_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(
|
def load_daily_aggregate_history(
|
||||||
self, target_trade_date: date, *, limit_dates: int
|
self, target_trade_date: date, *, limit_dates: int
|
||||||
) -> Sequence[SectorDailyAggregate]: ...
|
) -> Sequence[SectorDailyAggregate]: ...
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ from __future__ import annotations
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from contextlib import AbstractContextManager
|
from contextlib import AbstractContextManager
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Protocol
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
from .models import SectorType
|
from .models import SectorType
|
||||||
from .source import (
|
from .source import (
|
||||||
CapabilityProbeResult,
|
CapabilityProbeResult,
|
||||||
DailyRow,
|
DailyRow,
|
||||||
MoneyflowDcRow,
|
MoneyflowDcRow,
|
||||||
|
MoneyflowRow,
|
||||||
SectorIndexRow,
|
SectorIndexRow,
|
||||||
SectorMemberRow,
|
SectorMemberRow,
|
||||||
SourceResult,
|
SourceResult,
|
||||||
@@ -51,6 +52,13 @@ class SectorRadarSource(Protocol):
|
|||||||
def probe(self, trade_date: date) -> CapabilityProbeResult: ...
|
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):
|
class SectorRadarLock(Protocol):
|
||||||
"""Repository seam for a target-date advisory lock."""
|
"""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):
|
class CapabilityStatus(StrEnum):
|
||||||
"""Safe capability outcomes that never expose provider error text."""
|
"""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]:
|
def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]:
|
||||||
"""Load every ranking projection owned by one publication."""
|
"""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(
|
def load_daily_aggregate_history(
|
||||||
self, target_trade_date: date, *, limit_dates: int
|
self, target_trade_date: date, *, limit_dates: int
|
||||||
) -> Sequence[SectorDailyAggregate]:
|
) -> Sequence[SectorDailyAggregate]:
|
||||||
|
|||||||
@@ -379,6 +379,8 @@ class PostgresSectorRadarRepository:
|
|||||||
item.status.value,
|
item.status.value,
|
||||||
item.turnover_yuan,
|
item.turnover_yuan,
|
||||||
item.net_amount_yuan,
|
item.net_amount_yuan,
|
||||||
|
item.pct_change,
|
||||||
|
item.active_buy_net_amount_yuan,
|
||||||
)
|
)
|
||||||
for item in items
|
for item in items
|
||||||
)
|
)
|
||||||
@@ -392,6 +394,8 @@ class PostgresSectorRadarRepository:
|
|||||||
"status",
|
"status",
|
||||||
"turnover_yuan",
|
"turnover_yuan",
|
||||||
"net_amount_yuan",
|
"net_amount_yuan",
|
||||||
|
"pct_change",
|
||||||
|
"active_buy_net_amount_yuan",
|
||||||
),
|
),
|
||||||
("fact_revision", "ts_code"),
|
("fact_revision", "ts_code"),
|
||||||
rows,
|
rows,
|
||||||
@@ -422,6 +426,8 @@ class PostgresSectorRadarRepository:
|
|||||||
item.aggregate.turnover_yuan,
|
item.aggregate.turnover_yuan,
|
||||||
item.aggregate.membership_coverage,
|
item.aggregate.membership_coverage,
|
||||||
item.aggregate.moneyflow_coverage,
|
item.aggregate.moneyflow_coverage,
|
||||||
|
item.pct_change,
|
||||||
|
item.leading_code,
|
||||||
)
|
)
|
||||||
for item in items
|
for item in items
|
||||||
)
|
)
|
||||||
@@ -439,6 +445,8 @@ class PostgresSectorRadarRepository:
|
|||||||
"turnover_yuan",
|
"turnover_yuan",
|
||||||
"membership_coverage",
|
"membership_coverage",
|
||||||
"moneyflow_coverage",
|
"moneyflow_coverage",
|
||||||
|
"pct_change",
|
||||||
|
"leading_code",
|
||||||
),
|
),
|
||||||
("publication_id", "sector_type", "sector_code"),
|
("publication_id", "sector_type", "sector_code"),
|
||||||
rows,
|
rows,
|
||||||
@@ -566,6 +574,8 @@ class PostgresSectorRadarRepository:
|
|||||||
"status",
|
"status",
|
||||||
"turnover_yuan",
|
"turnover_yuan",
|
||||||
"net_amount_yuan",
|
"net_amount_yuan",
|
||||||
|
"pct_change",
|
||||||
|
"active_buy_net_amount_yuan",
|
||||||
),
|
),
|
||||||
("fact_revision", "ts_code"),
|
("fact_revision", "ts_code"),
|
||||||
tuple(
|
tuple(
|
||||||
@@ -577,6 +587,8 @@ class PostgresSectorRadarRepository:
|
|||||||
item.status.value,
|
item.status.value,
|
||||||
item.turnover_yuan,
|
item.turnover_yuan,
|
||||||
item.net_amount_yuan,
|
item.net_amount_yuan,
|
||||||
|
item.pct_change,
|
||||||
|
item.active_buy_net_amount_yuan,
|
||||||
)
|
)
|
||||||
for item in stock_items
|
for item in stock_items
|
||||||
),
|
),
|
||||||
@@ -596,6 +608,8 @@ class PostgresSectorRadarRepository:
|
|||||||
"turnover_yuan",
|
"turnover_yuan",
|
||||||
"membership_coverage",
|
"membership_coverage",
|
||||||
"moneyflow_coverage",
|
"moneyflow_coverage",
|
||||||
|
"pct_change",
|
||||||
|
"leading_code",
|
||||||
),
|
),
|
||||||
("publication_id", "sector_type", "sector_code"),
|
("publication_id", "sector_type", "sector_code"),
|
||||||
tuple(
|
tuple(
|
||||||
@@ -611,6 +625,8 @@ class PostgresSectorRadarRepository:
|
|||||||
item.aggregate.turnover_yuan,
|
item.aggregate.turnover_yuan,
|
||||||
item.aggregate.membership_coverage,
|
item.aggregate.membership_coverage,
|
||||||
item.aggregate.moneyflow_coverage,
|
item.aggregate.moneyflow_coverage,
|
||||||
|
item.pct_change,
|
||||||
|
item.leading_code,
|
||||||
)
|
)
|
||||||
for item in aggregate_items
|
for item in aggregate_items
|
||||||
),
|
),
|
||||||
@@ -798,6 +814,19 @@ class PostgresSectorRadarRepository:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return tuple(row[0] for row in rows)
|
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]:
|
def load_rankings(self, publication_id: str) -> Sequence[RankedMetric]:
|
||||||
"""Load all ranking projections for one publication in deterministic order."""
|
"""Load all ranking projections for one publication in deterministic order."""
|
||||||
|
|
||||||
@@ -816,6 +845,31 @@ class PostgresSectorRadarRepository:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return tuple(self._ranking_from_row(row) for row in rows)
|
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(
|
def load_daily_aggregate_history(
|
||||||
self, target_trade_date: date, *, limit_dates: int
|
self, target_trade_date: date, *, limit_dates: int
|
||||||
) -> Sequence[SectorDailyAggregate]:
|
) -> Sequence[SectorDailyAggregate]:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from ..domain.source import (
|
|||||||
CapabilityStatus,
|
CapabilityStatus,
|
||||||
DailyRow,
|
DailyRow,
|
||||||
MoneyflowDcRow,
|
MoneyflowDcRow,
|
||||||
|
MoneyflowRow,
|
||||||
SectorIndexRow,
|
SectorIndexRow,
|
||||||
SectorMemberRow,
|
SectorMemberRow,
|
||||||
SourceContractError,
|
SourceContractError,
|
||||||
@@ -61,6 +62,7 @@ FIELDS: dict[str, tuple[str, ...]] = {
|
|||||||
),
|
),
|
||||||
"suspend_d": ("ts_code", "trade_date", "suspend_timing", "suspend_type"),
|
"suspend_d": ("ts_code", "trade_date", "suspend_timing", "suspend_type"),
|
||||||
"daily": ("ts_code", "trade_date", "close", "pre_close", "pct_chg", "vol", "amount"),
|
"daily": ("ts_code", "trade_date", "close", "pre_close", "pct_chg", "vol", "amount"),
|
||||||
|
"moneyflow": ("trade_date", "ts_code", "net_mf_amount"),
|
||||||
"moneyflow_dc": (
|
"moneyflow_dc": (
|
||||||
"trade_date",
|
"trade_date",
|
||||||
"ts_code",
|
"ts_code",
|
||||||
@@ -80,6 +82,7 @@ ROW_LIMITS: dict[str, int | None] = {
|
|||||||
"suspend_d": None,
|
"suspend_d": None,
|
||||||
"daily": 6_000,
|
"daily": 6_000,
|
||||||
"moneyflow_dc": 6_000,
|
"moneyflow_dc": 6_000,
|
||||||
|
"moneyflow": 6_000,
|
||||||
}
|
}
|
||||||
|
|
||||||
_SECTOR_TYPE_PARAM = {
|
_SECTOR_TYPE_PARAM = {
|
||||||
@@ -312,6 +315,19 @@ class TushareSectorRadarAdapter:
|
|||||||
self._require_unique(rows, key=lambda row: row.ts_code, api_name="daily")
|
self._require_unique(rows, key=lambda row: row.ts_code, api_name="daily")
|
||||||
return SourceResult((snapshot,), tuple(sorted(rows, key=lambda row: row.ts_code)))
|
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(
|
def fetch_moneyflow_dc(
|
||||||
self,
|
self,
|
||||||
trade_date: date,
|
trade_date: date,
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ from decimal import Decimal
|
|||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
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 ....bootstrap.config import Settings, get_settings
|
||||||
|
from ..application.details import (
|
||||||
|
RankingExtras,
|
||||||
|
ReadRadarDetails,
|
||||||
|
SectorDetail,
|
||||||
|
SectorHistory,
|
||||||
|
)
|
||||||
from ..application.read import (
|
from ..application.read import (
|
||||||
STOCK_SECTOR_CONCEPT_LIMIT,
|
STOCK_SECTOR_CONCEPT_LIMIT,
|
||||||
RadarDateIndex,
|
RadarDateIndex,
|
||||||
@@ -101,6 +107,106 @@ class RadarRankingRowResponse(BaseModel):
|
|||||||
rank_percentile: Decimal | None = Field(default=None, gt=0, le=100)
|
rank_percentile: Decimal | None = Field(default=None, gt=0, le=100)
|
||||||
rank_change_days: int = Field(ge=1, le=5)
|
rank_change_days: int = Field(ge=1, le=5)
|
||||||
rank_change: int | None
|
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]:
|
def _empty_ranking_rows() -> list[RadarRankingRowResponse]:
|
||||||
@@ -143,8 +249,8 @@ class StockSectorMembershipResponse(BaseModel):
|
|||||||
ts_code: str
|
ts_code: str
|
||||||
requested_trade_date: date
|
requested_trade_date: date
|
||||||
trade_date: date | None
|
trade_date: date | None
|
||||||
industries: list[SectorRefResponse] = Field(default_factory=list)
|
industries: list[SectorRefResponse] = Field(default_factory=lambda: list[SectorRefResponse]())
|
||||||
concepts: list[SectorRefResponse] = Field(default_factory=list)
|
concepts: list[SectorRefResponse] = Field(default_factory=lambda: list[SectorRefResponse]())
|
||||||
concept_total: int = Field(ge=0)
|
concept_total: int = Field(ge=0)
|
||||||
concept_limit: int = Field(ge=1, le=100)
|
concept_limit: int = Field(ge=1, le=100)
|
||||||
|
|
||||||
@@ -223,6 +329,91 @@ def get_sector_radar_rankings(
|
|||||||
raise _storage_error() from exc
|
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(
|
@sector_radar_router.get(
|
||||||
"/stocks/{ts_code}/membership", response_model=StockSectorMembershipResponse
|
"/stocks/{ts_code}/membership", response_model=StockSectorMembershipResponse
|
||||||
)
|
)
|
||||||
@@ -276,7 +467,12 @@ def _rankings_response(page: RankingPage) -> RadarRankingsResponse:
|
|||||||
page=query.page,
|
page=query.page,
|
||||||
page_size=query.page_size,
|
page_size=query.page_size,
|
||||||
total=page.total,
|
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
|
observation = row.observation
|
||||||
|
extras = extras or RankingExtras()
|
||||||
return RadarRankingRowResponse(
|
return RadarRankingRowResponse(
|
||||||
trade_date=observation.trade_date,
|
trade_date=observation.trade_date,
|
||||||
sector_type=observation.sector_type,
|
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_percentile=row.rank_percentile,
|
||||||
rank_change_days=rank_change_days,
|
rank_change_days=rank_change_days,
|
||||||
rank_change=row.rank_change(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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -646,11 +646,7 @@ class RunZhixingB1:
|
|||||||
|
|
||||||
if self.sector_reader is None:
|
if self.sector_reader is None:
|
||||||
return None
|
return None
|
||||||
return self.sector_reader.sector_member_codes(
|
return self.sector_reader.sector_member_codes(target_trade_date, sector_code)
|
||||||
target_trade_date,
|
|
||||||
sector_code,
|
|
||||||
sector_type="industry",
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_sector_counts(
|
def list_sector_counts(
|
||||||
self,
|
self,
|
||||||
@@ -684,11 +680,7 @@ class RunZhixingB1:
|
|||||||
|
|
||||||
if self.sector_reader is None or not stock_codes:
|
if self.sector_reader is None or not stock_codes:
|
||||||
return SelectionSectorMembership(snapshot_trade_date=None, sector_counts=())
|
return SelectionSectorMembership(snapshot_trade_date=None, sector_counts=())
|
||||||
return self.sector_reader.sector_counts(
|
return self.sector_reader.sector_counts(stock_codes, target_trade_date)
|
||||||
stock_codes,
|
|
||||||
target_trade_date,
|
|
||||||
sector_type="industry",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_item(
|
def _to_item(
|
||||||
|
|||||||
@@ -66,16 +66,12 @@ class SelectionSectorReader(Protocol):
|
|||||||
self,
|
self,
|
||||||
stock_codes: Sequence[str],
|
stock_codes: Sequence[str],
|
||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
*,
|
|
||||||
sector_type: str = "industry",
|
|
||||||
) -> SelectionSectorMembership: ...
|
) -> SelectionSectorMembership: ...
|
||||||
|
|
||||||
def sector_member_codes(
|
def sector_member_codes(
|
||||||
self,
|
self,
|
||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
sector_code: str,
|
sector_code: str,
|
||||||
*,
|
|
||||||
sector_type: str = "industry",
|
|
||||||
) -> tuple[str, ...]: ...
|
) -> tuple[str, ...]: ...
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+5
-22
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
The selection context owns no sector-membership storage. This adapter keeps
|
The selection context owns no sector-membership storage. This adapter keeps
|
||||||
the port contract local to selection while delegating point-in-time reads to
|
the port contract local to selection while delegating point-in-time reads to
|
||||||
the sector-radar application service in the composition root.
|
the sector-radar application service in the composition root. Selection only
|
||||||
|
speaks the sub-industry vocabulary, so the radar sector type is fixed here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -17,20 +18,6 @@ from zhixing_server.modules.selection.domain.runs import (
|
|||||||
SelectionSectorMembership,
|
SelectionSectorMembership,
|
||||||
)
|
)
|
||||||
|
|
||||||
SELECTION_SECTOR_TYPES: dict[str, SectorType] = {
|
|
||||||
"concept": SectorType.CONCEPT,
|
|
||||||
"industry": SectorType.INDUSTRY,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _sector_type(value: str) -> SectorType:
|
|
||||||
"""Map the public sector-type vocabulary onto the radar domain enum."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
return SELECTION_SECTOR_TYPES[value]
|
|
||||||
except KeyError:
|
|
||||||
raise ValueError(f"unsupported sector type: {value}") from None
|
|
||||||
|
|
||||||
|
|
||||||
class SectorRadarSelectionReader:
|
class SectorRadarSelectionReader:
|
||||||
"""Resolve selection sector aggregates through the sector-radar context."""
|
"""Resolve selection sector aggregates through the sector-radar context."""
|
||||||
@@ -42,15 +29,13 @@ class SectorRadarSelectionReader:
|
|||||||
self,
|
self,
|
||||||
stock_codes: Sequence[str],
|
stock_codes: Sequence[str],
|
||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
*,
|
|
||||||
sector_type: str = "industry",
|
|
||||||
) -> SelectionSectorMembership:
|
) -> SelectionSectorMembership:
|
||||||
"""Return per-sector stock counts for one run's selected stocks."""
|
"""Return per-sector stock counts for one run's selected stocks."""
|
||||||
|
|
||||||
snapshot = self._reader.sector_counts(
|
snapshot = self._reader.sector_counts(
|
||||||
stock_codes,
|
stock_codes,
|
||||||
target_trade_date,
|
target_trade_date,
|
||||||
sector_type=_sector_type(sector_type),
|
sector_type=SectorType.INDUSTRY,
|
||||||
)
|
)
|
||||||
return SelectionSectorMembership(
|
return SelectionSectorMembership(
|
||||||
snapshot_trade_date=snapshot.trade_date,
|
snapshot_trade_date=snapshot.trade_date,
|
||||||
@@ -68,17 +53,15 @@ class SectorRadarSelectionReader:
|
|||||||
self,
|
self,
|
||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
sector_code: str,
|
sector_code: str,
|
||||||
*,
|
|
||||||
sector_type: str = "industry",
|
|
||||||
) -> tuple[str, ...]:
|
) -> tuple[str, ...]:
|
||||||
"""Return one sector's member stock codes on the aligned snapshot."""
|
"""Return one sector's member stock codes on the aligned snapshot."""
|
||||||
|
|
||||||
snapshot = self._reader.sector_member_codes(
|
snapshot = self._reader.sector_member_codes(
|
||||||
target_trade_date,
|
target_trade_date,
|
||||||
sector_code,
|
sector_code,
|
||||||
sector_type=_sector_type(sector_type),
|
sector_type=SectorType.INDUSTRY,
|
||||||
)
|
)
|
||||||
return snapshot.stock_codes
|
return snapshot.stock_codes
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["SELECTION_SECTOR_TYPES", "SectorRadarSelectionReader"]
|
__all__ = ["SectorRadarSelectionReader"]
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ from dataclasses import replace
|
|||||||
from datetime import UTC, date, datetime, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
import pytest
|
import pytest
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.config import Config
|
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 (
|
from zhixing_server.modules.sector_radar.domain.models import (
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
PublicationStatus,
|
PublicationStatus,
|
||||||
@@ -35,6 +36,11 @@ def prepare_database(database_url: str) -> None:
|
|||||||
config = Config(str(server_root / "alembic.ini"))
|
config = Config(str(server_root / "alembic.ini"))
|
||||||
sqlalchemy_url = sqlalchemy_database_url(database_url)
|
sqlalchemy_url = sqlalchemy_database_url(database_url)
|
||||||
config.set_main_option("sqlalchemy.url", sqlalchemy_url.replace("%", "%%"))
|
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")
|
command.upgrade(config, "head")
|
||||||
|
|
||||||
|
|
||||||
@@ -111,12 +117,22 @@ def test_postgres_sector_radar_revisions_and_last_good() -> None:
|
|||||||
status=StockFactStatus.AVAILABLE,
|
status=StockFactStatus.AVAILABLE,
|
||||||
turnover_yuan=Decimal("1000"),
|
turnover_yuan=Decimal("1000"),
|
||||||
net_amount_yuan=Decimal("100"),
|
net_amount_yuan=Decimal("100"),
|
||||||
|
pct_change=Decimal("1.25"),
|
||||||
|
active_buy_net_amount_yuan=Decimal("-25000"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
).inserted
|
).inserted
|
||||||
== 1
|
== 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(
|
running = RadarPublication(
|
||||||
publication_id=publication_ids[0],
|
publication_id=publication_ids[0],
|
||||||
target_trade_date=TARGET_DATE,
|
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 second.status == "success"
|
||||||
assert len(history) == 2
|
assert len(history) == 2
|
||||||
assert all(item.net_amount_yuan == Decimal(300_000) for item in history)
|
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):
|
with pytest.raises(ValueError):
|
||||||
reader.sector_member_codes(TARGET_DATE, " ")
|
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
|
||||||
|
|||||||
@@ -41,17 +41,15 @@ class FakeSectorReader:
|
|||||||
) -> None:
|
) -> None:
|
||||||
self.counts = counts
|
self.counts = counts
|
||||||
self.member_codes = member_codes
|
self.member_codes = member_codes
|
||||||
self.count_calls: list[tuple[tuple[str, ...], date, str]] = []
|
self.count_calls: list[tuple[tuple[str, ...], date]] = []
|
||||||
self.member_calls: list[tuple[date, str, str]] = []
|
self.member_calls: list[tuple[date, str]] = []
|
||||||
|
|
||||||
def sector_counts(
|
def sector_counts(
|
||||||
self,
|
self,
|
||||||
stock_codes: Sequence[str],
|
stock_codes: Sequence[str],
|
||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
*,
|
|
||||||
sector_type: str = "industry",
|
|
||||||
) -> SelectionSectorMembership:
|
) -> SelectionSectorMembership:
|
||||||
self.count_calls.append((tuple(stock_codes), target_trade_date, sector_type))
|
self.count_calls.append((tuple(stock_codes), target_trade_date))
|
||||||
return SelectionSectorMembership(
|
return SelectionSectorMembership(
|
||||||
snapshot_trade_date=target_trade_date,
|
snapshot_trade_date=target_trade_date,
|
||||||
sector_counts=self.counts,
|
sector_counts=self.counts,
|
||||||
@@ -61,10 +59,8 @@ class FakeSectorReader:
|
|||||||
self,
|
self,
|
||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
sector_code: str,
|
sector_code: str,
|
||||||
*,
|
|
||||||
sector_type: str = "industry",
|
|
||||||
) -> tuple[str, ...]:
|
) -> tuple[str, ...]:
|
||||||
self.member_calls.append((target_trade_date, sector_code, sector_type))
|
self.member_calls.append((target_trade_date, sector_code))
|
||||||
return self.member_codes
|
return self.member_codes
|
||||||
|
|
||||||
|
|
||||||
@@ -161,7 +157,7 @@ def test_list_sector_counts_aggregates_only_selected_stocks() -> None:
|
|||||||
aggregates = service.list_sector_counts("zhixing_b1")
|
aggregates = service.list_sector_counts("zhixing_b1")
|
||||||
|
|
||||||
assert isinstance(aggregates, SelectionSectorAggregates)
|
assert isinstance(aggregates, SelectionSectorAggregates)
|
||||||
assert sector_reader.count_calls == [(("000001.SZ", "000002.SZ"), TARGET, "industry")]
|
assert sector_reader.count_calls == [(("000001.SZ", "000002.SZ"), TARGET)]
|
||||||
assert aggregates.snapshot_trade_date == TARGET
|
assert aggregates.snapshot_trade_date == TARGET
|
||||||
assert aggregates.sector_type == "industry"
|
assert aggregates.sector_type == "industry"
|
||||||
assert aggregates.sectors == sector_reader.counts
|
assert aggregates.sectors == sector_reader.counts
|
||||||
@@ -198,7 +194,7 @@ def test_get_latest_resolves_sector_filter_against_run_snapshot() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert run is store.run
|
assert run is store.run
|
||||||
assert sector_reader.member_calls == [(TARGET, "BK0001.DC", "industry")]
|
assert sector_reader.member_calls == [(TARGET, "BK0001.DC")]
|
||||||
assert store.sector_codes_seen == ("000001.SZ", "000002.SZ")
|
assert store.sector_codes_seen == ("000001.SZ", "000002.SZ")
|
||||||
assert store.queries[-1].sector is None
|
assert store.queries[-1].sector is None
|
||||||
assert store.queries[-1].page == 2
|
assert store.queries[-1].page == 2
|
||||||
@@ -239,7 +235,7 @@ def test_get_run_resolves_sector_filter_against_run_snapshot() -> None:
|
|||||||
run = service.get_run("run-1", query=SelectionResultQuery(sector="BK0001.DC"))
|
run = service.get_run("run-1", query=SelectionResultQuery(sector="BK0001.DC"))
|
||||||
|
|
||||||
assert run is store.run
|
assert run is store.run
|
||||||
assert sector_reader.member_calls == [(TARGET, "BK0001.DC", "industry")]
|
assert sector_reader.member_calls == [(TARGET, "BK0001.DC")]
|
||||||
assert store.sector_codes_seen == ("000001.SZ",)
|
assert store.sector_codes_seen == ("000001.SZ",)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ const requestJson = vi.hoisted(() => vi.fn())
|
|||||||
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
|
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getSectorRadarHistory,
|
||||||
|
getSectorRadarDetail,
|
||||||
|
parseRadarDetailResponse,
|
||||||
getSectorRadarDates,
|
getSectorRadarDates,
|
||||||
getSectorRadarRankings,
|
getSectorRadarRankings,
|
||||||
getStockSectorMembership,
|
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", () => {
|
describe("sector radar API adapters", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
requestJson.mockReset()
|
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 () => {
|
it("forwards AbortSignal and normalizes decimal publication fields", async () => {
|
||||||
const signal = new AbortController().signal
|
const signal = new AbortController().signal
|
||||||
requestJson.mockResolvedValue({
|
requestJson.mockResolvedValue({
|
||||||
@@ -192,12 +290,6 @@ describe("stock sector membership adapter", () => {
|
|||||||
requested_trade_date: "2026-08-28",
|
requested_trade_date: "2026-08-28",
|
||||||
trade_date: "2026-08-28",
|
trade_date: "2026-08-28",
|
||||||
industries: [{ code: "BK0901.DC", name: "银行" }],
|
industries: [{ code: "BK0901.DC", name: "银行" }],
|
||||||
concepts: [
|
|
||||||
{ code: "BK0001.DC", name: "机器人" },
|
|
||||||
{ code: "BK0002.DC", name: "人工智能" },
|
|
||||||
],
|
|
||||||
concept_total: 2,
|
|
||||||
concept_limit: 20,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it("requests the snake-case endpoint and validates the payload", async () => {
|
it("requests the snake-case endpoint and validates the payload", async () => {
|
||||||
@@ -218,8 +310,6 @@ describe("stock sector membership adapter", () => {
|
|||||||
status: "no_data",
|
status: "no_data",
|
||||||
trade_date: null,
|
trade_date: null,
|
||||||
industries: [],
|
industries: [],
|
||||||
concepts: [],
|
|
||||||
concept_total: 0,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await getStockSectorMembership("000001.SZ", "2026-08-28")
|
const result = await getStockSectorMembership("000001.SZ", "2026-08-28")
|
||||||
@@ -237,6 +327,8 @@ describe("stock sector membership adapter", () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
getStockSectorMembership("000001.SZ", "2026-08-28"),
|
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,
|
radarViews,
|
||||||
sectorTypes,
|
sectorTypes,
|
||||||
type RadarDatesResponse,
|
type RadarDatesResponse,
|
||||||
|
type RadarSectorQuery,
|
||||||
|
type RadarHistoryResponse,
|
||||||
|
type RadarHistoryMetric,
|
||||||
|
type RadarDetailResponse,
|
||||||
|
type RadarMember,
|
||||||
type RadarMetricDefinition,
|
type RadarMetricDefinition,
|
||||||
type RadarPublication,
|
type RadarPublication,
|
||||||
type RadarRankingRow,
|
type RadarRankingRow,
|
||||||
@@ -148,7 +153,7 @@ export function parseRadarRankingsResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch one stock's industries and concepts from the last-good build snapshot.
|
* Fetch one stock's sub-industries from the last-good build snapshot.
|
||||||
*
|
*
|
||||||
* @param tsCode - Stock code such as `000001.SZ`.
|
* @param tsCode - Stock code such as `000001.SZ`.
|
||||||
* @param tradeDate - Requested trade date; the backend answers with the
|
* @param tradeDate - Requested trade date; the backend answers with the
|
||||||
@@ -176,7 +181,11 @@ export function parseStockSectorMembershipResponse(
|
|||||||
): StockSectorMembershipResponse {
|
): StockSectorMembershipResponse {
|
||||||
const record = readRecord(value, "membership")
|
const record = readRecord(value, "membership")
|
||||||
return {
|
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"),
|
ts_code: readNonEmptyString(record.ts_code, "membership.ts_code"),
|
||||||
requested_trade_date: readDate(
|
requested_trade_date: readDate(
|
||||||
record.requested_trade_date,
|
record.requested_trade_date,
|
||||||
@@ -184,19 +193,6 @@ export function parseStockSectorMembershipResponse(
|
|||||||
),
|
),
|
||||||
trade_date: readNullableDate(record.trade_date, "membership.trade_date"),
|
trade_date: readNullableDate(record.trade_date, "membership.trade_date"),
|
||||||
industries: readSectorRefs(record.industries, "membership.industries"),
|
industries: readSectorRefs(record.industries, "membership.industries"),
|
||||||
concepts: readSectorRefs(record.concepts, "membership.concepts"),
|
|
||||||
concept_total: readIntegerInRange(
|
|
||||||
record.concept_total,
|
|
||||||
0,
|
|
||||||
Number.MAX_SAFE_INTEGER,
|
|
||||||
"membership.concept_total",
|
|
||||||
),
|
|
||||||
concept_limit: readIntegerInRange(
|
|
||||||
record.concept_limit,
|
|
||||||
1,
|
|
||||||
100,
|
|
||||||
"membership.concept_limit",
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +305,33 @@ function readRankingRow(value: unknown, index: number): RadarRankingRow {
|
|||||||
record.metric_value,
|
record.metric_value,
|
||||||
`${path}.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`),
|
quality: readEnum(record.quality, radarMetricQualities, `${path}.quality`),
|
||||||
member_count: readIntegerInRange(
|
member_count: readIntegerInRange(
|
||||||
record.member_count,
|
record.member_count,
|
||||||
@@ -427,7 +450,8 @@ function readFiniteNumber(value: unknown, path: string): number {
|
|||||||
const number =
|
const number =
|
||||||
typeof value === "number"
|
typeof value === "number"
|
||||||
? value
|
? value
|
||||||
: typeof value === "string" && value.trim().length > 0
|
: typeof value === "string" &&
|
||||||
|
/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(value.trim())
|
||||||
? Number(value)
|
? Number(value)
|
||||||
: Number.NaN
|
: Number.NaN
|
||||||
if (!Number.isFinite(number)) throw contractError(path, "must be finite")
|
if (!Number.isFinite(number)) throw contractError(path, "must be finite")
|
||||||
@@ -483,3 +507,240 @@ function readNullablePositiveNumber(
|
|||||||
function contractError(path: string, reason: string): Error {
|
function contractError(path: string, reason: string): Error {
|
||||||
return new Error(`Invalid sector radar response: ${path} ${reason}`)
|
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 useQuery = vi.hoisted(() => vi.fn())
|
||||||
const useInfiniteQuery = vi.hoisted(() => vi.fn())
|
const useInfiniteQuery = vi.hoisted(() => vi.fn())
|
||||||
const api = vi.hoisted(() => ({
|
const api = vi.hoisted(() => ({
|
||||||
|
getSectorRadarHistory: vi.fn(),
|
||||||
|
getSectorRadarDetail: vi.fn(),
|
||||||
getSectorRadarDates: vi.fn(),
|
getSectorRadarDates: vi.fn(),
|
||||||
getSectorRadarRankings: vi.fn(),
|
getSectorRadarRankings: vi.fn(),
|
||||||
}))
|
}))
|
||||||
@@ -11,6 +13,9 @@ vi.mock("@tanstack/react-query", () => ({ useInfiniteQuery, useQuery }))
|
|||||||
vi.mock("./sector-radar.api", () => api)
|
vi.mock("./sector-radar.api", () => api)
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
sectorRadarSectorQueryKey,
|
||||||
|
useSectorRadarDetail,
|
||||||
|
useSectorRadarHistory,
|
||||||
sectorRadarDatesQueryKey,
|
sectorRadarDatesQueryKey,
|
||||||
sectorRadarRankingsQueryKey,
|
sectorRadarRankingsQueryKey,
|
||||||
useSectorRadarDates,
|
useSectorRadarDates,
|
||||||
@@ -36,6 +41,42 @@ describe("sector radar query hooks", () => {
|
|||||||
useInfiniteQuery.mockImplementation((options) => options)
|
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", () => {
|
it("keeps filters in one ranking key without splitting cached pages", () => {
|
||||||
expect(sectorRadarDatesQueryKey).toEqual(["sectorRadar", "dates"])
|
expect(sectorRadarDatesQueryKey).toEqual(["sectorRadar", "dates"])
|
||||||
expect(sectorRadarRankingsQueryKey(query)).toEqual([
|
expect(sectorRadarRankingsQueryKey(query)).toEqual([
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ import {
|
|||||||
type InfiniteData,
|
type InfiniteData,
|
||||||
} from "@tanstack/react-query"
|
} from "@tanstack/react-query"
|
||||||
|
|
||||||
import { getSectorRadarDates, getSectorRadarRankings, getStockSectorMembership } from "./sector-radar.api"
|
import {
|
||||||
|
getSectorRadarDates,
|
||||||
|
getSectorRadarRankings,
|
||||||
|
getStockSectorMembership,
|
||||||
|
getSectorRadarHistory,
|
||||||
|
getSectorRadarDetail,
|
||||||
|
} from "./sector-radar.api"
|
||||||
import type {
|
import type {
|
||||||
RadarRankingsQuery,
|
RadarRankingsQuery,
|
||||||
RadarRankingsResponse,
|
RadarRankingsResponse,
|
||||||
|
RadarSectorQuery,
|
||||||
} from "./sector-radar.types"
|
} from "./sector-radar.types"
|
||||||
|
|
||||||
export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const
|
export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const
|
||||||
@@ -24,7 +31,7 @@ export const stockSectorMembershipQueryKey = (
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read one stock's point-in-time industries and concepts for a detail view.
|
* Read one stock's point-in-time sub-industries for a detail view.
|
||||||
*
|
*
|
||||||
* Disabled until both the stock code and target trade date are known; the
|
* Disabled until both the stock code and target trade date are known; the
|
||||||
* query stays cached per code-date pair so switching selections refetches
|
* query stays cached per code-date pair so switching selections refetches
|
||||||
@@ -92,3 +99,31 @@ export function useSectorRadarRankings(query: RadarRankingsQuery) {
|
|||||||
queryKey: sectorRadarRankingsQueryKey(query),
|
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_percentile: number | null
|
||||||
rank_change_days: number
|
rank_change_days: number
|
||||||
rank_change: number | null
|
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 {
|
export interface RadarRankingsResponse {
|
||||||
@@ -123,7 +128,69 @@ export interface StockSectorMembershipResponse {
|
|||||||
requested_trade_date: string
|
requested_trade_date: string
|
||||||
trade_date: string | null
|
trade_date: string | null
|
||||||
industries: StockSectorRef[]
|
industries: StockSectorRef[]
|
||||||
concepts: StockSectorRef[]
|
}
|
||||||
concept_total: number
|
|
||||||
concept_limit: number
|
export interface RadarSectorQuery {
|
||||||
|
sectorType: SectorType
|
||||||
|
sectorCode: string
|
||||||
|
tradeDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadarHistoryMetric {
|
||||||
|
rank_position: number | null
|
||||||
|
rank_percentile: number | null
|
||||||
|
pool_size: number
|
||||||
|
metric_value: number | null
|
||||||
|
missing: boolean
|
||||||
|
in_top: boolean
|
||||||
|
in_bottom: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RadarMetricSummary = Record<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 navigate = vi.hoisted(() => vi.fn())
|
||||||
const useSectorRadarDates = vi.fn()
|
const useSectorRadarDates = vi.fn()
|
||||||
const useSectorRadarRankings = vi.fn()
|
const useSectorRadarRankings = vi.fn()
|
||||||
|
const useSectorRadarHistory = vi.fn()
|
||||||
const refetchDates = vi.fn()
|
const refetchDates = vi.fn()
|
||||||
const refetchRankings = vi.fn()
|
const refetchRankings = vi.fn()
|
||||||
const fetchNextPage = vi.fn()
|
const fetchNextPage = vi.fn()
|
||||||
@@ -24,6 +25,7 @@ vi.mock("@tanstack/react-router", () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({
|
vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({
|
||||||
|
useSectorRadarHistory: (...args: unknown[]) => useSectorRadarHistory(...args),
|
||||||
useSectorRadarDates: () => useSectorRadarDates(),
|
useSectorRadarDates: () => useSectorRadarDates(),
|
||||||
useSectorRadarRankings: (...args: unknown[]) =>
|
useSectorRadarRankings: (...args: unknown[]) =>
|
||||||
useSectorRadarRankings(...args),
|
useSectorRadarRankings(...args),
|
||||||
@@ -91,6 +93,11 @@ const rankingsResponse: RadarRankingsResponse = {
|
|||||||
rank_percentile: 100,
|
rank_percentile: 100,
|
||||||
rank_change_days: 1,
|
rank_change_days: 1,
|
||||||
rank_change: 3,
|
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",
|
trade_date: "2026-08-28",
|
||||||
@@ -111,6 +118,11 @@ const rankingsResponse: RadarRankingsResponse = {
|
|||||||
rank_percentile: null,
|
rank_percentile: null,
|
||||||
rank_change_days: 1,
|
rank_change_days: 1,
|
||||||
rank_change: null,
|
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,
|
refetch: refetchDates,
|
||||||
})
|
})
|
||||||
useSectorRadarRankings.mockReturnValue(rankingQueryResult())
|
useSectorRadarRankings.mockReturnValue(rankingQueryResult())
|
||||||
|
useSectorRadarHistory.mockReturnValue({
|
||||||
|
isPending: false,
|
||||||
|
isError: false,
|
||||||
|
data: { status: "no_data" },
|
||||||
|
})
|
||||||
navigate.mockReset()
|
navigate.mockReset()
|
||||||
refetchDates.mockReset()
|
refetchDates.mockReset()
|
||||||
refetchRankings.mockReset()
|
refetchRankings.mockReset()
|
||||||
@@ -175,14 +192,21 @@ describe("SectorRadarPage", () => {
|
|||||||
|
|
||||||
expect(screen.getAllByText("+12.5 亿元")[0]).toBeInTheDocument()
|
expect(screen.getAllByText("+12.5 亿元")[0]).toBeInTheDocument()
|
||||||
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||||
expect(screen.getAllByText("19/20")[0]).toBeInTheDocument()
|
expect(screen.queryByText("19/20")).not.toBeInTheDocument()
|
||||||
expect(screen.getAllByText("100%")[0]).toBeInTheDocument()
|
expect(screen.queryByText("BK0001.DC")).not.toBeInTheDocument()
|
||||||
expect(
|
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)
|
).toHaveLength(2)
|
||||||
expect(screen.getAllByRole("columnheader", { name: "样本" })).toHaveLength(
|
expect(
|
||||||
2,
|
screen.getAllByRole("columnheader", { name: "单日流入率" }),
|
||||||
)
|
).toHaveLength(2)
|
||||||
|
expect(screen.getAllByText("-2%")).toHaveLength(2)
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("columnheader", { name: "资金覆盖率" }),
|
screen.queryByRole("columnheader", { name: "资金覆盖率" }),
|
||||||
).not.toBeInTheDocument()
|
).not.toBeInTheDocument()
|
||||||
@@ -206,6 +230,66 @@ describe("SectorRadarPage", () => {
|
|||||||
).not.toBeInTheDocument()
|
).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", () => {
|
it("resets filters to the first batch in router search state", () => {
|
||||||
routeSearch = { ...routeSearch, page: 3 }
|
routeSearch = { ...routeSearch, page: 3 }
|
||||||
render(<SectorRadarPage />)
|
render(<SectorRadarPage />)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AlertTriangle, Database, RefreshCw } from "lucide-react"
|
import { AlertTriangle, Database, RefreshCw } from "lucide-react"
|
||||||
import { useRef } from "react"
|
import { useRef, useState } from "react"
|
||||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||||
|
|
||||||
import { PageLayout } from "@/app/layout/page-layout"
|
import { PageLayout } from "@/app/layout/page-layout"
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from "@/features/sector-radar/api/sector-radar.query"
|
} from "@/features/sector-radar/api/sector-radar.query"
|
||||||
import type {
|
import type {
|
||||||
RadarMetricKind,
|
RadarMetricKind,
|
||||||
RadarMetricUnit,
|
|
||||||
RadarPublication,
|
RadarPublication,
|
||||||
RadarRankingRow,
|
RadarRankingRow,
|
||||||
RadarRankingsQuery,
|
RadarRankingsQuery,
|
||||||
@@ -36,19 +35,23 @@ import {
|
|||||||
} from "@/shared/ui/select"
|
} from "@/shared/ui/select"
|
||||||
import { Skeleton } from "@/shared/ui/skeleton"
|
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 = [
|
const sectorTypeOptions = [
|
||||||
{ label: "概念", value: "concept" },
|
{ label: "概念", value: "concept" },
|
||||||
{ label: "行业", value: "industry" },
|
{ label: "行业", value: "industry" },
|
||||||
] as const
|
] as const
|
||||||
const viewOptions = [
|
const viewOptions = [
|
||||||
{ label: "波段资金率", value: "swing" },
|
{ label: "波段资金率", value: "swing" },
|
||||||
{ label: "单日资金率", value: "ratio" },
|
{ label: "单日流入率", value: "ratio" },
|
||||||
{ label: "单日净额", value: "amount" },
|
{ label: "单日净额", value: "amount" },
|
||||||
{ label: "排名变化", value: "rank_change" },
|
{ label: "排名变化", value: "rank_change" },
|
||||||
] as const
|
] as const
|
||||||
const metricOptions = [
|
const metricOptions = [
|
||||||
{ label: "主力净额", value: "amount" },
|
{ label: "主力净额", value: "amount" },
|
||||||
{ label: "单日资金率", value: "ratio" },
|
{ label: "单日流入率", value: "ratio" },
|
||||||
{ label: "波段资金率", value: "swing" },
|
{ label: "波段资金率", value: "swing" },
|
||||||
] as const
|
] as const
|
||||||
const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({
|
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 bottomRows = bottom.data?.pages.flatMap((page) => page.rows) ?? []
|
||||||
const rowCount = Math.max(topRows.length, bottomRows.length)
|
const rowCount = Math.max(topRows.length, bottomRows.length)
|
||||||
const rankChangeView = response.view === "rank_change"
|
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 })
|
const loadMoreRequestPending = useRef({ top: false, bottom: false })
|
||||||
function requestLoadMore(query: RankingQueryResult) {
|
function requestLoadMore(query: RankingQueryResult) {
|
||||||
const side = query === top ? "top" : "bottom"
|
const side = query === top ? "top" : "bottom"
|
||||||
@@ -401,7 +406,7 @@ function RadarTable({
|
|||||||
{response.sector_type === "concept" ? "概念" : "行业"}板块资金双榜
|
{response.sector_type === "concept" ? "概念" : "行业"}板块资金双榜
|
||||||
</caption>
|
</caption>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
{Array.from({ length: 8 }, (_, i) => (
|
{Array.from({ length: columns }, (_, i) => (
|
||||||
<col
|
<col
|
||||||
key={i}
|
key={i}
|
||||||
className={i === 3 || i === 4 ? "w-[120px]" : "w-[100px]"}
|
className={i === 3 || i === 4 ? "w-[120px]" : "w-[100px]"}
|
||||||
@@ -412,7 +417,7 @@ function RadarTable({
|
|||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
className="px-3 py-1 text-left font-medium"
|
className="px-3 py-1 text-left font-medium"
|
||||||
colSpan={3}
|
colSpan={(columns - 2) / 2}
|
||||||
scope="colgroup"
|
scope="colgroup"
|
||||||
>
|
>
|
||||||
资金进攻榜 TOP 10%
|
资金进攻榜 TOP 10%
|
||||||
@@ -426,12 +431,49 @@ function RadarTable({
|
|||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-3 py-1 text-right font-medium"
|
className="px-3 py-1 text-right font-medium"
|
||||||
colSpan={3}
|
colSpan={(columns - 2) / 2}
|
||||||
scope="colgroup"
|
scope="colgroup"
|
||||||
>
|
>
|
||||||
BOTTOM 10% 资金撤离榜
|
BOTTOM 10% 资金撤离榜
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<th className="px-3 py-1 text-left font-medium" scope="col">
|
<th className="px-3 py-1 text-left font-medium" scope="col">
|
||||||
排名 / 板块
|
排名 / 板块
|
||||||
@@ -464,11 +506,14 @@ function RadarTable({
|
|||||||
排名 / 板块
|
排名 / 板块
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border/60">
|
<tbody className="divide-y divide-border/60">
|
||||||
{Array.from({ length: rowCount }, (_, i) => (
|
{Array.from({ length: rowCount }, (_, i) => (
|
||||||
<RadarMirrorRow
|
<RadarMirrorRow
|
||||||
key={i}
|
key={`${response.publication?.publication_id}:${response.view}:${topRows[i]?.sector_code}:${bottomRows[i]?.sector_code}`}
|
||||||
|
view={response.view}
|
||||||
rankChangeView={rankChangeView}
|
rankChangeView={rankChangeView}
|
||||||
top={topRows[i]}
|
top={topRows[i]}
|
||||||
bottom={bottomRows[i]}
|
bottom={bottomRows[i]}
|
||||||
@@ -478,7 +523,7 @@ function RadarTable({
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
className="px-3 py-8 text-center text-muted-foreground"
|
className="px-3 py-8 text-center text-muted-foreground"
|
||||||
colSpan={8}
|
colSpan={columns}
|
||||||
>
|
>
|
||||||
没有符合当前筛选条件的板块。
|
没有符合当前筛选条件的板块。
|
||||||
</td>
|
</td>
|
||||||
@@ -543,11 +588,52 @@ function RadarMirrorRow({
|
|||||||
top,
|
top,
|
||||||
bottom,
|
bottom,
|
||||||
rankChangeView,
|
rankChangeView,
|
||||||
|
view,
|
||||||
}: {
|
}: {
|
||||||
|
view: RadarView
|
||||||
top?: RadarRankingRow
|
top?: RadarRankingRow
|
||||||
bottom?: RadarRankingRow
|
bottom?: RadarRankingRow
|
||||||
rankChangeView: boolean
|
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 (
|
return (
|
||||||
<tr className="hover:bg-muted/40">
|
<tr className="hover:bg-muted/40">
|
||||||
<RankCells row={top} align="left" />
|
<RankCells row={top} align="left" />
|
||||||
@@ -561,6 +647,76 @@ function RadarMirrorRow({
|
|||||||
</tr>
|
</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({
|
function RankCells({
|
||||||
row,
|
row,
|
||||||
align,
|
align,
|
||||||
@@ -626,23 +782,10 @@ function MetricCell({
|
|||||||
const value = row
|
const value = row
|
||||||
? rankChangeView
|
? rankChangeView
|
||||||
? formatRankChange(row.rank_change)
|
? 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 borderClass = direction === "in" ? "border-l" : "border-r"
|
||||||
const toneClass =
|
const toneClass = `${radarValueTone(rankChangeView ? row?.rank_change : row?.metric_value)} bg-muted/30`
|
||||||
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"
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
className={`${borderClass} border-border px-3 py-1.5 ${direction === "in" ? "text-right" : "text-left"} font-medium tabular-nums ${toneClass}`}
|
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() {
|
function RadarLoading() {
|
||||||
return (
|
return (
|
||||||
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
|
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
|
||||||
|
|||||||
@@ -56,12 +56,6 @@ describe("SignalDetailPanel", () => {
|
|||||||
requested_trade_date: "2026-08-28",
|
requested_trade_date: "2026-08-28",
|
||||||
trade_date: "2026-08-28",
|
trade_date: "2026-08-28",
|
||||||
industries: [{ code: "BK0901.DC", name: "银行" }],
|
industries: [{ code: "BK0901.DC", name: "银行" }],
|
||||||
concepts: [
|
|
||||||
{ code: "BK0001.DC", name: "机器人" },
|
|
||||||
{ code: "BK0002.DC", name: "人工智能" },
|
|
||||||
],
|
|
||||||
concept_total: 2,
|
|
||||||
concept_limit: 20,
|
|
||||||
},
|
},
|
||||||
isError: false,
|
isError: false,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
@@ -82,9 +76,6 @@ describe("SignalDetailPanel", () => {
|
|||||||
requested_trade_date: "2026-08-28",
|
requested_trade_date: "2026-08-28",
|
||||||
trade_date: null,
|
trade_date: null,
|
||||||
industries: [],
|
industries: [],
|
||||||
concepts: [],
|
|
||||||
concept_total: 0,
|
|
||||||
concept_limit: 20,
|
|
||||||
},
|
},
|
||||||
isError: false,
|
isError: false,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user