From 7c30f5bcac46daf883dc16993f86c61d6924e105 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Mon, 17 Aug 2026 15:23:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(regime):=20=E6=83=85=E7=BB=AA=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E6=98=93=E7=94=A8=E6=80=A7=E5=AE=8C=E5=96=84=20?= =?UTF-8?q?=E2=80=94=20=E9=98=B6=E6=AE=B5=E6=8C=87=E5=8D=97/=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=88=86=E4=BD=8D/=E9=98=B6=E6=AE=B5=E8=A7=84?= =?UTF-8?q?=E5=BE=8B/=E5=88=87=E6=8D=A2=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 当前阶段卡: 阶段含义 hover 提示 + 应对建议常显 (六阶段操作指引) - 5 张指标卡: 所选时间窗口内的历史分位徽标 p{N} (≥20 样本才显示) - 阶段规律卡: 当前阶段历史段数/平均/最长天数 + 下阶段转移分布, 持续天数超历史平均时给警示徽标 - 时间轴: 今日竖线 markLine + ▲彩色阶段切换点 markPoint - 后端: regime_builder.latest_phase_transition 检测末两日阶段切换, daily_pipeline 计算后经 push_alerts 推送通知 (切入退潮/冰点为 warn, 其余 info); 软失败不阻断管道 验证: tsc 通过; pytest regime/phase/market 67 通过; 浏览器实测 (截图 + DOM 双重核实): 应对建议/分位徽标 p27~p100/ 阶段规律 31段平均50天/今日线与切换点均正常渲染; ruff 相对 HEAD 无新增问题。 --- backend/app/jobs/daily_pipeline.py | 34 ++++++ backend/app/services/regime_builder.py | 17 +++ frontend/src/pages/Regime.tsx | 161 ++++++++++++++++++++++--- 3 files changed, 193 insertions(+), 19 deletions(-) diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py index 34f2698..54af0b0 100644 --- a/backend/app/jobs/daily_pipeline.py +++ b/backend/app/jobs/daily_pipeline.py @@ -537,6 +537,13 @@ def run_now( invalidate_regime_cache() logger.info("compute_regime: %d days", regime_days) emit("compute_regime", 92, f"市场环境 {regime_days} 天") + # 阶段切换推送监控通知 (软失败, 不影响管道): 末两日阶段不同 = 今日发生切换。 + # 切入退潮/冰点为风险信号, 用 warn 级别; 其余 info。 + if regime_days: + try: + _push_phase_change_alert(repo.store.data_dir) + except Exception as e: + logger.warning("phase change alert failed (soft): %s", e) except Exception as e: # noqa: BLE001 logger.warning("compute_regime failed (soft): %s", e) stage_errors.append(f"compute_regime: {e}") @@ -649,6 +656,33 @@ def _refresh_instruments_view(repo: KlineRepository) -> None: logger.warning("refresh instruments view failed: %s", e) +def _push_phase_change_alert(data_dir) -> None: + """情绪周期阶段切换 → 推送监控通知(SSE toast + 监控中心)。 + + 阶段切换(如 退潮→冰点)是重要的市场信号, 原先只有打开市场环境页才能看到。 + 复用 quote_service.push_alerts 广播通道; 未发生切换静默返回。 + """ + from app.services.market_phase import PHASE_LABELS + from app.services.regime_builder import latest_phase_transition + + tr = latest_phase_transition(data_dir) + if not tr: + return + prev, cur, d = tr + msg = f"情绪周期阶段切换: {PHASE_LABELS.get(prev, prev)} → {PHASE_LABELS.get(cur, cur)} ({d})" + severity = "warn" if cur in ("ebb", "ice") else "info" + app_state = _get_app_state() + qs = getattr(app_state, "quote_service", None) if app_state else None + if qs: + qs.push_alerts([{ + "source": "market", + "type": "phase_change", + "message": msg, + "severity": severity, + }]) + logger.info("phase change alert: %s (severity=%s)", msg, severity) + + def _run_tracked(fn, job_label: str) -> bool: """调度触发时包装 JobStore 跟踪,确保同步历史有记录。 diff --git a/backend/app/services/regime_builder.py b/backend/app/services/regime_builder.py index b78f429..f119c03 100644 --- a/backend/app/services/regime_builder.py +++ b/backend/app/services/regime_builder.py @@ -575,6 +575,23 @@ def detect_stale_dates(data_dir: Path, repo) -> list[date]: return sorted(stale) +def latest_phase_transition(data_dir: Path) -> tuple[str, str, str] | None: + """读取 regime 时序末两日, 返回最近一次阶段切换 (prev, new, 日期str)。 + + 末两日阶段相同(或数据不足/无阶段列)返回 None。供盘后管道推送阶段切换通知。 + """ + hist = load_regime_history(data_dir) + if hist.is_empty() or "phase" not in hist.columns: + return None + tail = hist.select(["date", "phase"]).sort("date").tail(2) + if tail.height < 2: + return None + prev_phase, cur_phase = tail["phase"].to_list() + if not prev_phase or not cur_phase or prev_phase == cur_phase: + return None + return prev_phase, cur_phase, str(tail["date"][-1]) + + def compute_regime_incremental(repo, data_dir: Path, *, today: date | None = None) -> pl.DataFrame: """增量计算 regime(供 daily_pipeline / 启动补算调用)。 diff --git a/frontend/src/pages/Regime.tsx b/frontend/src/pages/Regime.tsx index 41e43a9..a77cc81 100644 --- a/frontend/src/pages/Regime.tsx +++ b/frontend/src/pages/Regime.tsx @@ -7,7 +7,7 @@ * 时间范围: 1年(250交易日) / 2年(500) / 自定义(1~1000天) / 全部(走日期范围)。 * 美化对齐 Dashboard 设计语言: 半透明 surface 卡片 + 渐变竖条标题 + 语义色。 */ -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import * as echarts from 'echarts' import { @@ -27,6 +27,16 @@ import { cn } from '@/lib/cn' const STATE_ORDER: RegimeState[] = ['strong', 'lean_strong', 'range', 'lean_weak', 'weak'] +/** 阶段含义与应对提示 — meaning 与后端 market_phase.py 判定规则对齐, 供当前阶段卡展示 */ +const MARKET_PHASE_GUIDE: Record = { + ice: { meaning: '高度/宽度/首板同时贴地, 亏钱效应极致', action: '观望 · 跟踪率先异动股' }, + ignite: { meaning: '低位放量扩张, 晋级率回升', action: '试仓主线 · 快进快出' }, + rally: { meaning: '高度+宽度+晋级率共振', action: '持股 · 顺势而为' }, + climax: { meaning: '情绪极端宣泄, 批量二板+', action: '逐步兑现 · 不追高' }, + ebb: { meaning: '自高位回落, 晋级率坍塌', action: '防守 · 不接力' }, + repair: { meaning: '多空拉锯, 无明确方向', action: '轻仓试错 · 控回撤' }, +} + /** 综合分 → 对应状态色(与 classify_state 阈值一致: 70/55/45/30) */ function scoreToColor(score: number): string { if (score >= 70) return REGIME_STATE_COLORS.strong @@ -191,6 +201,40 @@ export function Regime() { return mlRows.filter(r => r.date === lastDate && r.rank <= 3) }, [mainline.data]) + // ── 指标历史分位: 最新值在当前窗口内的位置 (≤ 它的天数占比), 给指标卡参照系 ── + const pctRank = useCallback((field: keyof RegimeRow): number | null => { + if (!latest) return null + const latestV = latest[field] as number | null + if (latestV == null) return null + const vals = rows.map(r => r[field] as number | null).filter((v): v is number => v != null) + if (vals.length < 20) return null // 样本太少分位无意义 + const below = vals.filter(v => v <= latestV).length + return Math.round((100 * below) / vals.length) + }, [rows, latest]) + + // ── 阶段规律: 当前阶段的历史段统计 + 下一阶段转移分布 (窗口内 segments) ── + const phaseStats = useMemo(() => { + if (!phaseStreak || segments.length === 0) return null + const cur = phaseStreak.phase + const segs = segments.filter(s => s.phase === cur) + if (segs.length === 0) return null + const avgDays = Math.round(segs.reduce((a, s) => a + s.days, 0) / segs.length) + const maxDays = Math.max(...segs.map(s => s.days)) + // 转移分布: 每个历史同阶段段的后继段 (segments 按开始日期升序, 最后一段无后继不计) + const nexts = new Map() + segments.forEach((s, i) => { + if (s.phase !== cur) return + const nx = segments[i + 1] + if (!nx) return + nexts.set(nx.phase as MarketPhase, (nexts.get(nx.phase as MarketPhase) ?? 0) + 1) + }) + const total = [...nexts.values()].reduce((a, b) => a + b, 0) + const transitions = [...nexts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([phase, count]) => ({ phase, count, pct: total > 0 ? Math.round((100 * count) / total) : 0 })) + return { count: segs.length, avgDays, maxDays, transitions } + }, [phaseStreak, segments]) + // ── 当前势头: 末尾连续同态天数 + score 5日斜率(改善/恶化) + 上次弱势距今 ── const momentum = useMemo(() => { if (rows.length === 0) return null @@ -255,6 +299,17 @@ export function Regime() { prevPhase = r.phase } }) + // 阶段切换点: 当日 phase ≠ 前日 → 在高度线上标一枚新阶段颜色的小三角 + const switchPoints: any[] = [] + for (let i = 1; i < rows.length; i++) { + if (rows[i].phase && rows[i].phase !== rows[i - 1].phase) { + switchPoints.push({ + coord: [rows[i].date, heights[i]], + itemStyle: { color: MARKET_PHASE_COLORS[rows[i].phase as MarketPhase] }, + label: { show: false }, + }) + } + } return { backgroundColor: 'transparent', tooltip: { @@ -295,7 +350,19 @@ export function Regime() { itemStyle: { color: '#f59e0b', opacity: 0.4 }, z: 1 }, { name: '高度', type: 'line', data: heights, smooth: true, symbol: 'none', yAxisIndex: 0, lineStyle: { width: 1.6, color: '#ef4444' }, z: 3, - markArea: { silent: true, data: phaseBands } }, + markArea: { silent: true, data: phaseBands }, + // 最新交易日竖线 (定位"现在"): 淡白细线, 不参与交互 + markLine: { + silent: true, symbol: 'none', + lineStyle: { color: ct.text, opacity: 0.35, width: 1 }, + label: { show: true, position: 'end', formatter: '今日', color: ct.text, fontSize: 9 }, + data: [{ xAxis: dates[dates.length - 1] }], + }, + // 阶段切换点: 小三角=当日切换, 颜色=新阶段 + markPoint: { + silent: true, symbol: 'triangle', symbolSize: 7, + data: switchPoints, + } }, { name: '晋级率', type: 'line', data: promo, smooth: true, symbol: 'none', yAxisIndex: 1, lineStyle: { width: 1.2, color: '#3b82f6', type: 'dotted' }, z: 2 }, ], @@ -578,11 +645,20 @@ export function Regime() { 当前阶段 · {latest.date}
- + {MARKET_PHASE_LABELS[phaseStreak?.phase ?? 'repair']} {phaseStreak && 第 {phaseStreak.streak} 天}
+ {phaseStreak && ( +
+ {MARKET_PHASE_GUIDE[phaseStreak.phase].action} +
+ )}
{latestMainlines.length > 0 ? latestMainlines.map(m => (
{([ - { label: '市场高度', val: latest.max_consecutive, unit: '板', color: '#ef4444' }, - { label: '首板宽度', val: latest.first_board, unit: '家', color: '#f97316' }, - { label: '2板+宽度', val: latest.ge2_count, unit: '家', color: '#f59e0b' }, + { label: '市场高度', val: latest.max_consecutive, unit: '板', color: '#ef4444', field: 'max_consecutive' as const }, + { label: '首板宽度', val: latest.first_board, unit: '家', color: '#f97316', field: 'first_board' as const }, + { label: '2板+宽度', val: latest.ge2_count, unit: '家', color: '#f59e0b', field: 'ge2_count' as const }, { label: '晋级率', val: latest.promo_rate != null ? `${(latest.promo_rate * 100).toFixed(0)}%` : '—', - unit: '', color: '#3b82f6', + unit: '', color: '#3b82f6', field: 'promo_rate' as const, sub: latest.promo_pool != null ? `池 ${latest.promo_pool} 家` : undefined }, { label: '梯队完整度', val: latest.ladder_completeness != null ? `${(latest.ladder_completeness * 100).toFixed(0)}%` : '—', - unit: '', color: '#a855f7', sub: '2板→最高板不断档' }, - ] as { label: string; val: React.ReactNode; unit: string; color: string; sub?: string }[]).map(k => ( -
-
- {k.label} + unit: '', color: '#a855f7', field: 'ladder_completeness' as const, sub: '2板→最高板不断档' }, + ] as { label: string; val: React.ReactNode; unit: string; color: string; field: keyof RegimeRow; sub?: string }[]).map(k => { + const p = pctRank(k.field) + return ( +
+
+ {k.label} + {p != null && ( + + p{p} + + )} +
+
+ {k.val}{k.unit} +
+ {k.sub &&
{k.sub}
}
-
- {k.val}{k.unit} -
- {k.sub &&
{k.sub}
} -
- ))} + ) + })}
) : (
@@ -619,11 +706,47 @@ export function Regime() {
)} + {/* ── 阶段规律: 当前阶段的历史统计 + 下阶段转移分布 (窗口内 segments 提炼) ── */} + {phaseStats && phaseStreak && ( +
+ +
+ + + {MARKET_PHASE_LABELS[phaseStreak.phase]} + + 已持续 + {phaseStreak.streak} 天 + · 历史 {phaseStats.count} 段, 平均 {phaseStats.avgDays} 天, 最长 {phaseStats.maxDays} 天 + + {phaseStreak.streak > phaseStats.avgDays && ( + + 已超历史平均 + + )} + {phaseStats.transitions.length > 0 && ( + + 历史去向: + {phaseStats.transitions.map(t => ( + + {MARKET_PHASE_LABELS[t.phase]} {t.pct}% + + ))} + + )} +
+
+ )} + {/* ── 情绪周期时间轴 (阶段色带 + 高度/宽度/晋级率) ── */} {hasPhaseData && rows.length > 0 && (
+ hint="高度(红) · 2板+(琥珀柱) · 晋级率(蓝虚线) · ▲阶段切换 · 竖线今日" />
{rows.map(r => (