diff --git a/frontend/src/pages/Regime.tsx b/frontend/src/pages/Regime.tsx index a370ba2..5324ad6 100644 --- a/frontend/src/pages/Regime.tsx +++ b/frontend/src/pages/Regime.tsx @@ -11,8 +11,8 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import * as echarts from 'echarts' import { - Activity, RefreshCw, Loader2, Gauge, TrendingUp, - Flame, BarChart3, Pencil, + Activity, RefreshCw, Loader2, Gauge, TrendingUp, TrendingDown, Minus, + Pencil, CalendarDays, Repeat, Rows3, LayoutGrid, } from 'lucide-react' import { api, type RegimeRow, type RegimeState, @@ -20,13 +20,21 @@ import { } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { useChartTheme } from '@/lib/theme' -import { fmtBigNum } from '@/lib/format' import { toast } from '@/components/Toast' import { Modal } from '@/components/Modal' import { cn } from '@/lib/cn' const STATE_ORDER: RegimeState[] = ['strong', 'lean_strong', 'range', 'lean_weak', 'weak'] +/** 综合分 → 对应状态色(与 classify_state 阈值一致: 70/55/45/30) */ +function scoreToColor(score: number): string { + if (score >= 70) return REGIME_STATE_COLORS.strong + if (score >= 55) return REGIME_STATE_COLORS.lean_strong + if (score >= 45) return REGIME_STATE_COLORS.range + if (score >= 30) return REGIME_STATE_COLORS.lean_weak + return REGIME_STATE_COLORS.weak +} + // ── 时间范围 ────────────────────────────────────────────── // 1年=250 交易日, 2年=500 交易日; 自定义 1~1000; 全部走 start/end 日期范围。 type RangePreset = '1y' | '2y' | 'all' | { custom: number } @@ -106,6 +114,8 @@ export function Regime() { const qc = useQueryClient() const [range, setRange] = useState('1y') const [customOpen, setCustomOpen] = useState(false) + // 日历热力图显示模式: false=单行(月份网格横向排列+滚动条, 默认), true=展开(换行完整网格) + const [calendarExpanded, setCalendarExpanded] = useState(false) const ct = useChartTheme() // coverage: "全部"模式 + 标题展示依赖 @@ -134,6 +144,47 @@ export function Regime() { const rows: RegimeRow[] = history.data?.rows ?? [] const latest = rows.length > 0 ? rows[rows.length - 1] : null + // ── 当前势头: 末尾连续同态天数 + score 5日斜率(改善/恶化) + 上次弱势距今 ── + const momentum = useMemo(() => { + if (rows.length === 0) return null + const lastState = rows[rows.length - 1].state + // 末尾连续同态天数 + let streak = 1 + for (let i = rows.length - 2; i >= 0; i--) { + if (rows[i].state === lastState) streak++ + else break + } + // score 5日斜率(正=改善, 负=恶化) + const recent = rows.slice(-5) + const slope = recent.length >= 2 + ? (recent[recent.length - 1].score - recent[0].score) / (recent.length - 1) + : 0 + // 上次弱势(weak/lean_weak)距今天数 + let lastWeakGap = 0 + for (let i = rows.length - 1; i >= 0; i--) { + if (rows[i].state === 'weak' || rows[i].state === 'lean_weak') { + lastWeakGap = rows.length - 1 - i + break + } + } + return { streak, state: lastState, slope, lastWeakGap } + }, [rows]) + + // ── 状态转换频率: 近 N 天相邻 state 变化次数 ── + const transitions = useMemo(() => { + if (rows.length < 2) return { count: 0, rate: 0, label: '数据不足' } + let count = 0 + for (let i = 1; i < rows.length; i++) { + if (rows[i].state !== rows[i - 1].state) count++ + } + // 频率 = 转换次数 / 天数; <0.2 稳定, 0.2-0.4 中等, >0.4 频繁 + const rate = count / (rows.length - 1) + const label = rate < 0.2 ? '稳定' : rate < 0.4 ? '中等切换' : '频繁切换' + return { count, rate, label } + }, [rows]) + + + // 趋势图: 综合分主线 + 4 子维度曲线(可切换) + 状态背景色带 + 涨停数柱状 const trendOption = useMemo(() => { if (rows.length === 0) return null @@ -174,7 +225,7 @@ export function Regime() { // 默认只显示综合分 + 涨停数(简洁); 4 个子维度默认隐藏, 点图例展开看驱动因素 selected: { '综合分': true, '涨停数': true, '赚钱': false, '投机': false, '抗跌': false, '趋势': false }, }, - grid: { left: 48, right: 48, top: 36, bottom: 56 }, + grid: { left: 48, right: 64, top: 36, bottom: 56 }, xAxis: { type: 'category', data: dates, boundaryGap: false, axisLabel: { color: ct.text, fontSize: 10, formatter: (v: string) => v.slice(5) }, @@ -205,11 +256,20 @@ export function Regime() { { name: '综合分', type: 'line', data: scores, smooth: true, symbol: 'none', 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: 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 } }, - ] } }, + markLine: { + silent: true, + symbol: 'none', + lineStyle: { type: 'dashed', width: 1.5 }, + label: { position: 'end', fontSize: 10, fontWeight: 'bold', padding: [2, 4], borderRadius: 3 }, + data: [ + { yAxis: 70, lineStyle: { color: REGIME_STATE_COLORS.strong }, + label: { formatter: '强势 70', color: '#fff', backgroundColor: REGIME_STATE_COLORS.strong } }, + { yAxis: 45, lineStyle: { color: REGIME_STATE_COLORS.range }, + label: { formatter: '震荡 45', color: '#fff', backgroundColor: REGIME_STATE_COLORS.range } }, + { yAxis: 30, lineStyle: { color: REGIME_STATE_COLORS.lean_weak }, + label: { formatter: '偏弱 30', color: '#fff', backgroundColor: REGIME_STATE_COLORS.lean_weak } }, + ], + } }, ], } }, [rows, days, ct]) @@ -250,6 +310,44 @@ export function Regime() { }, [states.data, ct]) const pieRef = useEChart(pieOption, [pieOption]) + // 日历热力图数据: 按月分组(纯 CSS 渲染, 不依赖 echarts calendar 的跨年怪异行为)。 + // 结构: [{ year, month, label, weeks: [[cell|gap]×7]×N }] + // 每个 cell = { date, score, state } 或 null(该位置无交易日, 如月初前的空位) + const calendarMonths = useMemo(() => { + if (rows.length === 0) return [] + const byMonth = new Map() + for (const r of rows) { + const ym = r.date.slice(0, 7) // YYYY-MM + if (!byMonth.has(ym)) byMonth.set(ym, []) + byMonth.get(ym)!.push(r) + } + const MONTH_LABELS = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'] + return [...byMonth.entries()].sort().map(([ym, monthRows]) => { + const [y, m] = ym.split('-') + const year = Number(y), month = Number(m) + // 该月第一天是周几(0=周日 → 转成周一为起始: 周一=0) + const firstDay = new Date(year, month - 1, 1) + let dow = firstDay.getDay() // 0=Sun..6=Sat + dow = dow === 0 ? 6 : dow - 1 // 转成 周一=0..周日=6 + const cells: (RegimeRow | null)[] = Array(dow).fill(null) + for (const r of monthRows) cells.push(r) + // 补齐到 7 的倍数(完整周) + while (cells.length % 7 !== 0) cells.push(null) + // 切成每周一组 + const weeks: (RegimeRow | null)[][] = [] + for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7)) + return { year, month, label: `${y}年${MONTH_LABELS[month - 1]}`, weeks } + }) + }, [rows]) + + // 日历热力图横向滚动容器: 单行模式下默认滚到最右侧(显示最新月份) + const calendarScrollRef = useRef(null) + useEffect(() => { + if (!calendarExpanded && calendarScrollRef.current) { + calendarScrollRef.current.scrollLeft = calendarScrollRef.current.scrollWidth + } + }, [calendarExpanded, calendarMonths]) + const handleRecompute = async () => { setRecomputing(true) try { @@ -274,7 +372,7 @@ export function Regime() { : '自定义' return ( -
+
{/* ── 头部 (Dashboard 渐变条卡片) ── */}
@@ -322,10 +420,10 @@ export function Regime() {
- {/* ── 最新日概览 (4 个指标卡) ── */} + {/* ── 最新日概览 (4 个指标卡, 去掉与看板重复的涨停/涨跌/成交额) ── */} {latest ? (
- {/* 状态卡 */} + {/* 状态卡(保留) */}
最新状态 · {latest.date} @@ -336,51 +434,68 @@ export function Regime() { {latest.score} 分
- {/* 评分进度条 0~100 */}
- {/* 涨停 / 跌停 */} + {/* 当前势头(新) */}
- 涨停 / 跌停 + {(() => { + const TrendIcon = (momentum?.slope ?? 0) > 0.5 ? TrendingUp : (momentum?.slope ?? 0) < -0.5 ? TrendingDown : Minus + return 0.5 ? 'text-bull' : (momentum?.slope ?? 0) < -0.5 ? 'text-bear' : 'text-muted'}`} /> + })()} 当前势头
-
- {latest.limit_up} - / - {latest.limit_down} -
-
连板高度 {latest.max_consecutive} · 封板率 {(latest.seal_rate * 100).toFixed(0)}%
+ {momentum ? ( + <> +
+ 连续 {momentum.streak} 天{REGIME_STATE_LABELS[momentum.state]} +
+
+ 5日{(momentum.slope > 0 ? '改善' : momentum.slope < 0 ? '恶化' : '持平')} + {momentum.lastWeakGap > 0 && ` · 上次弱势 ${momentum.lastWeakGap} 天前`} +
+ + ) :
}
- {/* 涨跌家数比 */} + {/* 4 子维度迷你条(新) */}
- 涨跌家数比 + 四维拆解 · {latest.date}
-
{latest.up_ratio.toFixed(2)}
-
- 涨 {latest.up_count} - · - 跌 {latest.down_count} +
+ {([ + { label: '赚钱', val: latest.profit_score, color: '#f59e0b' }, + { label: '投机', val: latest.speculation_score, color: '#a855f7' }, + { label: '抗跌', val: latest.resilience_score, color: '#10b981' }, + { label: '趋势', val: latest.trend_score, color: '#3b82f6' }, + ] as const).map(d => ( +
+ {d.label} +
+
+
+ {d.val ?? '—'} +
+ ))}
- {/* 成交额 */} + {/* 状态转换频率(新) */}
- 成交额 + 状态转换 · 近 {days} 天
-
{fmtBigNum(latest.total_amount)}
-
- MA20 上方 {(latest.above_ma20_pct * 100).toFixed(0)}% -
-
-
+
+ {transitions.count} 次切换 +
+
+ 节奏:{transitions.label} + ({(transitions.rate * 100).toFixed(0)}%/天)
@@ -427,6 +542,104 @@ export function Regime() {
+ {/* ── 日历热力图(每日综合分按状态色, 支持单行/展开切换) ── */} + {calendarMonths.length > 0 && ( +
+ setCalendarExpanded(v => !v)} + className="inline-flex items-center gap-1 rounded-btn border border-border bg-base px-2 py-0.5 text-[10px] text-secondary hover:text-accent hover:border-accent/40 transition-colors" + title={calendarExpanded ? '切换为单行紧凑' : '切换为月份展开'} + > + {calendarExpanded ? <>单行 : <>展开} + + } + /> + {calendarExpanded ? ( + /* 展开模式: 按月分块的完整日历网格, 自动换行 */ +
+ {calendarMonths.map(mo => { + const monthRows = mo.weeks.flat().filter((c): c is RegimeRow => !!c) + const avgScore = monthRows.length > 0 + ? Math.round(monthRows.reduce((s, r) => s + r.score, 0) / monthRows.length) : 0 + return ( +
+
+ {mo.label} + {avgScore > 0 && ( + + {avgScore} + + )} +
+
+ {['一', '二', '三', '四', '五', '六', '日'].map(d => ( +
{d}
+ ))} +
+
+ {mo.weeks.flat().map((cell, i) => ( + cell ? ( +
+ ) : ( +
+ ) + ))} +
+
+ ) + })} +
+ ) : ( + /* 单行模式(默认): 月份网格横向一行+滚动条, 默认滚到最新, 每月份带月均分 */ +
+ {calendarMonths.map(mo => { + const monthRows = mo.weeks.flat().filter((c): c is RegimeRow => !!c) + const avgScore = monthRows.length > 0 + ? Math.round(monthRows.reduce((s, r) => s + r.score, 0) / monthRows.length) : 0 + return ( +
+
+ {mo.label} + {avgScore > 0 && ( + + {avgScore} + + )} +
+
+ {['一', '二', '三', '四', '五', '六', '日'].map(d => ( +
{d}
+ ))} +
+
+ {mo.weeks.flat().map((cell, i) => ( + cell ? ( +
+ ) : ( +
+ ) + ))} +
+
+ ) + })} +
+ )} +
+ )} + {/* ── 自定义天数弹窗 ── */} {customOpen && (