Files
tick-stock-panel/backend/app/strategy/intraday_features.py
T
shy3130 716897f41a feat(signals): 信号库新增盘中信号(分钟K特征)
- intraday_features: 会话对齐特征帧构造器(vwap/涨跌幅/1·3·5分钟放量比/
  日内与开盘30分钟高低点距离, 滚动窗口不跨午休, 只用已收盘bar防未来函数)
- custom_signals: timeframe=daily|intraday 双 schema, 盘中条件支持
  cross_up/cross_down 穿越算子; 输出为当日条件上升沿(首bar不触发,
  null特征判false绝不误报)
- 旧4个分时穿越信号列名零迁移(评估器回映射历史列名, 存量监控规则不动)
- 引擎单点注入 csgi_ 列: 监控/分钟策略/分钟回测共用同一构造器;
  日线策略引用盘中信号显式报错
- 回放验证 API /api/custom-signals/intraday/replay(本地历史分钟K重放,
  区间≤60天标的≤200, 先验证再配置监控)
- 能力门槛: 分钟K能力(订阅池)或全量分钟能力(本地分区); 回放仅需本地历史

验证: 新增16个测试(特征数值/边界/旧4信号黄金等价/引擎注入/回放端点),
受影响回归109个全过, ruff对齐基线, docs/features.md 同步
2026-09-06 18:26:29 +08:00

179 lines
7.6 KiB
Python

