diff --git a/backend/app/services/regime_builder.py b/backend/app/services/regime_builder.py index faed363..3143779 100644 --- a/backend/app/services/regime_builder.py +++ b/backend/app/services/regime_builder.py @@ -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() diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 729e569..72a8919 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 { diff --git a/frontend/src/pages/Regime.tsx b/frontend/src/pages/Regime.tsx index 8cf174e..a370ba2 100644 --- a/frontend/src/pages/Regime.tsx +++ b/frontend/src/pages/Regime.tsx @@ -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(() => { 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() { {/* ── 趋势图 + 分布图 ── */}
- +