feat(regime): 趋势图可解释化 — 综合分主线 + 子维度曲线 + 状态背景色带

问题: 原趋势图标题「环境综合分·涨停数趋势」, 但综合分是4维加权结果,
图里只画涨停数一个副指标, 用户无法理解综合分为何涨跌(黑盒)。

后端(regime_builder.py)
- 抽出 _compute_subscores(metrics): 计算4子维度分(赚钱/投机/抗跌/趋势)+综合分
- classify_state 复用 _compute_subscores(逻辑不重复)
- _aggregate_daily 持久化4个子分列: profit_score/speculation_score/
  resilience_score/trend_score(0-100), 供趋势图展示+未来策略按子维度过滤

前端
- RegimeRow 类型加8个可选字段(4子分+4原始指标, 兼容旧数据)
- 趋势图重构:
  · 综合分主线(加粗2.5px置顶) + 半透明面积
  · 4子维度点状曲线(赚钱橙/投机紫/抗跌绿/趋势蓝), 默认隐藏, 点图例展开
  · 状态背景色带(markArea, 连续同状态日期段用状态色低透明度着色)
  · 涨停数柱状降为半透明背景(opacity 0.35)
  · 阈值横虚线更新为新模型 70/45/30
  · 默认 legend.selected 只显示综合分+涨停数(简洁), 子维度按需展开
- 标题改为「环境综合分趋势」+ hint 说明各曲线含义