"""盘中信号特征帧 — 当日已完成分钟 K → 数值特征序列(每根已完成 bar 一行)。
单一口径源: 监控评估(quote_service) / 分钟策略执行(引擎注入) / 分钟回测 / 回放验证
共用本模块构造特征, 保证四条路径对同一段分钟数据产出完全一致的特征值。
设计:
- 会话对齐: 滚动窗口只在本时段(09:30-11:30 / 13:00-15:00)内回看,
不跨午休、不跨日; 日累计类特征(vwap/当日高低)按交易日分组。
- null 语义: 窗口不足、基准为零、缺昨收、开盘未满 30 分钟 → 特征为 null,
任何条件对 null 判 false, 绝不把数据不足伪装成 0。
- 纯函数: 不做 IO, 分钟帧由调用方传入(cutoff 过滤也由调用方决定)。
"""
from __future__ import annotations
from datetime import datetime
import polars as pl
from app.market_time import CN_TZ
# ── 特征白名单(供 custom_signals 校验与 /options 展示) ──────────
# 字段 → 中文标签。数值均为「每根已完成 bar 一个值」的序列。
INTRADAY_FEATURES: dict[str, str] = {
"price": "现价",
"vwap": "分时均价",
"pct_vs_prev_close": "相对昨收涨跌幅",
"pct_from_open": "相对开盘涨跌幅",
"vol_ratio_1m_today": "1分钟放量比(今日基准)",
"vol_ratio_3m_today": "3分钟放量比(今日基准)",
"vol_ratio_5m_today": "5分钟放量比(今日基准)",
"day_high_dist": "距当日最高价",
"day_low_dist": "距当日最低价",
"open_30m_high_dist": "距开盘30分钟最高价",
"open_30m_low_dist": "距开盘30分钟最低价",
}
# 滚动窗口特征的窗口长度(字段名后缀 → bar 数)
_VOL_WINDOWS = {1: "vol_ratio_1m_today", 3: "vol_ratio_3m_today", 5: "vol_ratio_5m_today"}
_REQUIRED_COLS = ("symbol", "datetime", "close", "volume", "amount")
_DAY_KEY = ["symbol", "date"]
_SESSION_KEY = ["symbol", "date", "session"]
def _naive(dt: datetime) -> datetime | None:
"""统一为北京墙钟 naive(与分钟存储契约一致)。"""
if not isinstance(dt, datetime):
return None
if dt.tzinfo is not None:
return dt.astimezone(CN_TZ).replace(tzinfo=None)
return dt
def build_feature_frame(
minute_df: pl.DataFrame,
*,
prev_close: dict[str, float] | None = None,
cutoff: datetime | None = None,
) -> pl.DataFrame:
"""把分钟 K 帧编译为特征帧。
参数:
minute_df: 列含 symbol/datetime/open/high/low/close/volume/amount(后四列必需,
OHLC 缺失时相关特征降级为 null)。可包含多个交易日, 特征按日分组。
prev_close: 映射 symbol → 昨收(已复权口径需与分钟价一致); 缺失标的的
pct_vs_prev_close 为 null。
cutoff: 只使用严格早于 cutoff 的 bar(盘中传入当前分钟; 回放/回测传 None)。
返回: symbol/datetime + INTRADAY_FEATURES 全部特征列(Float64, 可 null)。
"""
empty = pl.DataFrame(
schema={"symbol": pl.Utf8, "datetime": pl.Datetime, "date": pl.Date}
| {name: pl.Float64 for name in INTRADAY_FEATURES}
)
if minute_df is None or minute_df.is_empty() or not set(_REQUIRED_COLS).issubset(minute_df.columns):
return empty
df = minute_df
if "symbol" in df.columns:
df = df.with_columns(pl.col("symbol").cast(pl.Utf8))
dt_expr = pl.col("datetime")
if df.schema["datetime"].time_zone is not None:
dt_expr = dt_expr.dt.convert_time_zone(CN_TZ.key).dt.replace_time_zone(None)
df = df.with_columns(dt_expr.alias("datetime"))
if cutoff is not None:
cut = _naive(cutoff)
if cut is not None:
df = df.filter(pl.col("datetime") < cut)
df = df.drop_nulls("datetime").sort(["symbol", "datetime"])
if df.is_empty():
return empty
df = df.with_columns(
pl.col("datetime").dt.date().alias("date"),
# 会话归属: 13:00 及以后为午后续时段, 滚动窗口不与上午合并
pl.when(pl.col("datetime").dt.hour() >= 13).then(1).otherwise(0).alias("session"),
)
df = df.with_columns(pl.int_range(pl.len()).over(_SESSION_KEY).alias("session_idx"))
cols = {"price": pl.col("close").cast(pl.Float64)}
# ── 日累计特征(跨上午/下午累计) ──
if {"volume", "amount"}.issubset(df.columns):
cum_vol = pl.col("volume").cast(pl.Float64).cum_sum().over(_DAY_KEY)
cum_amt = pl.col("amount").cast(pl.Float64).cum_sum().over(_DAY_KEY)
cols["vwap"] = pl.when(cum_vol > 0).then(cum_amt / (cum_vol * 100.0))
else:
cols["vwap"] = pl.lit(None, dtype=pl.Float64)
if prev_close:
pc = pl.DataFrame(
{"symbol": list(prev_close.keys()), "_prev_close": [float(v) for v in prev_close.values()]}
)
df = df.join(pc, on="symbol", how="left")
cols["pct_vs_prev_close"] = pl.when(
pl.col("_prev_close").is_not_null() & (pl.col("_prev_close") > 0)
).then(pl.col("close") / pl.col("_prev_close") - 1.0)
else:
cols["pct_vs_prev_close"] = pl.lit(None, dtype=pl.Float64)
if "open" in df.columns:
day_open = pl.col("open").cast(pl.Float64).first().over(_DAY_KEY)
cols["pct_from_open"] = pl.when(day_open > 0).then(pl.col("close") / day_open - 1.0)
else:
cols["pct_from_open"] = pl.lit(None, dtype=pl.Float64)
if "high" in df.columns:
day_high = pl.col("high").cast(pl.Float64).cum_max().over(_DAY_KEY)
cols["day_high_dist"] = pl.when(day_high > 0).then(pl.col("close") / day_high - 1.0)
else:
cols["day_high_dist"] = pl.lit(None, dtype=pl.Float64)
if "low" in df.columns:
day_low = pl.col("low").cast(pl.Float64).cum_min().over(_DAY_KEY)
cols["day_low_dist"] = pl.when(day_low > 0).then(pl.col("close") / day_low - 1.0)
else:
cols["day_low_dist"] = pl.lit(None, dtype=pl.Float64)
# ── 滚动放量比(今日基准): 当前 N 根 bar 量和 / 此前 N 根 bar 量和 ──
# 滚动窗口按(symbol, date, session)分组 → 不跨午休、不跨日; 窗口不满自然为 null。
if "volume" in df.columns:
vol = pl.col("volume").cast(pl.Float64)
for n, name in _VOL_WINDOWS.items():
win = vol.rolling_sum(n).over(_SESSION_KEY)
prev_win = win.shift(n).over(_SESSION_KEY)
cols[name] = pl.when(prev_win > 0).then(win / prev_win)
else:
for name in _VOL_WINDOWS.values():
cols[name] = pl.lit(None, dtype=pl.Float64)
# ── 开盘 30 分钟高低点: 上午时段第 30 根 bar 的累计高/低, 全日广播 ──
if "high" in df.columns:
marker_h = (
pl.when((pl.col("session") == 0) & (pl.col("session_idx") == 29))
.then(pl.col("high").cast(pl.Float64).cum_max().over(_DAY_KEY))
.otherwise(None)
.forward_fill()
.over(_DAY_KEY)
)
cols["open_30m_high_dist"] = pl.when(marker_h > 0).then(pl.col("close") / marker_h - 1.0)
else:
cols["open_30m_high_dist"] = pl.lit(None, dtype=pl.Float64)
if "low" in df.columns:
marker_l = (
pl.when((pl.col("session") == 0) & (pl.col("session_idx") == 29))
.then(pl.col("low").cast(pl.Float64).cum_min().over(_DAY_KEY))
.otherwise(None)
.forward_fill()
.over(_DAY_KEY)
)
cols["open_30m_low_dist"] = pl.when(marker_l > 0).then(pl.col("close") / marker_l - 1.0)
else:
cols["open_30m_low_dist"] = pl.lit(None, dtype=pl.Float64)
return df.with_columns([expr.cast(pl.Float64).alias(name) for name, expr in cols.items()]).select(
["symbol", "date", "datetime", *INTRADAY_FEATURES.keys()]
)