feat(sector-radar): add weighted scores and rank-change views

This commit is contained in:
yuxuanhui
2026-09-21 23:57:55 +08:00
parent 669e89d3c3
commit b9981aa48d
45 changed files with 2468 additions and 320 deletions
@@ -0,0 +1,36 @@
# 现有实现与改动定位
只读研究已完成:`/root/radar_storage_research`、`/root/radar_ui_research`,主会话另行阅读领域算法、models、read、build 和 HTTP 相关契约。
## 领域与应用
- `domain/metrics.py:140` 起,旧 SwingEqualThreeToTenStrategy 取 3–10 日八个窗口的累计净额/累计成交额,再等权平均;需采用新版本,不能把它误认作前置研究的两个窗口。
- `domain/ranking.py:42` 的排序键按 observation.value 排序;`with_rank_changes` 同版本过去名次减当前名次;select_rank_change_side 已实现 ceil(N×10%),不需另写重复算法。
- `application/build.py:694` 的 _rank 读取 9 个历史成功日期再拼当前日;历史排名取前 5 次成功发布,并非严格前 5 交易日。
- `application/build.py:753` 的 input_hash 包含策略版本;重算可沿用版本隔离与幂等思路。
- `application/read.py` 的 _METRIC_DEFINITIONS 和 `application/details.py:25` 的 _METRIC_VERSIONS 都硬编码当前策略;升级必须让旧发布保持可读。
- `application/read.py:289` 的 extras 只服务 amount/ratio,swing/rank_change 的涨跌幅、净额和辅助字段需要补齐。
- `application/details.py:173` 的 history_data 已按发布来源中的交易日历补出缺口,适合统一严格交易日语义;必须避免每行单独查库。
## 存储
- `domain/persistence.py:294–355` 定义 aggregate、ranking、history 读写契约。
- `infrastructure/postgres.py:532–710` 为事务发布,`:741–778` 为 ranking 批写,`:1145–1173` 为序列化,`:1239–1267` 为固定列反序列化。
- ranking SELECT 位于 `postgres.py:871–964`、`:996–1033`,当前只含 metric_value,没有 score。
- `postgres.py:966–994` 的 aggregate history 只返回原始金额/coverage,不包含 publication_id、pct_change、leading_code,离线重算需要精确来源记录。
- 最新迁移为 `0009_radar_sector_detail`。新增 score 应采用独立 migration,旧值可空。
- 单测 `tests/unit/sector_radar/test_postgres_repository.py:43–65` 使用 17 列 fake row,必须随查询同步。现有集成测试尚未覆盖 ranking/aggregate 往返。
## 前端
- `pages/sector-radar-page.tsx:209–229` 已有两个下拉,分别为变化指标/对比区间;改为截图的按钮组与统计天数次行。
- `pages/sector-radar-page.tsx:406–428` 顶部双榜和中央标题仍是普通资金榜文案。
- `pages/sector-radar-page.tsx:430–463`、`:643–708` 是单日/波段镜像列,无 score。
- `pages/sector-radar-page.tsx:465–500` 的变化榜目前只显示样本、排名百分位与一个变化值;应展示涨跌幅及另外两项指标变化。
- `api/sector-radar.types.ts:61–114` 与 `api/sector-radar.api.ts:277–377` 仅承载单个 rank_change,需扩展。
- 路由与 query key 已保存基准/天数,保留这一结构;URL 显式值优先。
- 页面测试 `sector-radar-page.test.tsx:654–683` 已覆盖正变化/缺历史,需增加评分、三基准五窗口、镜像辅助列、侧内排序与截图文案。
## 参考站核对
本会话下载的 `/tmp/onechart-public-evidence/index.html` 中:`:1197` 默认 Swing/1 日,`:1380` 附近 mirrorMetricLabel 随基准变,`:1397–1427` 的 mirrorAuxColumns 在变化榜显示另外两个基准,在单日/波段榜显示 score/净额/在榜。前置研究完整来源和数值证据位于已归档的评分研究任务。
@@ -0,0 +1,19 @@
# 加权公式与验证边界
前置研究在 2026-09-04 至 2026-09-21 的 9,492 条公开样本上复现单日、波段评分与最终排名;分数最大误差 3.41e-13,属于浮点运算误差。这是观测数据还原,不是原站源码证据。
对本项目已有聚合数据,F 为主力净额元、A 为成交额元:
```text
r = F / (A + 100)
W = log10(1 + MA5(A)) / 10
单日评分 = 1000 × P(r) × W
波段评分 = 500 × (P(MA3(r)) + P(MA10(r))) × W
波段原值 = (MA3(r) + MA10(r)) / 2
```
P 为同日、同类型全池升序平均名次 / N。波段必须先分别排名,再合成。最终评分并列按板块代码稳定排序。权重可能大于 1,分数不是固定上限 1000 的百分制。
公开字段可以确认的等价权重是 `log10(MA5(F/r)-99)/10`;`A=F/r-100` 是本任务采用的可复算口径,不能证明原站源码中具体常量的用途。原站边界与并列规则未完全公开,本地明确使用 design.md 中的完整交易日窗口、平均并列名次及缺失语义。
公开验证池为 791 个板块,本地数据库池和按日成员不同。本项目保留当前上市股票与已保存历史输入,因此不承诺与网站逐值相同。原始研究数据和复算快照保留在本地前置研究任务中,不是运行本功能的依赖。
@@ -0,0 +1,166 @@
"""Synthetic browser fixtures; restricted to the disposable local radar test DB."""
import hashlib
import math
import os
from dataclasses import replace
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from urllib.parse import urlsplit
import psycopg
from zhixing_server.modules.sector_radar.application.scoring import calculate_rankings
from zhixing_server.modules.sector_radar.domain.metrics import (
AmountNetStrategy,
RatioTurnoverStrategy,
SwingEqualThreeToTenStrategy,
)
from zhixing_server.modules.sector_radar.domain.models import (
PublicationStatus,
RadarPublication,
SectorDailyAggregate,
SectorType,
)
from zhixing_server.modules.sector_radar.domain.persistence import (
DailyAggregateRecord,
PublicationSourceGroup,
PublicationSourceRecord,
RankingRecord,
)
from zhixing_server.modules.sector_radar.domain.source import build_source_snapshot
from zhixing_server.modules.sector_radar.infrastructure.postgres import (
PostgresSectorRadarRepository,
)
url = os.environ["ZHIXING_TEST_DATABASE_URL"]
location = urlsplit(url)
if (location.hostname, location.port, location.path) != ("127.0.0.1", 55439, "/radar_test"):
raise SystemExit("Preview seeding is restricted to the disposable local radar test database")
with psycopg.connect(url) as connection:
connection.execute("TRUNCATE sector_radar_publication CASCADE")
start = date(2026, 8, 10)
days = tuple(
start + timedelta(days=n) for n in range(43) if (start + timedelta(days=n)).weekday() < 5
)
strategies = (AmountNetStrategy(), RatioTurnoverStrategy(), SwingEqualThreeToTenStrategy())
names = (
"人工智能",
"机器人",
"商业航天",
"低空经济",
"半导体",
"创新药",
"新能源",
"电力设备",
"数字经济",
"消费电子",
)
now = datetime.now(UTC)
calendar = build_source_snapshot(
api_name="trade_cal",
params={"fixture": "weighted-radar-preview"},
rows=[{"exchange": "SSE", "cal_date": day, "is_open": 1} for day in days],
target_trade_date=days[-1],
observed_at=now,
)
repository = PostgresSectorRadarRepository(url)
history = []
try:
repository.save_source_snapshots((calendar,))
for index, day in enumerate(days):
aggregates = []
for sector_type, size in ((SectorType.CONCEPT, 300), (SectorType.INDUSTRY, 100)):
for n in range(size):
turnover = Decimal(
str(10 ** (8 + n % 4) * (1 + 0.2 * math.cos(n + index)))
).quantize(Decimal(".01"))
ratio = Decimal(
str(
0.09 * math.sin(n * 1.37 + index * 0.43) + 0.02 * math.cos(n * 0.71 + index)
)
)
aggregates.append(
SectorDailyAggregate(
day,
sector_type,
f"TEST-{sector_type.value[0]}-{n:04}",
f"{names[n % len(names)]} {n + 1}",
10,
10,
(turnover * ratio).quantize(Decimal(".01")),
turnover,
Decimal(1),
Decimal(1),
)
)
publication = RadarPublication(
f"preview-{day}",
day,
PublicationStatus.RUNNING,
"local-preview-v1",
"synthetic-preview-v1",
tuple(strategy.metric_version for strategy in strategies),
None,
Decimal(1),
now,
)
repository.create_publication(publication)
repository.save_publication_sources(
(
PublicationSourceRecord(
publication.publication_id, PublicationSourceGroup.CALENDAR, 0, calendar
),
)
)
for sector_type, group in (
(SectorType.CONCEPT, PublicationSourceGroup.CONCEPT_INDICES),
(SectorType.INDUSTRY, PublicationSourceGroup.INDUSTRY_INDICES),
):
snapshot = build_source_snapshot(
api_name="dc_index",
params={"fixture": "preview", "type": sector_type.value},
rows=[
{
"trade_date": day,
"ts_code": row.sector_code,
"name": row.sector_name,
"pct_change": Decimal(str(3 * math.sin(n + index))).quantize(
Decimal(".01")
),
}
for n, row in enumerate(aggregates)
if row.sector_type is sector_type
],
target_trade_date=day,
observed_at=now,
)
repository.save_source_snapshots((snapshot,))
repository.save_publication_sources(
(PublicationSourceRecord(publication.publication_id, group, 0, snapshot),)
)
ranks = calculate_rankings(day, aggregates, history, days, strategies)
repository.finalize_publication(
replace(
publication,
status=PublicationStatus.SUCCESS,
input_hash=hashlib.sha256(str(day).encode()).hexdigest(),
finished_at=now,
),
memberships=(),
stock_facts=(),
daily_aggregates=(
DailyAggregateRecord(
publication.publication_id,
row,
Decimal(str(3 * math.sin(n + index))).quantize(Decimal(".01")),
)
for n, row in enumerate(aggregates)
),
rankings=(RankingRecord(publication.publication_id, row) for row in ranks),
)
history.extend(aggregates)
print(f"Seeded {len(days)} days × 400 synthetic sectors; no production data or provider calls.")
finally:
repository.close()