Files
tick-stock-panel/backend/app/api/regime.py
T
shy3130 46ebcd89c8 fix(regime): 修复重算无产出并补全 beta 标签
后端
- /recompute 全量分支原本误调 compute_regime_incremental(增量补差),
  无缺口/stale 时直接返回 0 行; 改为用 earliest_enriched_date ~ 今天
  走 run_regime_batch + upsert 强制覆盖, 真正实现"全量重算"
- _scan_enriched_fallback 原本漏调 compute_limit_signals 的 instruments
  参数致慢路径必抛异常 → enriched 缓存未预热时拿不到含信号列数据 → 聚合
  返回空; 改用主管道同款 compute_all(df, instruments, historical_shares)
  一站式补算, 与 indicators/pipeline.py 对齐
- 新增 enriched_date_set / earliest_enriched_date 辅助函数(抽出复用)

前端
- Regime 重算按钮: 加"重算中…"文字反馈 + 完成 toast(区分有/无新数据) +
  catch 捕获错误(原异常被静默吞掉)
- 市场环境菜单加 beta 胶囊标签(对齐 Settings 数据源标签样式)

验证
- 后端 582 passed; 真实数据全量重算产出 986 行(2022-07-08 ~ 2026-07-31)
- 前端 tsc + pnpm build 通过
2026-08-02 12:22:48 +08:00

157 lines
4.9 KiB
Python

"""市场环境(regime) API — 时序查询 + 手动重算。
装配逻辑在 app.services.regime_builder(纯函数), API 层薄壳 + TTL 缓存。
"""
from __future__ import annotations
import threading
import time
from datetime import date
from typing import Any
from fastapi import APIRouter, Query, Request
from app.services import regime_builder
router = APIRouter(prefix="/api/regime", tags=["regime"])
_CACHE_TTL = 5.0
_cache: dict[str, Any] | None = None
_cache_ts: float = 0.0
_cache_lock = threading.Lock()
def invalidate_regime_cache() -> None:
"""清空 regime 查询缓存。批算/重算后调用。"""
global _cache, _cache_ts
with _cache_lock:
_cache = None
_cache_ts = 0.0
def _data_dir(request: Request) -> Any:
return request.app.state.repo.store.data_dir
def _df_to_records(df) -> list[dict]:
"""polars DataFrame → JSON 安全的 list[dict](date 转 ISO 字符串)。"""
if df is None or df.is_empty():
return []
records = []
for r in df.to_dicts():
if "date" in r and r["date"] is not None:
r["date"] = str(r["date"])
records.append(r)
return records
@router.get("/history")
def regime_history(
request: Request,
start: date | None = Query(None),
end: date | None = Query(None),
limit: int = Query(120, ge=1, le=1000),
):
"""历史环境时序(含状态/指标)。默认最近 N 天。"""
global _cache, _cache_ts
cache_key = f"hist|{start}|{end}|{limit}"
with _cache_lock:
if (
_cache is not None
and _cache.get("key") == cache_key
and (time.time() - _cache_ts) < _CACHE_TTL
):
return _cache["data"]
df = regime_builder.load_regime_history(_data_dir(request))
if df.is_empty():
result: dict = {"rows": [], "total": 0}
else:
if start:
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")
rows = _df_to_records(df)
result = {"rows": rows, "total": len(rows)}
with _cache_lock:
_cache = {"key": cache_key, "data": result}
_cache_ts = time.time()
return result
def pl_col_date(df, op: str, value: date):
"""polars 日期过滤辅助(避免重复 import)。"""
import polars as pl
col = pl.col("date")
return col >= value if op == ">=" else col <= value
@router.get("/latest")
def regime_latest(request: Request):
"""最新一日环境(轻量)。"""
df = regime_builder.load_regime_history(_data_dir(request))
if df.is_empty():
return {"row": None}
latest = df.sort("date", descending=True).head(1)
rows = _df_to_records(latest)
return {"row": rows[0] if rows else None}
@router.get("/states")
def regime_states(
request: Request,
days: int = Query(60, ge=1, le=1000),
):
"""状态分布统计(各状态天数/占比)。"""
df = regime_builder.load_regime_history(_data_dir(request))
if df.is_empty():
return {"distribution": [], "days": 0}
df = df.sort("date", descending=True).head(days)
total = df.height
counts = df.group_by("state").len().sort("len", descending=True)
distribution = [
{
"state": r["state"],
"label": regime_builder.STATE_LABELS.get(r["state"], r["state"]),
"count": r["len"],
"pct": round(r["len"] / total * 100, 1) if total else 0,
}
for r in counts.to_dicts()
]
return {"distribution": distribution, "days": total}
@router.get("/coverage")
def regime_coverage(request: Request):
"""regime 数据覆盖元信息(供数据画像)。"""
return regime_builder.get_regime_coverage(_data_dir(request))
@router.post("/recompute")
def regime_recompute(request: Request, start: date | None = None, end: date | None = None):
"""手动触发重算(全量或指定区间)。管理员操作。
- 不传 start: 强制全量重算(enriched 最早日 ~ 今天), 覆盖所有已有行。
与 daily_pipeline 的增量补差(compute_regime_incremental)不同 —— 此接口面向
人工「我要重新算一遍」的预期, 必须真正重算而非增量补缺口。
- 传 start: 仅重算 [start, end] 区间。
"""
repo = request.app.state.repo
data_dir = _data_dir(request)
end = end or date.today()
if start is None:
# 全量: 从 enriched 最早日强制重算到今天
earliest = regime_builder.earliest_enriched_date(repo)
if earliest is None:
invalidate_regime_cache()
return {"ok": True, "computed": 0}
start = earliest
new_rows = regime_builder.run_regime_batch(repo, start=start, end=end)
if not new_rows.is_empty():
regime_builder.upsert_regime_history(data_dir, new_rows)
invalidate_regime_cache()
return {"ok": True, "computed": new_rows.height if not new_rows.is_empty() else 0}