Files
shy3130 697c27bb02 feat(v0.2): 市场阶段与主线识别 + 因子挖掘全链路 + 数据层完善
- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动,
  EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合,
  可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存
- 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档),
  周度调度默认关闭且永不自动发布
- 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益,
  信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错)
- 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复
- 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
2026-08-16 23:39:07 +08:00

360 lines
12 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 Annotated, Any
import polars as pl
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))
# 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)}
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] 区间。
- 重算后统一重标情绪周期阶段(refresh_phase_labels)并回填主线
(概念+行业, 概念成分为当前快照回看历史, 早年有归属漂移)。
"""
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)
phase_days = regime_builder.refresh_phase_labels(data_dir)
from app.services import market_mainline
mainline_rows = 0
for kind in ("concept", "industry"):
rows = market_mainline.compute_mainline_range(repo, data_dir, start, end, kind=kind)
if not rows.is_empty():
market_mainline.upsert_mainline_history(data_dir, rows)
mainline_rows += rows.height
invalidate_regime_cache()
return {
"ok": True,
"computed": new_rows.height if not new_rows.is_empty() else 0,
"phase_days": phase_days,
"mainline_rows": mainline_rows,
}
@router.get("/phases")
def regime_phases(
request: Request,
start: date | None = None,
end: date | None = None,
):
"""情绪周期阶段段列表: 连续同阶段合段, 附段内均值指标与主导主线。
直接回答「什么阶段走什么主升」: 主升/高潮段的主导主线即该段行情主线。
主线按段内进入当日 top5 的天数与累计分排序, 取前 3。
"""
from app.services.market_mainline import load_mainline_history
from app.services.market_phase import PHASE_LABELS
data_dir = _data_dir(request)
df = regime_builder.load_regime_history(data_dir)
if df.is_empty() or "phase" not in df.columns:
return {"segments": [], "total": 0}
if start:
df = df.filter(pl_col_date(df, ">=", start))
if end:
df = df.filter(pl_col_date(df, "<=", end))
df = df.sort("date")
if df.is_empty():
return {"segments": [], "total": 0}
mainline = load_mainline_history(data_dir, "concept")
segments: list[dict] = []
cur: dict | None = None
for r in df.iter_rows(named=True):
phase = r.get("phase")
if cur is None or cur["phase"] != phase:
cur = {
"phase": phase,
"label": PHASE_LABELS.get(phase, phase),
"start": str(r["date"]),
"end": str(r["date"]),
"days": 0,
"_height": 0.0,
"_first_board": 0.0,
"_ge2": 0.0,
"_promo_sum": 0.0,
"_promo_n": 0,
"_seal": 0.0,
}
segments.append(cur)
cur["end"] = str(r["date"])
cur["days"] += 1
cur["_height"] += float(r.get("max_consecutive") or 0)
cur["_first_board"] += float(r.get("first_board") or 0)
cur["_ge2"] += float(r.get("ge2_count") or 0)
promo = r.get("promo_rate")
if promo is not None:
cur["_promo_sum"] += float(promo)
cur["_promo_n"] += 1
cur["_seal"] += float(r.get("seal_rate") or 0)
for seg in segments:
n = seg["days"]
seg["avg_height"] = round(seg.pop("_height") / n, 1)
seg["avg_first_board"] = round(seg.pop("_first_board") / n, 1)
seg["avg_ge2"] = round(seg.pop("_ge2") / n, 1)
seg["avg_promo"] = (
round(seg.pop("_promo_sum") / seg["_promo_n"], 3) if seg["_promo_n"] else None
)
seg.pop("_promo_n")
seg["avg_seal_rate"] = round(seg.pop("_seal") / n, 3)
seg["top_mainlines"] = _segment_mainlines(
mainline, date.fromisoformat(seg["start"]), date.fromisoformat(seg["end"])
)
return {"segments": segments, "total": len(segments)}
def _segment_mainlines(mainline: pl.DataFrame, start: date, end: date, top: int = 3) -> list[dict]:
"""段内主导主线: 按进入当日 top5 的天数与累计分排序。"""
if mainline.is_empty():
return []
seg = mainline.filter(
(pl.col("date") >= start) & (pl.col("date") <= end) & (pl.col("rank") <= 5)
)
if seg.is_empty():
return []
ranked = (
seg.group_by("member")
.agg(
pl.col("date").n_unique().alias("top5_days"),
pl.col("score").sum().alias("score_sum"),
pl.col("max_boards").max().alias("max_boards"),
pl.col("leader_symbol").first().alias("leader_symbol"),
)
.sort(["top5_days", "score_sum"], descending=[True, True])
.head(top)
)
return [
{
"member": r["member"],
"top5_days": r["top5_days"],
"score_sum": round(r["score_sum"], 1),
"max_boards": r["max_boards"],
"leader_symbol": r["leader_symbol"],
}
for r in ranked.to_dicts()
]
@router.post("/mainline/recompute")
def mainline_recompute(request: Request):
"""全量重算主线(概念+行业), 应用当前过滤配置。窄扫描, 秒级。
修改过滤配置(preferences mainline-filter)后调用本接口生效,
无需触发较重的 regime 全量重算。
"""
from app.services import market_mainline
repo = request.app.state.repo
data_dir = _data_dir(request)
earliest = regime_builder.earliest_enriched_date(repo)
if earliest is None:
return {"ok": True, "rows": 0}
rows = 0
for kind in ("concept", "industry"):
computed = market_mainline.compute_mainline_range(
repo, data_dir, earliest, date.today(), kind=kind
)
if not computed.is_empty():
market_mainline.upsert_mainline_history(data_dir, computed)
rows += computed.height
return {"ok": True, "rows": rows}
@router.get("/mainline")
def regime_mainline(
request: Request,
start: date | None = None,
end: date | None = None,
top: Annotated[int, Query(ge=1, le=30)] = 10,
kind: Annotated[str, Query(pattern="^(concept|industry)$")] = "concept",
):
"""每日主线排行(截 rank<=top) + 窗口内持续性汇总。
membership_note 说明概念成分口径(当前快照回看历史)。
"""
from app.services.market_mainline import MEMBERSHIP_NOTE, load_mainline_history
try:
from app.services import preferences
filter_cfg = preferences.get_mainline_filter_config()
except Exception:
filter_cfg = {"min_members": 4, "max_members": 600, "blacklist": []}
df = load_mainline_history(_data_dir(request), kind)
if df.is_empty():
return {"rows": [], "leaders": [], "membership_note": MEMBERSHIP_NOTE, "filter": filter_cfg}
if start:
df = df.filter(pl_col_date(df, ">=", start))
if end:
df = df.filter(pl_col_date(df, "<=", end))
df = df.sort(["date", "rank"])
rows_df = df.filter(pl.col("rank") <= top)
leaders = (
df.filter(pl.col("rank") == 1)
.group_by("member")
.agg(
pl.col("date").n_unique().alias("top1_days"),
pl.col("score").mean().round(1).alias("avg_score"),
pl.col("max_boards").max().alias("max_boards"),
)
.sort(["top1_days", "avg_score"], descending=[True, True])
.head(10)
)
return {
"rows": _df_to_records(rows_df),
"leaders": leaders.to_dicts(),
"membership_note": MEMBERSHIP_NOTE,
"filter": filter_cfg,
}