Merge pull request #158 from shy3130/feat/regime-enhance

feat(regime): 市场环境页全面增强(内存可控/开关/画像/美化/分批可配)
This commit is contained in:
wshy
2026-08-02 16:26:13 +08:00
committed by GitHub
14 changed files with 712 additions and 107 deletions
+5 -1
View File
@@ -71,7 +71,11 @@ def regime_history(
df = df.filter(pl_col_date(df, ">=", start))
if end:
df = df.filter(pl_col_date(df, "<=", end))
df = df.sort("date", descending=True).head(limit).sort("date")
# limit 仅在"最近 N 天"模式(未传 start/end)生效;
# 日期范围模式(传了 start/end, 如"全部")应返回完整范围, 不截断。
if start is None and end is None:
df = df.sort("date", descending=True).head(limit)
df = df.sort("date")
rows = _df_to_records(df)
result = {"rows": rows, "total": len(rows)}
+39
View File
@@ -418,6 +418,9 @@ def get_preferences() -> dict:
"pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(),
"pipeline_pull_etf": preferences.get_pipeline_pull_etf(),
"pipeline_pull_index": preferences.get_pipeline_pull_index(),
"pipeline_regime_enabled": preferences.get_pipeline_regime_enabled(),
"regime_batch_days": preferences.get_regime_batch_days(),
"regime_warmup_days": preferences.get_regime_warmup_days(),
"pipeline_index_symbols": preferences.get_pipeline_index_symbols(),
"pipeline_schedule": preferences.get_pipeline_schedule(),
"instruments_schedule": preferences.get_instruments_schedule(),
@@ -830,6 +833,42 @@ def update_pipeline_pull_types(req: PipelinePullTypesIn) -> dict:
return preferences.set_pipeline_pull_types(cfg)
class PipelineRegimeEnabledIn(BaseModel):
"""盘后管道是否自动计算市场环境(regime)。"""
pipeline_regime_enabled: bool
@router.put("/preferences/pipeline-regime-enabled")
def update_pipeline_regime_enabled(req: PipelineRegimeEnabledIn) -> dict:
"""更新盘后管道 regime 自动计算开关。"""
from app.services import preferences
preferences.save({"pipeline_regime_enabled": bool(req.pipeline_regime_enabled)})
return {"pipeline_regime_enabled": preferences.get_pipeline_regime_enabled()}
class RegimeBatchParamsIn(BaseModel):
"""regime 全量回填分批参数(控制内存峰值)。"""
batch_days: int | None = None
warmup_days: int | None = None
@router.put("/preferences/regime-batch-params")
def update_regime_batch_params(req: RegimeBatchParamsIn) -> dict:
"""更新 regime 分批参数。仅在传入字段时保存对应项(支持部分更新)。"""
from app.services import preferences
updates: dict = {}
if req.batch_days is not None:
updates["regime_batch_days"] = req.batch_days
if req.warmup_days is not None:
updates["regime_warmup_days"] = req.warmup_days
if updates:
preferences.save(updates)
return {
"regime_batch_days": preferences.get_regime_batch_days(),
"regime_warmup_days": preferences.get_regime_warmup_days(),
}
class PipelineIndexSymbolsIn(BaseModel):
"""指数自定义拉取代码(逗号/换行/空格分隔,空串表示全量)。"""
symbols: str = ""
+21 -13
View File
@@ -518,21 +518,29 @@ def run_now(
# Step 2.6: 市场环境(regime) 增量计算 — enriched 已就绪后聚合环境指标。
# 双检测(缺口+stale), 自动补算遗漏/被覆写的日。软失败: 不阻断主管道。
# 默认关闭: regime 是本地聚合计算(非拉取), 首次/regime 表为空时需全量回填
# 多日, 内存与耗时较高。用户可在数据页「市场环境」卡片设置里开启自动计算,
# 或直接在该页面点「重算」手动触发(不受此开关影响)。
regime_days = 0
try:
emit("compute_regime", 90, "计算市场环境…")
from app.services import regime_builder
from app.api.regime import invalidate_regime_cache
new_regime = regime_builder.compute_regime_incremental(repo, repo.store.data_dir)
regime_days = new_regime.height if not new_regime.is_empty() else 0
if regime_days:
invalidate_regime_cache()
logger.info("compute_regime: %d days", regime_days)
emit("compute_regime", 92, f"市场环境 {regime_days}")
except Exception as e: # noqa: BLE001
logger.warning("compute_regime failed (soft): %s", e)
stage_errors.append(f"compute_regime: {e}")
from app.services import preferences as _prefs_regime
if not _prefs_regime.get_pipeline_regime_enabled():
skipped.append("regime")
logger.info("compute_regime skipped: user disabled (pipeline_regime_enabled=False)")
else:
try:
emit("compute_regime", 90, "计算市场环境…")
from app.services import regime_builder
from app.api.regime import invalidate_regime_cache
new_regime = regime_builder.compute_regime_incremental(repo, repo.store.data_dir)
regime_days = new_regime.height if not new_regime.is_empty() else 0
if regime_days:
invalidate_regime_cache()
logger.info("compute_regime: %d days", regime_days)
emit("compute_regime", 92, f"市场环境 {regime_days}")
except Exception as e: # noqa: BLE001
logger.warning("compute_regime failed (soft): %s", e)
stage_errors.append(f"compute_regime: {e}")
skipped.append("regime")
# Step 3: 刷新视图
emit("refresh_views", 95, "刷新 DuckDB 视图…")
+45
View File
@@ -240,6 +240,51 @@ def get_pipeline_pull_index() -> bool:
return load().get("pipeline_pull_index", True)
def get_pipeline_regime_enabled() -> bool:
"""盘后管道是否自动计算市场环境(regime)。默认 False。
regime 是本地聚合计算(非拉取), 首次/regime 表为空时需全量回填多日,
内存与耗时较高, 故默认关闭; 用户可在数据页「市场环境」卡片设置里开启,
或直接在该页面点「重算」手动触发(不受此开关影响)。
"""
return load().get("pipeline_regime_enabled", False)
# regime 全量回填分批参数范围:
# - batch_days: 每批目标交易日数。越小内存越省、批次越多越慢; ma20 需 20 交易日,
# 故下限 25(留 warmup 余量), 上限 500(约 2 年)。
# - warmup_days: 每批前缀预热天数(日历日), 必须 > ma20 的 20 交易日(≈28 日历日),
# 下限 35 留余量, 上限 90。
_REGIME_BATCH_DAYS_MIN = 25
_REGIME_BATCH_DAYS_MAX = 500
_REGIME_WARMUP_DAYS_MIN = 35
_REGIME_WARMUP_DAYS_MAX = 90
def get_regime_batch_days() -> int:
"""regime 全量回填每批目标交易日数。默认 60(约一季度)。
超过此天数的范围会被切成多批, 每批独立算指标后拼接, 控制内存峰值。
"""
v = load().get("regime_batch_days", 60)
try:
return max(_REGIME_BATCH_DAYS_MIN, min(_REGIME_BATCH_DAYS_MAX, int(v)))
except (TypeError, ValueError):
return 60
def get_regime_warmup_days() -> int:
"""regime 分批每批的 warmup 前缀日历天数。默认 40。
用于预热 ma20 等滚动窗口指标, 使每批边界计算正确。必须 > 20 交易日。
"""
v = load().get("regime_warmup_days", 40)
try:
return max(_REGIME_WARMUP_DAYS_MIN, min(_REGIME_WARMUP_DAYS_MAX, int(v)))
except (TypeError, ValueError):
return 40
_PIPELINE_PULL_KEYS = ("pipeline_pull_etf", "pipeline_pull_index")
+95 -25
View File
@@ -145,10 +145,8 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl.
if "date" not in avail or "change_pct" not in avail:
return pl.DataFrame()
# 基础聚合
agg_exprs = []
if "change_pct" in avail:
agg_exprs.append(pl.col("change_pct"))
# 基础聚合 — 全部用 group_by 一次性向量化算出, 避免逐日 filter 扫全表(OOM/超时元凶)。
has_ma20 = "close" in avail and "ma20" in avail
grouped = df.group_by("date").agg(
*[
pl.col("change_pct").gt(0).sum().alias("up_count")
@@ -181,9 +179,20 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl.
[pl.col("amount").mean().alias("avg_amount")]
if "amount" in avail else [pl.lit(0).alias("avg_amount")]
),
# MA20 上方占比: 向量化一次算出 (避免逐日 filter 扫全表)。
# 仅统计 ma20 有效(非空且>0)的行中, close>ma20 的占比。
*(
[
pl.when(pl.col("ma20").is_not_null() & (pl.col("ma20") > 0) & (pl.col("close") > pl.col("ma20")))
.then(1).otherwise(None).sum().alias("_above_cnt"),
pl.when(pl.col("ma20").is_not_null() & (pl.col("ma20") > 0))
.then(1).otherwise(None).sum().alias("_valid_cnt"),
]
if has_ma20 else []
),
).sort("date")
# 转成 dict 列表做后续计算(polars 表达式难表达的比率/MA20占比/分类)
# 转成 dict 列表做分类(规则引擎需逐日算, 但只扫 grouped 行数=天数, 不再回扫全表)
index_pct_map = index_pct_map or {}
rows = []
for r in grouped.iter_rows(named=True):
@@ -191,15 +200,10 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl.
down = r.get("down_count", 0) or 0
limit_up = r.get("limit_up", 0) or 0
broken = r.get("broken_limit", 0) or 0
# MA20 上方占比
ma20_above = 0
if "close" in avail and "ma20" in avail:
day_df = df.filter(pl.col("date") == r["date"])
if not day_df.is_empty() and "ma20" in day_df.columns:
valid = day_df.filter(pl.col("ma20").is_not_null() & (pl.col("ma20") > 0))
if not valid.is_empty():
above = valid.filter(pl.col("close") > pl.col("ma20"))
ma20_above = above.height / valid.height
# MA20 上方占比: 来自向量化聚合 (None→0)
valid_cnt = r.get("_valid_cnt") or 0
above_cnt = r.get("_above_cnt") or 0
ma20_above = (above_cnt / valid_cnt) if valid_cnt > 0 else 0.0
metrics = {
"limit_up": limit_up,
"limit_down": r.get("limit_down", 0) or 0,
@@ -235,29 +239,95 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl.
return pl.DataFrame(rows) if rows else pl.DataFrame()
# 全量回填分批参数(控制内存峰值) —— 实际值从用户偏好读取(preferences.get_regime_*),
# 这里的常量仅作 fallback(偏好读取失败时)和文档说明:
# - batch_days: 每批目标交易日数。越小内存越省、批次越多越慢; ma20 需 20 交易日。
# - warmup_days: 每批前缀预热天数(日历日), 必须 > ma20 的 20 交易日(≈28 日历日)。
_REGIME_BATCH_DAYS_DEFAULT = 60
_REGIME_WARMUP_DAYS_DEFAULT = 40
def _compute_batch(repo, enriched_dir, instruments, historical_shares,
batch_start: date, batch_end: date, warmup_days: int) -> pl.DataFrame:
"""单批: 读 [batch_start-warmup, batch_end] → 算指标 → 截断回 [batch_start, batch_end]。
warmup 前缀保证每批边界的滚动窗口指标(ma20)正确, 不依赖相邻批次。
返回目标区间(不含 warmup)的含指标列 DataFrame。
"""
from datetime import timedelta
from app.indicators.pipeline import compute_indicators, compute_limit_signals
warmup_start = batch_start - timedelta(days=warmup_days)
df = pl.scan_parquet(enriched_dir / "**" / "*.parquet").filter(
(pl.col("date") >= warmup_start) & (pl.col("date") <= batch_end)
).collect()
if df.is_empty():
return pl.DataFrame()
df = compute_indicators(df, needed={"change_pct", "ma20", "vol_ratio_5d"})
if instruments is not None and not instruments.is_empty():
df = compute_limit_signals(
df, instruments,
needed={"signal_limit_up", "signal_limit_down", "signal_broken_limit_up"},
historical_shares=historical_shares,
)
# 丢弃 warmup 行, 只留目标区间
return df.filter((pl.col("date") >= batch_start) & (pl.col("date") <= batch_end))
def _scan_enriched_fallback(repo, start: date, end: date) -> pl.DataFrame | None:
"""缓存不覆盖时的慢路径: 一次性 scan 全部 enriched parquet + 重算指标。
"""缓存不覆盖时的慢路径: scan enriched parquet + 重算所需指标
仅在 regime 首次全量回填或缓存未预热时触发。返回含信号列的多日 DataFrame。
enriched 持久化只存基础列(OHLCV + raw_*/turnover/consecutive_*), 不含
change_pct/ma20/signal_* 等派生列, 故此处需用 compute_all 补算全套指标。
内存控制(关键, 两层优化):
1. needed 白名单: regime 只需 change_pct/ma20/涨跌停信号等少数列, 不用 compute_all
算 72 列全套指标(那会让全量峰值达 6.8GB)。
2. 分批: 范围超过 batch_days 个交易日时按批切片, 每批带 warmup 前缀算完后 concat。
batch_days / warmup_days 由用户偏好控制(数据页「市场环境」卡片设置),
实测默认值(60/40)全量(515万行)峰值约 1.9GB, 4GB 内存机器可稳跑。
必须传入 instruments(涨跌停价表), 否则 compute_limit_signals 会跳过涨跌停信号。
"""
try:
from app.services import preferences
batch_days = preferences.get_regime_batch_days()
warmup_days = preferences.get_regime_warmup_days()
except Exception: # noqa: BLE001
batch_days = _REGIME_BATCH_DAYS_DEFAULT
warmup_days = _REGIME_WARMUP_DAYS_DEFAULT
try:
enriched_dir = repo.store.data_dir / "kline_daily_enriched"
if not enriched_dir.exists():
return None
from app.indicators.pipeline import compute_all
df = pl.scan_parquet(enriched_dir / "**" / "*.parquet").filter(
(pl.col("date") >= start) & (pl.col("date") <= end)
).collect()
if df.is_empty():
return None
instruments = repo.get_instruments()
historical_shares = repo.get_historical_shares()
df = compute_all(df, instruments=instruments, historical_shares=historical_shares)
return df
# 收集目标区间内所有交易日, 决定是否分批
target_dates = sorted(d for d in enriched_date_set(repo)
if start <= d <= end)
if not target_dates:
return None
# 小范围: 单次算(无分批开销)
if len(target_dates) <= batch_days:
df = _compute_batch(repo, enriched_dir, instruments, historical_shares,
target_dates[0], target_dates[-1], warmup_days)
return df if not df.is_empty() else None
# 大范围: 按交易日分批, 逐批算 + concat
batches = [
(target_dates[i], target_dates[min(i + batch_days - 1, len(target_dates) - 1)])
for i in range(0, len(target_dates), batch_days)
]
logger.info("regime fallback: %d 天分 %d 批 (每批≤%d天 + %d天warmup)",
len(target_dates), len(batches), batch_days, warmup_days)
parts: list[pl.DataFrame] = []
for bs, be in batches:
df = _compute_batch(repo, enriched_dir, instruments, historical_shares, bs, be, warmup_days)
if not df.is_empty():
parts.append(df)
if not parts:
return None
return pl.concat(parts, how="vertical_relaxed")
except Exception as e: # noqa: BLE001
logger.warning("regime scan_enriched_fallback failed: %s", e)
return None
+1 -1
View File
@@ -78,9 +78,9 @@ const nav = [
{ to: '/industry-analysis', label: '行业分析', icon: Landmark },
{ to: '/financials', label: '财务分析', icon: FileText },
{ to: '/monitor', label: '监控中心', icon: RadioTower },
{ to: '/regime', label: '市场环境', icon: Gauge, badge: 'beta' },
{ to: '/review', label: '复盘', icon: BookOpenCheck },
{ to: '/indices', label: '指数', icon: BarChart3 },
{ to: '/regime', label: '市场环境', icon: Gauge, badge: 'beta' },
{ to: '/data', label: '数据', icon: Database },
] as const
@@ -21,7 +21,7 @@ import { storage } from '@/lib/storage'
export type CardKey =
| 'instruments' | 'daily' | 'adj_factor' | 'enriched'
| 'index' | 'etf' | 'minute' | 'financials'
| 'index' | 'etf' | 'minute' | 'financials' | 'regime'
interface CardDef {
key: CardKey
@@ -43,6 +43,7 @@ export const DATA_CARD_DEFS: CardDef[] = [
{ key: 'etf', label: 'ETF', desc: '场内交易基金日K', defaultHiddenIfNoCap: false, defaultHidden: true },
{ key: 'minute', label: '分钟 K', desc: '分钟级K线(需 Pro+)', defaultHiddenIfNoCap: true },
{ key: 'financials', label: '财务数据', desc: '财报数据(需 Expert)', defaultHiddenIfNoCap: true },
{ key: 'regime', label: '市场环境', desc: '每日环境状态(本地计算)', defaultHiddenIfNoCap: false },
]
const DEFAULT_ORDER = DATA_CARD_DEFS.map(d => d.key)
@@ -0,0 +1,175 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, Loader2, Activity, Layers } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { toast } from '@/components/Toast'
/**
* 市场环境(regime) 计算设置 —— 控制盘后管道是否自动计算 + 全量回填分批参数。
*
* regime 是本地聚合计算(非外部拉取): 首次/regime 表为空时需全量回填多日,
* 内存与耗时较高。分批参数控制全量回填的内存峰值: 范围超过「每批天数」时
* 切成多批独立算后拼接, 每批带 warmup 前缀保证滚动窗口指标(ma20)边界正确。
*/
export function RegimeConfigCard() {
const qc = useQueryClient()
const prefs = useQuery({ queryKey: QK.preferences, queryFn: api.preferences })
const updateEnabled = useMutation({
mutationFn: (enabled: boolean) => api.updatePipelineRegimeEnabled(enabled),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
// 分批参数本地草稿(失焦/回车时整体保存), 避免每次按键都请求
const batchDays = prefs.data?.regime_batch_days ?? 60
const warmupDays = prefs.data?.regime_warmup_days ?? 40
const [draftBatch, setDraftBatch] = useState(String(batchDays))
const [draftWarmup, setDraftWarmup] = useState(String(warmupDays))
// prefs 加载后同步一次草稿(仅首次)
const [synced, setSynced] = useState(false)
if (!synced && prefs.data) {
setDraftBatch(String(batchDays))
setDraftWarmup(String(warmupDays))
setSynced(true)
}
const updateParams = useMutation({
mutationFn: (params: { batch_days?: number; warmup_days?: number }) =>
api.updateRegimeBatchParams(params),
onSuccess: (data) => {
setDraftBatch(String(data.regime_batch_days))
setDraftWarmup(String(data.regime_warmup_days))
qc.invalidateQueries({ queryKey: QK.preferences })
toast('分批参数已保存', 'success')
},
onError: () => toast('保存失败,请检查输入范围', 'error'),
})
const saveBatch = () => {
const n = Math.floor(Number(draftBatch) || 0)
if (n < 25 || n > 500) {
toast('每批天数范围 25 ~ 500', 'error')
setDraftBatch(String(batchDays))
return
}
if (n !== batchDays) updateParams.mutate({ batch_days: n })
}
const saveWarmup = () => {
const n = Math.floor(Number(draftWarmup) || 0)
if (n < 35 || n > 90) {
toast('预热天数范围 35 ~ 90', 'error')
setDraftWarmup(String(warmupDays))
return
}
if (n !== warmupDays) updateParams.mutate({ warmup_days: n })
}
// 默认关闭: 未设置过时视为 false
const on = prefs.data?.pipeline_regime_enabled ?? false
return (
<div className="space-y-3">
{/* 盘后自动计算开关 */}
<label className={`flex items-start gap-2.5 rounded-card border px-3 py-2.5 transition-colors cursor-pointer ${
on ? 'border-accent/40 bg-accent/[0.05]' : 'border-border bg-base/30 hover:border-border/70'
}`}>
<button
type="button"
onClick={() => updateEnabled.mutate(!on)}
disabled={updateEnabled.isPending}
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
on ? 'bg-accent border-accent' : 'bg-base border-border'
}`}
role="checkbox"
aria-checked={on}
>
{on && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<Activity className="h-3.5 w-3.5 text-accent" />
<span className="text-xs font-medium text-foreground"></span>
</div>
<div className="text-[10px] text-muted leading-snug mt-0.5">
; ,
</div>
</div>
</label>
{/* 分批参数 */}
<div className="rounded-card border border-border bg-base/30 px-3 py-2.5">
<div className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5 text-accent" />
<span className="text-xs font-medium text-foreground"></span>
</div>
<div className="mt-0.5 text-[10px] text-muted leading-snug">
</div>
<div className="mt-2.5 grid grid-cols-2 gap-2.5">
<div>
<label className="text-[10px] text-muted">()</label>
<input
type="number"
min={25}
max={500}
value={draftBatch}
onChange={e => setDraftBatch(e.target.value)}
onBlur={saveBatch}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
disabled={updateParams.isPending}
className="mt-1 h-7 w-full rounded-input border border-border bg-base px-2 text-xs text-foreground outline-none focus:border-accent disabled:opacity-50"
/>
<div className="mt-0.5 text-[9px] text-muted"> 25 ~ 500 · 60</div>
</div>
<div>
<label className="text-[10px] text-muted">()</label>
<input
type="number"
min={35}
max={90}
value={draftWarmup}
onChange={e => setDraftWarmup(e.target.value)}
onBlur={saveWarmup}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
disabled={updateParams.isPending}
className="mt-1 h-7 w-full rounded-input border border-border bg-base px-2 text-xs text-foreground outline-none focus:border-accent disabled:opacity-50"
/>
<div className="mt-0.5 text-[9px] text-muted"> 35 ~ 90 · 40</div>
</div>
</div>
{/* 快捷预设 */}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<span className="text-[10px] text-muted">:</span>
{[
{ label: '省内存', batch: 30, warmup: 40 },
{ label: '默认', batch: 60, warmup: 40 },
{ label: '更快', batch: 120, warmup: 40 },
].map(p => (
<button
key={p.label}
onClick={() => updateParams.mutate({ batch_days: p.batch, warmup_days: p.warmup })}
disabled={updateParams.isPending}
className={`h-5 rounded-btn border px-2 text-[10px] transition-colors disabled:opacity-50 ${
batchDays === p.batch
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border bg-base text-secondary hover:text-accent hover:border-accent/40'
}`}
>
{p.label} {p.batch}
</button>
))}
</div>
</div>
{(updateEnabled.isPending || updateParams.isPending) && (
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<Loader2 className="h-3 w-3 animate-spin" />
</div>
)}
</div>
)
}
@@ -19,6 +19,7 @@ export const CARD_META: Record<string, {
etf: { capKey: 'kline.daily.batch', tierReq: '' },
minute: { capKey: 'kline.minute.batch', tierReq: 'Pro+' },
financials: { capKey: 'financial', tierReq: 'Expert' },
regime: { capKey: '', tierReq: '' },
}
export function Pill({ label, value }: { label: string; value: number | string }) {
+13
View File
@@ -969,6 +969,9 @@ export interface Preferences {
pipeline_pull_a_share: boolean
pipeline_pull_etf: boolean
pipeline_pull_index: boolean
pipeline_regime_enabled: boolean
regime_batch_days: number
regime_warmup_days: number
pipeline_index_symbols: string
pipeline_schedule: { hour: number; minute: number }
instruments_schedule: { hour: number; minute: number }
@@ -1132,6 +1135,16 @@ export const api = {
method: 'PUT',
body: JSON.stringify(cfg),
}),
updatePipelineRegimeEnabled: (enabled: boolean) =>
request<{ pipeline_regime_enabled: boolean }>('/api/settings/preferences/pipeline-regime-enabled', {
method: 'PUT',
body: JSON.stringify({ pipeline_regime_enabled: enabled }),
}),
updateRegimeBatchParams: (params: { batch_days?: number; warmup_days?: number }) =>
request<{ regime_batch_days: number; regime_warmup_days: number }>('/api/settings/preferences/regime-batch-params', {
method: 'PUT',
body: JSON.stringify(params),
}),
updatePipelineIndexSymbols: (symbols: string) =>
request<{ pipeline_index_symbols: string }>('/api/settings/preferences/pipeline-index-symbols', {
method: 'PUT',
+1
View File
@@ -89,6 +89,7 @@ export const QK = {
regimeHistory: (limit?: number) => ['regime-history', limit ?? 0] as const,
regimeLatest: ['regime-latest'] as const,
regimeStates: (days: number) => ['regime-states', days] as const,
regimeCoverage: ['regime-coverage'] as const,
} as const
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
+42
View File
@@ -43,6 +43,7 @@ import { ExtendHistoryPanel } from '@/components/data/ExtendHistoryPanel'
import { RepairDailyPanel } from '@/components/data/RepairDailyPanel'
import { EnrichedRebuildPanel } from '@/components/data/EnrichedRebuildPanel'
import { MinuteSyncConfig } from '@/components/data/MinuteSyncConfig'
import { RegimeConfigCard } from '@/components/data/RegimeConfigCard'
import { PipelineScopeConfig } from '@/components/data/PipelineScopeConfig'
import { PageSettingsModal, getCardVisibility, getCardOrder, type CardKey } from '@/components/data/PageSettingsModal'
import { QuoteConfigCard } from '@/components/data/QuoteConfigCard'
@@ -70,6 +71,14 @@ export function Data() {
},
})
// 市场环境(regime) 覆盖画像 —— 走独立接口(/api/regime/coverage), 不在 data/status 内。
// 同步任务完成后刷新一次; 平时 30s 轮询与 status 对齐。
const regimeCoverage = useQuery({
queryKey: QK.regimeCoverage,
queryFn: () => api.regimeCoverage(),
refetchInterval: activeJobId ? false : 30_000,
})
const history = useQuery({
queryKey: QK.pipelineJobs,
queryFn: () => api.pipelineJobs(15),
@@ -260,6 +269,8 @@ export function Data() {
if (job.data && (job.data.status === 'succeeded' || job.data.status === 'failed')) {
qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
// 同步任务结束后 regime 覆盖范围可能变化, 一并刷新画像
qc.invalidateQueries({ queryKey: QK.regimeCoverage })
const t = setTimeout(() => setActiveJobId(null), 5_000)
return () => clearTimeout(t)
}
@@ -323,6 +334,9 @@ export function Data() {
sync_index: 'index_daily',
sync_minute: 'minute',
extend_minute: 'minute',
compute_regime: 'regime',
// regime 软失败时入 skipped_stages 的是 'regime'(非 stage 名), 也映射到该卡片
regime: 'regime',
}
const activeCard = isRunning && job.data ? STAGE_CARD[job.data.stage] ?? null : null
@@ -530,6 +544,26 @@ export function Data() {
/>
)
}
case 'regime':
return (
<StatCard
title="市场环境"
hint="每日环境状态 · 本地计算"
stats={regimeCoverage.data ?? null}
loading={regimeCoverage.isLoading}
active={activeCard === 'regime'}
done={doneStages.has('regime')}
skipped={skippedCards.has('regime')}
stagePct={activeCard === 'regime' ? (job.data?.stage_pct ?? 0) : 0}
tierKey="regime"
capLimits={caps.data?.capabilities}
tierLabel={caps.data?.label}
auto={prefs.data?.pipeline_regime_enabled === true}
subLabel="状态 · 综合分 · 指标"
onSettings={hasData ? () => setOpenSettings(v => v === 'regime' ? null : 'regime') : undefined}
settingsOpen={openSettings === 'regime'}
/>
)
default:
return null
}
@@ -984,6 +1018,14 @@ export function Data() {
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'regime' && (
<SettingsModal title="市场环境 · 计算设置" onClose={() => setOpenSettings(null)}>
<RegimeConfigCard />
</SettingsModal>
)}
</AnimatePresence>
<AnimatePresence>
{openSettings === 'pipeline-scope' && (
<SettingsModal title="盘后管道 · 拉取内容" onClose={() => setOpenSettings(null)}>
+271 -66
View File
@@ -3,11 +3,17 @@
*
* 数据来源: 后端 regime_builder 批算的时序表(每日离散状态 + 多维指标)。
* 不复刻 Dashboard 的当日总览(那是单日快照), 聚焦历史趋势与状态分布。
*
* 时间范围: 1年(250交易日) / 2年(500) / 自定义(1~1000天) / 全部(走日期范围)。
* 美化对齐 Dashboard 设计语言: 半透明 surface 卡片 + 渐变竖条标题 + 语义色。
*/
import { useEffect, useMemo, useRef, useState } from 'react'
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 } from 'lucide-react'
import {
Activity, RefreshCw, Loader2, Gauge, TrendingUp,
Flame, BarChart3, Pencil,
} from 'lucide-react'
import {
api, type RegimeRow, type RegimeState,
REGIME_STATE_LABELS, REGIME_STATE_COLORS,
@@ -16,9 +22,50 @@ 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']
// ── 时间范围 ──────────────────────────────────────────────
// 1年=250 交易日, 2年=500 交易日; 自定义 1~1000; 全部走 start/end 日期范围。
type RangePreset = '1y' | '2y' | 'all' | { custom: number }
const RANGE_LABEL: Record<'1y' | '2y' | 'all', string> = {
'1y': '1年', '2y': '2年', all: '全部',
}
/** 把 preset 解析成 (start?, end?, limit?) 三元组供 history 接口使用。 */
function resolveHistoryRange(
preset: RangePreset,
coverage: { earliest_date: string | null; latest_date: string | null } | undefined,
): { start?: string; end?: string; limit?: number } {
if (preset === '1y') return { limit: 250 }
if (preset === '2y') return { limit: 500 }
if (preset === 'all') {
// 全部: 用 coverage 实际日期范围, 不传 limit
return { start: coverage?.earliest_date ?? undefined, end: coverage?.latest_date ?? undefined }
}
// 自定义天数
return { limit: Math.max(1, Math.min(1000, preset.custom)) }
}
/** history/states 共用的"天数"语义: 用于 states 接口 + 标题展示。 */
function resolveDays(
preset: RangePreset,
coverage: { rows: number } | undefined,
): number {
if (preset === '1y') return 250
if (preset === '2y') return 500
if (preset === 'all') return coverage?.rows && coverage.rows > 0 ? coverage.rows : 1000
return Math.max(1, Math.min(1000, preset.custom))
}
function isPresetKey(p: RangePreset, k: '1y' | '2y' | 'all'): boolean {
return p === k
}
// ── EChart hook ───────────────────────────────────────────
function useEChart(option: echarts.EChartsOption | null, deps: unknown[]) {
const ref = useRef<HTMLDivElement>(null)
const instRef = useRef<echarts.ECharts | null>(null)
@@ -39,14 +86,42 @@ function useEChart(option: echarts.EChartsOption | null, deps: unknown[]) {
return ref
}
// ── 页内通用 SectionTitle (对齐 Dashboard 渐变竖条风格) ────
function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; title: string; hint?: ReactNode }) {
return (
<div className="flex items-center gap-2">
<span className="h-3 w-0.5 rounded-full bg-gradient-to-b from-accent to-accent/30" />
<Icon className="h-3.5 w-3.5 text-accent" />
<h2 className="text-xs font-semibold text-foreground">{title}</h2>
{hint != null && <span className="ml-auto text-[10px] text-muted font-mono">{hint}</span>}
</div>
)
}
// ── 卡片容器样式 (Dashboard 同款) ─────────────────────────
const cardCls = 'rounded-card border border-border bg-surface/80 shadow-[0_1px_2px_hsl(var(--border)/0.4)] backdrop-blur-sm transition-shadow hover:shadow-[0_2px_8px_hsl(var(--border)/0.5)]'
// ── 主组件 ────────────────────────────────────────────────
export function Regime() {
const qc = useQueryClient()
const [days, setDays] = useState(120)
const [range, setRange] = useState<RangePreset>('1y')
const [customOpen, setCustomOpen] = useState(false)
const ct = useChartTheme()
// coverage: "全部"模式 + 标题展示依赖
const coverage = useQuery({
queryKey: QK.regimeCoverage,
queryFn: () => api.regimeCoverage(),
staleTime: 5 * 60 * 1000,
})
const days = resolveDays(range, coverage.data)
const histRange = resolveHistoryRange(range, coverage.data)
// queryKey 用 range 的完整三元组区分: limit / start+end(全部) / custom天数
const history = useQuery({
queryKey: QK.regimeHistory(days),
queryFn: () => api.regimeHistory(undefined, undefined, days),
queryKey: ['regime-history', range] as const,
queryFn: () => api.regimeHistory(histRange.start, histRange.end, histRange.limit),
staleTime: 5 * 60 * 1000,
})
const states = useQuery({
@@ -122,12 +197,12 @@ export function Regime() {
setRecomputing(true)
try {
const r = await api.regimeRecompute()
// computed=0 表示无缺口/stale, 数据未变更; >0 表示新增/重算了 N 天
toast(r.computed > 0 ? `重算完成 · 新增 ${r.computed}` : '重算完成 · 数据已是最新', 'success')
await Promise.all([
qc.invalidateQueries({ queryKey: ['regime-history'] }),
qc.invalidateQueries({ queryKey: ['regime-states'] }),
qc.invalidateQueries({ queryKey: ['regime-latest'] }),
qc.invalidateQueries({ queryKey: QK.regimeCoverage }),
])
} catch (e) {
toast(`重算失败 · ${String((e as Error)?.message || e)}`, 'error')
@@ -136,58 +211,120 @@ export function Regime() {
}
}
// 自定义按钮标签
const customLabel = typeof range === 'object'
? `自定义 ${range.custom}`
: '自定义'
return (
<div className="mx-auto max-w-6xl px-4 py-5 space-y-4">
{/* 头部 */}
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-accent" />
<h1 className="text-base font-semibold text-foreground"></h1>
<span className="text-xs text-muted"> · · </span>
<div className="ml-auto flex items-center gap-2">
<select value={days} onChange={e => setDays(Number(e.target.value))}
className="h-7 rounded-btn border border-border bg-base px-2 text-xs text-foreground">
<option value={60}> 60 </option>
<option value={120}> 120 </option>
<option value={250}> 250 </option>
</select>
<button onClick={handleRecompute} disabled={recomputing}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-btn border border-border bg-base text-xs text-secondary hover:text-accent disabled:opacity-50">
{recomputing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
{recomputing ? '重算中…' : '重算'}
</button>
{/* ── 头部 (Dashboard 渐变条卡片) ── */}
<div className={cn(cardCls, 'relative overflow-hidden rounded-card bg-gradient-to-r from-surface/90 to-surface/70 px-4 py-3')}>
<div className="absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-accent to-accent/20" />
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-accent" />
<h1 className="text-base font-semibold text-foreground"></h1>
<span className="text-xs text-muted"> · · </span>
<div className="ml-auto flex items-center gap-2">
{/* 时间范围按钮组 */}
<div className="flex items-center rounded-btn border border-border bg-base/60 p-0.5">
{(['1y', '2y', 'all'] as const).map(k => (
<button
key={k}
onClick={() => setRange(k)}
className={cn(
'h-6 rounded-[5px] px-2.5 text-xs font-medium transition-colors',
isPresetKey(range, k)
? 'bg-accent text-white shadow-sm'
: 'text-secondary hover:text-foreground',
)}
>
{RANGE_LABEL[k]}
</button>
))}
<button
onClick={() => setCustomOpen(true)}
className={cn(
'inline-flex items-center gap-1 h-6 rounded-[5px] px-2.5 text-xs font-medium transition-colors',
typeof range === 'object'
? 'bg-accent text-white shadow-sm'
: 'text-secondary hover:text-foreground',
)}
>
{typeof range === 'object' && <Pencil className="h-3 w-3" />}
{customLabel}
</button>
</div>
{/* 重算 */}
<button onClick={handleRecompute} disabled={recomputing}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-btn border border-border bg-base text-xs text-secondary hover:text-accent disabled:opacity-50">
{recomputing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
{recomputing ? '重算中…' : '重算'}
</button>
</div>
</div>
</div>
{/* 最新日概览 */}
{/* ── 最新日概览 (4 个指标卡) ── */}
{latest ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-card border border-border bg-base p-3">
<div className="text-[10px] text-muted"> · {latest.date}</div>
<div className="mt-1 flex items-baseline gap-2">
{/* 状态卡 */}
<div className={cn(cardCls, 'p-3')}>
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<Gauge className="h-3 w-3" /> · {latest.date}
</div>
<div className="mt-1.5 flex items-baseline gap-2">
<span className="text-2xl font-bold" style={{ color: REGIME_STATE_COLORS[latest.state] }}>
{REGIME_STATE_LABELS[latest.state]}
</span>
<span className="text-sm text-muted">{latest.score} </span>
</div>
</div>
<div className="rounded-card border border-border bg-base p-3">
<div className="text-[10px] text-muted"> / </div>
<div className="mt-1 text-lg font-semibold text-foreground">
<span className="text-red-400">{latest.limit_up}</span>
<span className="mx-1 text-muted">/</span>
<span className="text-green-400">{latest.limit_down}</span>
{/* 评分进度条 0~100 */}
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-base">
<div className="h-full rounded-full transition-all"
style={{ width: `${Math.max(2, Math.min(100, latest.score))}%`, backgroundColor: REGIME_STATE_COLORS[latest.state] }} />
</div>
<div className="text-[10px] text-muted"> {latest.max_consecutive} · {(latest.seal_rate * 100).toFixed(0)}%</div>
</div>
<div className="rounded-card border border-border bg-base p-3">
<div className="text-[10px] text-muted"></div>
<div className="mt-1 text-lg font-semibold text-foreground">{latest.up_ratio.toFixed(2)}</div>
<div className="text-[10px] text-muted"> {latest.up_count} · {latest.down_count}</div>
{/* 涨停 / 跌停 */}
<div className={cn(cardCls, 'p-3')}>
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<Flame className="h-3 w-3" /> /
</div>
<div className="mt-1.5 flex items-baseline gap-1 text-lg font-semibold">
<span className="text-bull">{latest.limit_up}</span>
<span className="mx-0.5 text-muted">/</span>
<span className="text-bear">{latest.limit_down}</span>
</div>
<div className="mt-1 text-[10px] text-muted"> {latest.max_consecutive} · {(latest.seal_rate * 100).toFixed(0)}%</div>
</div>
<div className="rounded-card border border-border bg-base p-3">
<div className="text-[10px] text-muted"></div>
<div className="mt-1 text-lg font-semibold text-foreground">{fmtBigNum(latest.total_amount)}</div>
<div className="text-[10px] text-muted">MA20 {(latest.above_ma20_pct * 100).toFixed(0)}%</div>
{/* 涨跌家数比 */}
<div className={cn(cardCls, 'p-3')}>
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<TrendingUp className="h-3 w-3" />
</div>
<div className="mt-1.5 text-lg font-semibold text-foreground">{latest.up_ratio.toFixed(2)}</div>
<div className="mt-1 flex items-center gap-1.5 text-[10px]">
<span className="text-bull"> {latest.up_count}</span>
<span className="text-muted">·</span>
<span className="text-bear"> {latest.down_count}</span>
</div>
</div>
{/* 成交额 */}
<div className={cn(cardCls, 'p-3')}>
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<BarChart3 className="h-3 w-3" />
</div>
<div className="mt-1.5 text-lg font-semibold text-foreground">{fmtBigNum(latest.total_amount)}</div>
<div className="mt-2 flex items-center gap-1.5">
<span className="text-[10px] text-muted">MA20 {(latest.above_ma20_pct * 100).toFixed(0)}%</span>
<div className="ml-auto h-1.5 w-12 overflow-hidden rounded-full bg-base">
<div className="h-full rounded-full bg-accent"
style={{ width: `${Math.max(0, Math.min(100, latest.above_ma20_pct * 100))}%` }} />
</div>
</div>
</div>
</div>
) : (
@@ -196,42 +333,110 @@ export function Regime() {
</div>
)}
{/* 状态色带 */}
{/* ── 状态色带时间轴 ── */}
{rows.length > 0 && (
<div className="rounded-card border border-border bg-base p-3">
<div className="mb-2 text-xs font-medium text-foreground"></div>
<div className="flex h-6 w-full overflow-hidden rounded">
<div className={cn(cardCls, 'p-3')}>
<SectionTitle icon={Activity} title="状态时间轴"
hint={`${rows[0]?.date}${rows[rows.length - 1]?.date} · ${rows.length}`} />
<div className="mt-2.5 flex h-7 w-full overflow-hidden rounded-md">
{rows.map(r => (
<div key={r.date} title={`${r.date} ${REGIME_STATE_LABELS[r.state]}(${r.score})`}
className="flex-1 min-w-[2px]" style={{ backgroundColor: REGIME_STATE_COLORS[r.state] }} />
className="flex-1 min-w-[2px] transition-opacity hover:opacity-80"
style={{ backgroundColor: REGIME_STATE_COLORS[r.state] }} />
))}
</div>
<div className="mt-1.5 flex items-center gap-3 text-[10px] text-muted">
<span>{rows[0]?.date}</span>
<div className="ml-auto flex items-center gap-2">
{STATE_ORDER.map(s => (
<span key={s} className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-sm" style={{ backgroundColor: REGIME_STATE_COLORS[s] }} />
{REGIME_STATE_LABELS[s]}
</span>
))}
</div>
<span>{rows[rows.length - 1]?.date}</span>
<div className="mt-2 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[10px] text-muted">
{STATE_ORDER.map(s => (
<span key={s} className="flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded"
style={{ backgroundColor: REGIME_STATE_COLORS[s] }} />
{REGIME_STATE_LABELS[s]}
</span>
))}
</div>
</div>
)}
{/* 趋势图 + 分布图 */}
{/* ── 趋势图 + 分布图 ── */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="rounded-card border border-border bg-base p-3 lg:col-span-2">
<div className="mb-1 text-xs font-medium text-foreground"> · </div>
<div ref={trendRef} className="h-[320px]" />
<div className={cn(cardCls, 'p-3 lg:col-span-2')}>
<SectionTitle icon={Activity} title="环境综合分 · 涨停数趋势" />
<div ref={trendRef} className="mt-2 h-[320px]" />
</div>
<div className="rounded-card border border-border bg-base p-3">
<div className="mb-1 text-xs font-medium text-foreground"> {days} </div>
<div ref={pieRef} className="h-[320px]" />
<div className={cn(cardCls, 'p-3')}>
<SectionTitle icon={Gauge} title="状态分布" hint={`${days}`} />
<div ref={pieRef} className="mt-2 h-[320px]" />
</div>
</div>
{/* ── 自定义天数弹窗 ── */}
{customOpen && (
<CustomDaysModal
current={typeof range === 'object' ? range.custom : 120}
onClose={() => setCustomOpen(false)}
onApply={(d) => { setRange({ custom: d }); setCustomOpen(false) }}
/>
)}
</div>
)
}
// ── 自定义天数输入弹窗 ────────────────────────────────────
function CustomDaysModal({ current, onClose, onApply }: {
current: number
onClose: () => void
onApply: (days: number) => void
}) {
const [val, setVal] = useState(String(current))
const inputRef = useRef<HTMLInputElement>(null)
const apply = () => {
const n = Math.max(1, Math.min(1000, Math.floor(Number(val) || 0)))
if (Number.isNaN(n) || n < 1) {
toast('请输入 1 ~ 1000 之间的天数', 'error')
return
}
onApply(n)
}
return (
<Modal onClose={onClose} ariaLabel="自定义天数" initialFocusRef={inputRef}
panelClassName="w-[88vw] max-w-xs bg-surface border border-border rounded-card shadow-xl p-4">
<div className="space-y-3">
<div>
<div className="text-xs font-medium text-foreground"></div>
<div className="mt-0.5 text-[10px] text-muted"> 1 ~ 1000 </div>
</div>
<input
ref={inputRef}
type="number"
min={1}
max={1000}
value={val}
onChange={e => setVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') apply() }}
className="h-8 w-full rounded-input border border-border bg-base px-2.5 text-sm text-foreground outline-none focus:border-accent"
/>
{/* 快捷预设 */}
<div className="flex flex-wrap gap-1.5">
{[60, 90, 180, 365].map(d => (
<button key={d} onClick={() => setVal(String(d))}
className="h-6 rounded-btn border border-border bg-base px-2 text-[11px] text-secondary hover:text-accent hover:border-accent/40 transition-colors">
{d}
</button>
))}
</div>
<div className="flex justify-end gap-2 pt-1">
<button onClick={onClose}
className="h-7 rounded-btn px-3 text-xs text-secondary hover:text-foreground transition-colors">
</button>
<button onClick={apply}
className="h-7 rounded-btn bg-accent px-3 text-xs font-medium text-white hover:bg-accent/90 transition-colors">
</button>
</div>
</div>
</Modal>
)
}
@@ -39,6 +39,7 @@ const BUILTIN_PAGES: NavEntry[] = [
{ id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
{ id: '/stock-analysis', label: '个股分析', type: 'builtin', visible: true },
{ id: '/regime', label: '市场环境', type: 'builtin', visible: true },
{ id: '/review', label: '复盘', type: 'builtin', visible: true },
{ id: '/financials', label: '财务分析', type: 'builtin', visible: true },
{ id: '/indices', label: '指数', type: 'builtin', visible: true },