feat(sector-radar): add weighted scores and rank-change views
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user