mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
Merge pull request #158 from shy3130/feat/regime-enhance
feat(regime): 市场环境页全面增强(内存可控/开关/画像/美化/分批可配)
This commit is contained in:
@@ -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)}
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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 视图…")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 前缀列表 =====
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user