Files
tick-stock-panel/backend/app/services/market_phase.py
T
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

256 lines
10 KiB
Python

"""市场情绪周期阶段(冰点/启动/主升/高潮/退潮/修复) — 纯函数模块。
与 regime_builder 的 5 档 state(强势/偏强/震荡/偏弱/弱势)并存:
- state: 综合情绪分(赚钱/投机/抗跌/趋势 4 维加权), 回测环境过滤与挖掘在用, 不动。
- phase: 基于"连板梯队"的阶段(用户判据: 高度、宽度、晋级率、梯队完整度),
刻画情绪周期位置(冰点→启动→主升→高潮→退潮), 供市场环境页分析与主线识别。
驱动量(全部可从已存储的 consecutive_limit_ups 派生, 2020-08 起全历史可回算):
- height 高度: 当日最高连板数
- first_board 首板宽度: 首板(1 连板)家数
- ge2/ge3/ge5 宽度: N 板以上家数
- promo 晋级率: 昨日连板池今日继续封板的比例 (池 <10 家记 null)
- seal_rate 封板率: regime 已有列
- ladder_completeness 梯队完整度: 2..height 档位中非空占比
阈值标定: 2020-08~2026-08 全市场 1454 个交易日的 p10/p60/p90 分位数
(标定脚本一次性运行, 不提交); 关键异常段抽查(2024-09/10 rally→climax→ebb,
2024-01/02 微盘退潮)人工核过归属。阈值集中在下方, 调整只需改这里。
"""
from __future__ import annotations
import logging
import polars as pl
logger = logging.getLogger(__name__)
# ───────────────────────── 阶段词汇 ─────────────────────────
PHASE_ICE = "ice"
PHASE_IGNITE = "ignite"
PHASE_RALLY = "rally"
PHASE_CLIMAX = "climax"
PHASE_EBB = "ebb"
PHASE_REPAIR = "repair"
PHASE_LABELS = {
PHASE_ICE: "冰点",
PHASE_IGNITE: "启动",
PHASE_RALLY: "主升",
PHASE_CLIMAX: "高潮",
PHASE_EBB: "退潮",
PHASE_REPAIR: "修复",
}
# 规则判定优先级: 高潮 > 主升 > 退潮 > 启动 > 冰点 > 修复(兜底)
_PHASE_PRIORITY = (PHASE_CLIMAX, PHASE_RALLY, PHASE_EBB, PHASE_IGNITE, PHASE_ICE)
# ───────────────────────── 阈值(标定自 2020-08~2026-08 分位数) ─────────────────────────
# 高潮: 情绪极端宣泄 — 二板以上宽度或首板数达到 p90 的 ~2 倍以上(历史 <2% 天数)
CLIMAX_GE2 = 50 # p90(25) 的 2 倍
CLIMAX_FIRST_BOARD = 220 # p90(88) 的 2.5 倍
# 主升: 高度/宽度/晋级率同时高于中位 (p60), 或晋级率极强 (p85+)
RALLY_HEIGHT = 7 # p60
RALLY_GE2 = 15 # p60
RALLY_PROMO = 0.23 # p60
RALLY_PROMO_ALT = 0.30 # p85+
RALLY_GE2_ALT = 12
RALLY_HEIGHT_ALT = 5
# 退潮: 晋级率崩至 p20 以下且宽度自近期高位回落; 或晋级率/封板率双弱
EBB_PROMO = 0.15 # p20
EBB_PROMO_STRICT = 0.13
EBB_SEAL = 0.57 # ~p10-p15
EBB_RECENT_GE2 = 12 # 5 日前 ge2 高于此才认定"自高位退潮"
EBB_RECENT_HEIGHT = 6
# 启动: 宽度/高度自低位扩张且晋级率恢复
IGNITE_GE2_DELTA = 3 # ge2 较 5 日前增加量
IGNITE_GE2 = 8 # p20-p40
IGNITE_PROMO = 0.20 # ~p55
IGNITE_HEIGHT_DELTA = 1 # height 较 5 日前抬升
IGNITE_HEIGHT = 5 # p40
IGNITE_PROMO_SOFT = 0.19
# 冰点: 高度/宽度/首板同时贴地 (p10)
ICE_HEIGHT = 4 # p10-p20
ICE_GE2 = 6 # p10
ICE_FIRST_BOARD = 24 # p10
# 晋级率最小池(家数), 低于此记 null(小样本噪声)
PROMO_MIN_POOL = 10
# 平滑与持续性: EMA alpha≈1/3 (约 5 日), 阶段切换需连续 CONFIRM_DAYS 日同标签
_EMA_ALPHA = 1.0 / 3.0
_CONFIRM_DAYS = 2
# 大盘弱档否决: 正面阶段(主升/高潮/启动)不允许出现在 5 档 state 为弱势/偏弱的日子。
# 涨停梯队可能与大盘背离(如 2024-01 微盘崩期间中字头涨停生态走强), 该否决
# 保证"主升"标签在大盘层面也成立; state 列缺失时(单元测试)不启用否决。
_POSITIVE_PHASES = frozenset({PHASE_CLIMAX, PHASE_RALLY, PHASE_IGNITE})
_VETO_STATES = frozenset({"weak", "lean_weak"})
def with_prev_consecutive(df: pl.DataFrame) -> pl.DataFrame:
"""按 symbol 追加昨日连板数列 _prev_consec (供晋级率)。
df 需含 symbol/date/consecutive_limit_ups; 输入应覆盖前一交易日
(调用方保证 warmup 或直接传全量), 每个符号首行 _prev_consec 为 null。
"""
if "_prev_consec" in df.columns:
return df
return (
df.sort(["symbol", "date"])
.with_columns(
pl.col("consecutive_limit_ups").shift(1).over("symbol").alias("_prev_consec")
)
)
def ladder_daily_aggs() -> list[pl.Expr]:
"""group_by("date").agg(...) 可直接拼接的梯队聚合表达式。
要求 df 含 consecutive_limit_ups; 含 _prev_consec 时附带晋级率分子/分母。
"""
consec = pl.col("consecutive_limit_ups")
exprs = [
consec.eq(1).sum().alias("first_board"),
consec.ge(2).sum().alias("ge2_count"),
consec.ge(3).sum().alias("ge3_count"),
consec.ge(5).sum().alias("ge5_count"),
consec.filter(consec.ge(2)).n_unique().alias("rungs_filled"),
]
return exprs
def ladder_promo_aggs() -> list[pl.Expr]:
"""晋级率聚合(分子/分母); 要求 df 已含 _prev_consec 列。"""
prev = pl.col("_prev_consec")
consec = pl.col("consecutive_limit_ups")
return [
prev.ge(1).sum().alias("promo_pool"),
(prev.ge(1) & consec.eq(prev + 1)).sum().alias("promo_ok"),
]
def finalize_ladder_row(r: dict) -> dict:
"""把聚合行的梯队原始值整理为持久化字段(晋级率/ladder_completeness)。"""
height = int(r.get("max_consecutive") or 0)
rungs = int(r.get("rungs_filled") or 0)
completeness = (rungs / (height - 1)) if height >= 3 else 0.0
pool = int(r.get("promo_pool") or 0)
ok = int(r.get("promo_ok") or 0)
promo = (ok / pool) if pool >= PROMO_MIN_POOL else None
return {
"first_board": int(r.get("first_board") or 0),
"ge2_count": int(r.get("ge2_count") or 0),
"ge3_count": int(r.get("ge3_count") or 0),
"ge5_count": int(r.get("ge5_count") or 0),
"ladder_completeness": round(completeness, 4),
"promo_pool": pool,
"promo_rate": round(promo, 4) if promo is not None else None,
}
def _ema(values: list[float], alpha: float = _EMA_ALPHA) -> list[float]:
out: list[float] = []
cur = None
for v in values:
if v is None or v != v: # None 或 NaN
if cur is None:
out.append(None)
continue
out.append(cur) # ffill: 缺失沿用上一平滑值
continue
cur = v if cur is None else cur + alpha * (v - cur)
out.append(cur)
# 前向回填: 序列开头缺失用首个有效值
first_valid = next((i for i, x in enumerate(out) if x is not None), None)
if first_valid is not None:
for i in range(first_valid):
out[i] = out[first_valid]
else:
out = [0.0] * len(values)
return out
def classify_phase_series(daily: pl.DataFrame) -> pl.DataFrame:
"""对完整日序打阶段标签, 追加 phase 列。
输入列: date, max_consecutive, first_board, ge2_count, promo_rate, seal_rate
(promo_rate 允许 null)。处理: promo 前向填充 → 各驱动 EMA 平滑 →
逐日规则判定(按优先级) → 连续 _CONFIRM_DAYS 日同标签才切换(持续性)。
"""
required = {"date", "max_consecutive", "first_board", "ge2_count", "promo_rate", "seal_rate"}
missing = required - set(daily.columns)
if missing:
raise ValueError(f"classify_phase_series 缺少列: {sorted(missing)}")
rows = daily.sort("date")
n = rows.height
states = rows["state"].to_list() if "state" in rows.columns else None
height_s = _ema([float(v) if v is not None else None for v in rows["max_consecutive"].to_list()])
first_s = _ema([float(v) if v is not None else None for v in rows["first_board"].to_list()])
ge2_s = _ema([float(v) if v is not None else None for v in rows["ge2_count"].to_list()])
promo_s = _ema([float(v) if v is not None else None for v in rows["promo_rate"].to_list()])
seal_s = _ema([float(v) if v is not None else None for v in rows["seal_rate"].to_list()])
def raw_label(i: int) -> str:
h, fb, g2, pr, sr = height_s[i], first_s[i], ge2_s[i], promo_s[i], seal_s[i]
g2_prev = ge2_s[max(0, i - 5)]
h_prev = height_s[max(0, i - 5)]
# 高潮
if g2 >= CLIMAX_GE2 or fb >= CLIMAX_FIRST_BOARD:
return PHASE_CLIMAX
# 主升
if h >= RALLY_HEIGHT and g2 >= RALLY_GE2 and pr >= RALLY_PROMO:
return PHASE_RALLY
if pr >= RALLY_PROMO_ALT and g2 >= RALLY_GE2_ALT and h >= RALLY_HEIGHT_ALT:
return PHASE_RALLY
# 冰点: 高度/宽度/首板同时贴地 — 优先于退潮(持续死寂的市场是"冰点"
# 而非"自高位退潮"; 退潮的规则 B 不带 from_high 条件, 顺序反了会把
# 长期冰点误标成退潮)
if h <= ICE_HEIGHT and g2 <= ICE_GE2 and fb <= ICE_FIRST_BOARD:
return PHASE_ICE
# 退潮: 自高位回落 + 晋级率坍塌, 或晋级/封板双弱
from_high = g2_prev >= EBB_RECENT_GE2 or h_prev >= EBB_RECENT_HEIGHT
if from_high and (pr <= EBB_PROMO and g2 < g2_prev):
return PHASE_EBB
if pr <= EBB_PROMO_STRICT and sr <= EBB_SEAL:
return PHASE_EBB
# 启动: 自低位扩张
if g2 - g2_prev >= IGNITE_GE2_DELTA and g2 >= IGNITE_GE2 and pr >= IGNITE_PROMO:
return PHASE_IGNITE
if h - h_prev >= IGNITE_HEIGHT_DELTA and h >= IGNITE_HEIGHT and pr >= IGNITE_PROMO_SOFT:
return PHASE_IGNITE
return PHASE_REPAIR
labels: list[str] = []
current = None
pending: str | None = None
pending_run = 0
for i in range(n):
raw = raw_label(i)
if (
states is not None
and raw in _POSITIVE_PHASES
and states[i] in _VETO_STATES
):
raw = PHASE_REPAIR
if current is None:
current = raw
labels.append(raw)
continue
if raw == current:
labels.append(current)
pending, pending_run = None, 0
continue
if raw == pending:
pending_run += 1
else:
pending, pending_run = raw, 1
if pending_run >= _CONFIRM_DAYS:
current = raw
labels.append(current)
pending, pending_run = None, 0
else:
labels.append(current)
return daily.with_columns(
pl.Series("phase", labels, dtype=pl.Utf8).alias("phase")
).sort("date")