验证
- 后端 583 passed; 子分加权一致性100%(综合分=Σ子分×权重)
- 实测解释性: weak日(7-30综合23) 赚钱9+抗跌0 清楚显示普跌+大跌股多
- tsc + pnpm build 通过
This commit is contained in:
shy3130
2026-08-02 17:29:09 +08:00
parent fa5583c2cb
commit 6a750de722
3 changed files with 97 additions and 17 deletions
+34 -8
View File
@@ -60,14 +60,11 @@ def _score(value: float, low: float, high: float) -> float:
return float(max(0, min(100, round((value - low) / (high - low) * 100))))
def classify_state(metrics: dict) -> tuple[str, int]:
"""规则引擎: 4 维指标 → 离散状态 + 综合分(0-100)
def _compute_subscores(metrics: dict) -> dict:
"""计算 4 个子维度分 + 综合分(未取整)。供 classify_state 和持久化复用
对齐看板情绪分的轻量维度(去掉量能/主线以控制内存):
- 赚钱 profit: 涨家数占比 + 均涨幅 + 中位涨幅 + 强弱差
- 投机 speculation: 涨停数 + 封板率 + 连板高度
- 抗跌 resilience: 跌家数占比 + 大跌股占比(大跌日此项暴跌 → 总分进 weak)
- 趋势 trend: 指数涨幅 + MA20 上方占比
返回 {profit, speculation, resilience, trend, score(float, 0-100)}。
子维度分也是 0-100, 供趋势图展示"综合分由什么驱动"
metrics 期望字段(由 _aggregate_daily 聚合):
up_pct, down_pct, avg_pct, median_pct, strong_up_pct, strong_down_pct,
@@ -109,7 +106,29 @@ def classify_state(metrics: dict) -> tuple[str, int]:
+ resilience * WEIGHTS["resilience"]
+ trend * WEIGHTS["trend"]
)
score = max(0, min(100, round(score)))
return {
"profit": profit, "speculation": speculation,
"resilience": resilience, "trend": trend,
"score": max(0, min(100, score)),
}
def classify_state(metrics: dict) -> tuple[str, int]:
"""规则引擎: 4 维指标 → 离散状态 + 综合分(0-100)。
对齐看板情绪分的轻量维度(去掉量能/主线以控制内存):
- 赚钱 profit: 涨家数占比 + 均涨幅 + 中位涨幅 + 强弱差
- 投机 speculation: 涨停数 + 封板率 + 连板高度
- 抗跌 resilience: 跌家数占比 + 大跌股占比(大跌日此项暴跌 → 总分进 weak)
- 趋势 trend: 指数涨幅 + MA20 上方占比
metrics 期望字段(由 _aggregate_daily 聚合):
up_pct, down_pct, avg_pct, median_pct, strong_up_pct, strong_down_pct,
strong_diff_pct, limit_up, seal_rate(0-1), max_consecutive,
index_pct(小数), above_ma20_pct(0-1)
"""
sub = _compute_subscores(metrics)
score = max(0, min(100, round(sub["score"])))
if score >= STATE_STRONG:
state = "strong"
@@ -242,6 +261,8 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl.
"strong_diff_pct": strong_up_pct - strong_down_pct,
}
state, score = classify_state(metrics)
# 4 个子维度分(供趋势图展示"综合分由什么驱动" + 未来策略按子维度过滤)
sub = _compute_subscores(metrics)
rows.append({
"date": r["date"],
"state": state,
@@ -263,6 +284,11 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl.
"median_pct": round(median_pct, 4),
"strong_up_pct": round(strong_up_pct, 4),
"strong_down_pct": round(strong_down_pct, 4),
# 4 个子维度分(0-100, 综合分加权来源): 赚钱/投机/抗跌/趋势
"profit_score": round(sub["profit"]),
"speculation_score": round(sub["speculation"]),
"resilience_score": round(sub["resilience"]),
"trend_score": round(sub["trend"]),
})
return pl.DataFrame(rows) if rows else pl.DataFrame()
+9
View File
@@ -437,6 +437,15 @@ export interface RegimeRow {
above_ma20_pct: number
total_amount: number
avg_turnover: number
// 4 个子维度分(0-100, 重算后才有; 旧数据可能缺) — 综合分的加权来源
avg_pct?: number
median_pct?: number
strong_up_pct?: number
strong_down_pct?: number
profit_score?: number
speculation_score?: number
resilience_score?: number
trend_score?: number
}
export interface RegimeHistory {
+54 -9
View File
@@ -134,19 +134,49 @@ export function Regime() {
const rows: RegimeRow[] = history.data?.rows ?? []
const latest = rows.length > 0 ? rows[rows.length - 1] : null
// 趋势图: 综合分曲线 + 涨停数柱状
// 趋势图: 综合分主线 + 4 子维度曲线(可切换) + 状态背景色带 + 涨停数柱状
const trendOption = useMemo<echarts.EChartsOption | null>(() => {
if (rows.length === 0) return null
const dates = rows.map(r => r.date)
const scores = rows.map(r => r.score)
const limitUps = rows.map(r => r.limit_up)
const profit = rows.map(r => r.profit_score ?? null)
const speculation = rows.map(r => r.speculation_score ?? null)
const resilience = rows.map(r => r.resilience_score ?? null)
const trend = rows.map(r => r.trend_score ?? null)
// 状态背景色带: 合并连续同状态日期段, 每段用状态色低透明度着色
const stateBands: any[] = []
let bandStart = rows[0]?.date
let prevState = rows[0]?.state
rows.forEach((r, i) => {
if (r.state !== prevState || i === rows.length - 1) {
const bandEnd = i === rows.length - 1 ? r.date : rows[i - 1].date
if (prevState && REGIME_STATE_COLORS[prevState as RegimeState]) {
stateBands.push([
{ xAxis: bandStart, itemStyle: { color: REGIME_STATE_COLORS[prevState as RegimeState], opacity: 0.08 } },
{ xAxis: bandEnd },
])
}
bandStart = r.date
prevState = r.state
}
})
const subLineStyle = { width: 1.2, type: 'dotted' as const, opacity: 0.8 }
return {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', backgroundColor: ct.tooltipBg, borderColor: ct.tooltipBorder, textStyle: { color: ct.tooltipText } },
legend: { data: ['综合分', '涨停数'], textStyle: { color: ct.text }, top: 0 },
grid: { left: 48, right: 48, top: 32, bottom: 56 },
legend: {
data: ['综合分', '涨停数', '赚钱', '投机', '抗跌', '趋势'],
textStyle: { color: ct.text, fontSize: 10 }, top: 0,
// 默认只显示综合分 + 涨停数(简洁); 4 个子维度默认隐藏, 点图例展开看驱动因素
selected: { '综合分': true, '涨停数': true, '赚钱': false, '投机': false, '抗跌': false, '趋势': false },
},
grid: { left: 48, right: 48, top: 36, bottom: 56 },
xAxis: {
type: 'category', data: dates,
type: 'category', data: dates, boundaryGap: false,
axisLabel: { color: ct.text, fontSize: 10, formatter: (v: string) => v.slice(5) },
axisLine: { lineStyle: { color: ct.grid } },
},
@@ -159,13 +189,27 @@ export function Regime() {
{ type: 'slider', bottom: 8, height: 16, borderColor: ct.border, fillerColor: ct.zoomFill, textStyle: { color: ct.text } },
],
series: [
// 涨停数柱状(半透明背景)
{ name: '涨停数', type: 'bar', data: limitUps, yAxisIndex: 1, barMaxWidth: 6,
itemStyle: { color: REGIME_STATE_COLORS.strong, opacity: 0.35 }, z: 1 },
// 4 子维度曲线: 帮助理解综合分由什么驱动(点图例可切换)
{ name: '赚钱', type: 'line', data: profit, smooth: true, symbol: 'none',
lineStyle: { ...subLineStyle, color: '#f59e0b' }, z: 2 },
{ name: '投机', type: 'line', data: speculation, smooth: true, symbol: 'none',
lineStyle: { ...subLineStyle, color: '#a855f7' }, z: 2 },
{ name: '抗跌', type: 'line', data: resilience, smooth: true, symbol: 'none',
lineStyle: { ...subLineStyle, color: '#10b981' }, z: 2 },
{ name: '趋势', type: 'line', data: trend, smooth: true, symbol: 'none',
lineStyle: { ...subLineStyle, color: '#3b82f6' }, z: 2 },
// 综合分主线(加粗置顶) + 状态背景色带 + 阈值横虚线
{ name: '综合分', type: 'line', data: scores, smooth: true, symbol: 'none',
lineStyle: { width: 2, color: ct.textStrong }, areaStyle: { opacity: 0.08 },
lineStyle: { width: 2.5, color: ct.textStrong }, areaStyle: { opacity: 0.06 }, z: 3,
markArea: { silent: true, data: stateBands },
markLine: { silent: true, lineStyle: { type: 'dashed', color: ct.grid }, data: [
{ yAxis: 75, label: { formatter: '强势', color: ct.text, fontSize: 9 } },
{ yAxis: 40, label: { formatter: '震荡', color: ct.text, fontSize: 9 } },
{ yAxis: 70, label: { formatter: '强势', color: ct.text, fontSize: 9 } },
{ yAxis: 45, label: { formatter: '震荡', color: ct.text, fontSize: 9 } },
{ yAxis: 30, label: { formatter: '偏弱', color: ct.text, fontSize: 9 } },
] } },
{ name: '涨停数', type: 'bar', data: limitUps, yAxisIndex: 1, barMaxWidth: 6, itemStyle: { color: REGIME_STATE_COLORS.strong } },
],
}
}, [rows, days, ct])
@@ -373,7 +417,8 @@ export function Regime() {
{/* ── 趋势图 + 分布图 ── */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className={cn(cardCls, 'p-3 lg:col-span-2')}>
<SectionTitle icon={Activity} title="环境综合分 · 涨停数趋势" />
<SectionTitle icon={Activity} title="环境综合分趋势"
hint="综合分(粗) · 赚钱/投机/抗跌/趋势(细, 可点图例切换) · 背景色=状态" />
<div ref={trendRef} className="mt-2 h-[320px]" />
</div>
<div className={cn(cardCls, 'p-3')}>