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 同步
This commit is contained in:
shy3130
2026-09-06 18:26:29 +08:00
parent 91d2268532
commit 716897f41a
8 changed files with 1044 additions and 76 deletions
+129 -4
View File
@@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from app.strategy import custom_signals
from app.strategy.intraday_features import INTRADAY_FEATURES
router = APIRouter(prefix="/api/custom-signals", tags=["custom-signals"])
@@ -24,7 +25,9 @@ def _invalidate(request: Request) -> None:
信号增删会改变注入列集合: 只清表达式缓存不够, repo 内存缓存 /
strategy 磁盘缓存里算好的历史窗口仍不含新 csg_ 列 (或仍含已删列),
需要一并清除, 否则创建信号后立即运行策略仍会报缺列。
盘中信号定义缓存(intraday)一并失效, 下一分钟 bucket 即生效。
"""
custom_signals.invalidate_intraday_cache()
from app.indicators.pipeline import invalidate_custom_signals
invalidate_custom_signals()
from app.services import strategy_cache
@@ -35,11 +38,11 @@ def _invalidate(request: Request) -> None:
class ConditionModel(BaseModel):
left: str # 字段名(须在白名单)
op: str # > >= < <= == !=
left: str # 字段名(日线在白名单 / 盘中在特征白名单)
op: str # > >= < <= == != ; 盘中额外: cross_up cross_down
right: str # "field:xxx" 或数字字符串
leftDays: int = 0 # 左字段取几日前 (0=当日, 默认)
rightDays: int = 0 # 右字段取几日前 (仅 right 为字段时有意义)
leftDays: int = 0 # 左字段取几日前 (0=当日, 默认; 盘中信号必须为 0)
rightDays: int = 0 # 右字段取几日前 (仅 right 为字段时有意义; 盘中信号必须为 0)
class SignalModel(BaseModel):
@@ -48,6 +51,17 @@ class SignalModel(BaseModel):
kind: str # entry | exit | both
conditions: list[ConditionModel]
enabled: bool = True
timeframe: str = "daily" # daily | intraday(分钟K特征, 输出当日条件上升沿)
min_bars: int = 0 # 仅 intraday: 当日最少已完成 bar 数, 不足不触发
class IntradayReplayRequest(BaseModel):
"""盘中信号历史回放 — 用本地分钟K重放触发时点, 不消耗盘中数据能力。"""
signal_id: str
start_date: str # YYYY-MM-DD
end_date: str # YYYY-MM-DD
symbols: list[str]
asset_type: str = "stock"
class AIGenerateRequest(BaseModel):
@@ -115,6 +129,18 @@ def get_options():
{"key": "exit", "label": "出场"},
{"key": "both", "label": "出入通用"},
],
# 盘中信号(timeframe=intraday): 分钟K特征白名单 + 额外穿越算子
"intraday": {
"fields": [
{"key": f, "label": label}
for f, label in sorted(INTRADAY_FEATURES.items())
],
"operators": [">", ">=", "<", "<=", "==", "!=", "cross_up", "cross_down"],
},
"timeframes": [
{"key": "daily", "label": "日线"},
{"key": "intraday", "label": "盘中(分钟K)"},
],
}
@@ -189,3 +215,102 @@ def delete_signal(signal_id: str, request: Request):
raise HTTPException(status_code=404, detail="信号不存在")
_invalidate(request)
return {"ok": True}
# ── 盘中信号历史回放 ────────────────────────────────────
@router.post("/intraday/replay")
def intraday_replay(req: IntradayReplayRequest, request: Request):
"""用本地历史分钟K回放盘中信号的触发时点。
只读本地分钟分区, 不消耗盘中数据能力 — 用户可先在历史区间验证信号,
再决定是否配置到监控/分钟策略。昨收取自本地日K(无昨日数据的日子该特征降级)。
"""
from datetime import date, timedelta
import polars as pl
from app.strategy.intraday_features import build_feature_frame
try:
start = date.fromisoformat(req.start_date)
end = date.fromisoformat(req.end_date)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"日期格式错误: {e}") from e
if start > end:
raise HTTPException(status_code=400, detail="start_date 不能晚于 end_date")
if (end - start).days > 60:
raise HTTPException(status_code=400, detail="回放区间最长 60 天")
symbols = [s for s in dict.fromkeys(req.symbols) if s]
if not symbols:
raise HTTPException(status_code=400, detail="symbols 不能为空")
if len(symbols) > 200:
raise HTTPException(status_code=400, detail="单次回放最多 200 只标的")
# 信号定义必须存在且为盘中类型
sig = next(
(s for s in custom_signals.load_all(_data_dir(request)) if s.get("id") == req.signal_id),
None,
)
if sig is None:
raise HTTPException(status_code=404, detail="信号不存在")
if sig.get("timeframe") != custom_signals.TIMEFRAME_INTRADAY:
raise HTTPException(status_code=400, detail="该信号不是盘中(timeframe=intraday)信号")
exprs = custom_signals.build_intraday_expressions([sig])
col = custom_signals.intraday_column_name(sig["id"])
if col not in exprs:
raise HTTPException(status_code=400, detail="信号编译失败, 请检查条件字段")
min_bars = int(sig.get("min_bars", 0) or 0)
repo = request.app.state.repo
# 昨收映射: 一次性取区间(含前置 15 天)日K, 按「严格早于当日」取最近收盘
daily = repo.get_daily_batch(symbols, start - timedelta(days=15), end, columns=["symbol", "date", "close"])
close_by_sym_date: dict[str, dict[date, float]] = {}
if not daily.is_empty():
for row in daily.sort(["symbol", "date"]).iter_rows(named=True):
close_by_sym_date.setdefault(str(row["symbol"]), {})[row["date"]] = float(row["close"])
triggers: list[dict] = []
days_scanned = 0
bars_scanned = 0
day = start
while day <= end:
minute_df = repo.get_minute_batch(symbols, day, asset_type=req.asset_type)
if minute_df is not None and not minute_df.is_empty():
days_scanned += 1
bars_scanned += minute_df.height
prev_close = {
sym: closes_map[max(d for d in closes_map if d < day)]
for sym, closes_map in close_by_sym_date.items()
if any(d < day for d in closes_map)
}
frame = build_feature_frame(minute_df, prev_close=prev_close)
if not frame.is_empty():
evaluated = custom_signals.apply_intraday_edges(frame, {col: exprs[col]}).with_columns(
pl.int_range(pl.len()).over(["symbol", "date"]).alias("_bar_idx")
)
if min_bars > 0:
evaluated = evaluated.with_columns(
pl.when(pl.col("_bar_idx") + 1 >= min_bars)
.then(pl.col(col))
.otherwise(False)
.alias(col)
)
for row in evaluated.filter(pl.col(col)).sort(["datetime", "symbol"]).iter_rows(named=True):
triggers.append({
"date": day.isoformat(),
"time": str(row["datetime"].time()),
"symbol": row["symbol"],
})
day += timedelta(days=1)
return {
"signal_id": req.signal_id,
"start_date": req.start_date,
"end_date": req.end_date,
"symbols": symbols,
"days_scanned": days_scanned,
"bars_scanned": bars_scanned,
"triggers": triggers,
}
+11
View File
@@ -1440,9 +1440,20 @@ class QuoteService:
prev_close=prev_close,
asset_type=asset_type,
now=now,
signals=self._load_intraday_signal_defs(),
)
return self._intraday_signal_evaluator.inject(enriched, signals)
def _load_intraday_signal_defs(self) -> list[dict]:
"""加载自定义盘中信号定义(带指纹缓存); 失败时退化为仅内置 4 信号。"""
try:
from app.strategy import custom_signals
return custom_signals.load_intraday_all(self._repo.store.data_dir)
except Exception as e:
logger.warning("load intraday signal defs failed: %s", e)
return []
@staticmethod
def _continuous_session_start_ms() -> float:
"""当前连续竞价时段的起点 (北京时间 9:30 或 13:00) 的 epoch 毫秒。"""
+172
View File
@@ -202,11 +202,17 @@ def validate(sig: dict) -> None:
raise ValueError("信号 name 不能为空")
if sig.get("kind") not in ("entry", "exit", "both"):
raise ValueError("kind 必须是 entry / exit / both")
timeframe = sig.get("timeframe", TIMEFRAME_DAILY)
if timeframe not in (TIMEFRAME_DAILY, TIMEFRAME_INTRADAY):
raise ValueError(f"timeframe 必须是 {TIMEFRAME_DAILY} / {TIMEFRAME_INTRADAY}: {timeframe!r}")
conds = sig.get("conditions")
if not isinstance(conds, list) or len(conds) == 0:
raise ValueError("conditions 不能为空")
if len(conds) > 8:
raise ValueError("conditions 最多 8 条")
if timeframe == TIMEFRAME_INTRADAY:
_validate_intraday(sig)
return
for i, c in enumerate(conds):
if not isinstance(c, dict):
raise ValueError(f"{i+1} 个条件格式错误")
@@ -317,3 +323,169 @@ def _expr_root_columns(expr: pl.Expr) -> set[str]:
return set(names)
except Exception:
return set()
# ══ 盘中信号(timeframe="intraday")═════════════════════════
# 与日线自定义信号同一套 left/op/right 条件结构, 但:
# - 字段白名单换成分钟特征(intraday_features.INTRADAY_FEATURES);
# - 运算符额外支持 cross_up / cross_down(序列上穿/下穿 另一序列或阈值);
# - 不支持 leftDays/rightDays 日期偏移;
# - 信号列名前缀 csgi_, 注入对象是分钟特征帧而非日线 enriched。
# 语义: 信号输出 = 当日条件组合的上升沿(false→true), 首根 bar 不触发。
from app.strategy.intraday_features import INTRADAY_FEATURES # noqa: E402
TIMEFRAME_DAILY = "daily"
TIMEFRAME_INTRADAY = "intraday"
INTRADAY_PREFIX = "csgi_"
INTRADAY_OPS = OPS | {"cross_up", "cross_down"}
_EDGE_GROUP = ["symbol", "date"]
def intraday_column_name(signal_id: str) -> str:
"""盘中信号 id → 分钟帧列名(加 csgi_ 前缀)。"""
return f"{INTRADAY_PREFIX}{signal_id}"
def _parse_right_intraday(right: object) -> tuple[str, object]:
"""盘中条件的右值: ('const', float) 或 ('field', 特征名)。"""
if isinstance(right, (int, float)):
return ("const", float(right))
if not isinstance(right, str):
raise ValueError(f"非法右值: {right!r}")
if right.startswith("field:"):
col = right[len("field:"):]
if col not in INTRADAY_FEATURES:
raise ValueError(f"盘中右值字段不在白名单: {col}")
return ("field", col)
try:
return ("const", float(right))
except ValueError:
pass
if right in INTRADAY_FEATURES:
return ("field", right)
raise ValueError(f"非法盘中右值(应为 field:特征 或数字): {right!r}")
def _validate_intraday(sig: dict) -> None:
"""校验盘中信号定义, 非法抛 ValueError。"""
conds = sig.get("conditions")
for i, c in enumerate(conds):
if not isinstance(c, dict):
raise ValueError(f"{i+1} 个条件格式错误")
left = c.get("left", "")
if left not in INTRADAY_FEATURES:
raise ValueError(f"{i+1} 个条件: 盘中字段 {left!r} 不在白名单")
if c.get("op") not in INTRADAY_OPS:
raise ValueError(f"{i+1} 个条件: 运算符 {c.get('op')!r} 非法(盘中额外支持 cross_up/cross_down)")
_parse_right_intraday(c.get("right"))
if int(c.get("leftDays", 0) or 0) or int(c.get("rightDays", 0) or 0):
raise ValueError(f"{i+1} 个条件: 盘中信号不支持日期偏移(leftDays/rightDays)")
min_bars = sig.get("min_bars", 0)
try:
n = int(min_bars)
except (TypeError, ValueError):
raise ValueError(f"min_bars 必须是整数: {min_bars!r}") # noqa: B904
if n < 0 or n > 240:
raise ValueError(f"min_bars 必须在 0..240 之间: {n}")
def build_intraday_expressions(signals: list[dict]) -> dict[str, pl.Expr]:
"""把盘中信号编译为特征帧上的「条件」表达式(AND 组合, 未做上升沿)。
表达式在 intraday_features.build_feature_frame 产出的帧上求值;
上升沿须通过 apply_intraday_edges 在 DataFrame 层两步计算 —
对已含 .over() 窗口的组合表达式直接 shift().over() 是窗口嵌套,
Polars 会返回全 null。编译失败的信号跳过并告警。
"""
out: dict[str, pl.Expr] = {}
for sig in signals:
if sig.get("enabled") is False or sig.get("timeframe") != TIMEFRAME_INTRADAY:
continue
try:
parts: list[pl.Expr] = []
for c in sig["conditions"]:
left = pl.col(c["left"])
kind, val = _parse_right_intraday(c["right"])
op = c["op"]
if op == "cross_up":
# 前一根 bar 未满足 且 当前 bar 满足; 右值为常量时不 shift 字面量
if kind == "field":
prev_ok = left.shift(1).over(_EDGE_GROUP) <= pl.col(val).shift(1).over(_EDGE_GROUP)
cur_ok = left > pl.col(val)
else:
prev_ok = left.shift(1).over(_EDGE_GROUP) <= val
cur_ok = left > val
parts.append(prev_ok & cur_ok)
elif op == "cross_down":
if kind == "field":
prev_ok = left.shift(1).over(_EDGE_GROUP) >= pl.col(val).shift(1).over(_EDGE_GROUP)
cur_ok = left < pl.col(val)
else:
prev_ok = left.shift(1).over(_EDGE_GROUP) >= val
cur_ok = left < val
parts.append(prev_ok & cur_ok)
else:
right = pl.col(val) if kind == "field" else val
parts.append(_OP_BUILDERS[op](left, right))
combined = parts[0]
for p in parts[1:]:
combined = combined & p
out[intraday_column_name(sig["id"])] = combined
except Exception as e:
logger.warning("intraday signal compile failed %s: %s", sig.get("id"), e)
return out
def apply_intraday_edges(frame: pl.DataFrame, exprs: dict[str, pl.Expr]) -> pl.DataFrame:
"""对特征帧求值盘中信号: 先算条件列, 再取「当日条件上升沿」为布尔列。
上升沿: 条件 false→true 的那根 bar 为 true; 首根 bar(前值为 null)不触发;
条件含 null(特征不足)视为 false。四条消费路径(监控/实盘/回测/回放)
必须共用本函数, 保证口径一致。
"""
if frame.is_empty() or not exprs:
return frame
df = frame.with_columns([e.fill_null(False).alias(n) for n, e in exprs.items()])
return df.with_columns([
(
pl.col(n)
& ~pl.col(n).shift(1).over(_EDGE_GROUP).fill_null(True)
).cast(pl.Boolean).alias(n)
for n in exprs
])
# ── 盘中信号定义加载(带指纹缓存: 引擎/监控高频路径用) ──────────
_intraday_cache: dict[Path, tuple[object, list[dict]]] = {}
def _dir_fingerprint(d: Path) -> tuple:
"""目录内 *.json 的 (文件名, mtime) 指纹 — 创建/删除/编辑都会变化。"""
try:
return tuple(sorted((f.name, f.stat().st_mtime_ns) for f in d.glob("*.json")))
except OSError:
return ()
def load_intraday_all(data_dir: Path) -> list[dict]:
"""读取全部启用的盘中信号定义(带缓存)。
盘中评估与引擎注入每分钟执行, 不宜每次全量读盘; save/delete 端点
调用 invalidate_intraday_cache() 主动失效。
"""
d = _dir(data_dir)
fp = _dir_fingerprint(d)
cached = _intraday_cache.get(data_dir)
if cached is not None and cached[0] == fp:
return cached[1]
sigs = [
s for s in load_all(data_dir)
if s.get("timeframe") == TIMEFRAME_INTRADAY and s.get("enabled") is not False
]
_intraday_cache[data_dir] = (fp, sigs)
return sigs
def invalidate_intraday_cache() -> None:
_intraday_cache.clear()
+64
View File
@@ -951,6 +951,19 @@ class StrategyEngine:
strategy_id=strategy_id,
exit_signal_hits=exit_signal_hits,
)
# 盘中信号列注入(csgi_): 实盘扫描与分钟回测共用本路径 — 与监控评估
# 同一特征构造器, 单点注入保证三处口径一致。
history = self._inject_intraday_signal_columns(history)
missing_csgi = [
name for name in s.required_features
if name.startswith("csgi_") and name not in history.columns
]
if missing_csgi:
raise ValueError(
"策略引用了未定义的盘中信号: "
+ ", ".join(sorted(missing_csgi))
+ " — 请先在「自定义信号」中创建(timeframe=intraday)后再运行"
)
if s.minute_daily_bars > 0:
df = s.filter_minute_history_fn(history, params, daily=context.daily_history)
else:
@@ -982,6 +995,15 @@ class StrategyEngine:
+ ", ".join(sorted(missing_csg))
+ " — 请先在「自定义信号」管理中创建对应信号后再运行"
)
missing_csgi = [
name for name in s.required_features
if name.startswith("csgi_")
]
if missing_csgi:
raise ValueError(
"盘中信号仅可用于分钟策略(timeframes=['1m']), 日线策略不支持: "
+ ", ".join(sorted(missing_csgi))
)
df = s.filter_history_fn(df, params)
if "date" in df.columns:
df = df.filter(pl.col("date") == as_of)
@@ -1555,6 +1577,48 @@ class StrategyEngine:
"turnover_rate", "change_pct", "pre_close",
)
def _user_data_dir(self) -> Path | None:
"""从策略目录推导 data_dir(…/strategies/custom → data_dir)。推不出则跳过注入。"""
for d in self._strategy_dirs:
if d.name == "custom" and d.parent.name == "strategies":
return d.parent.parent
return None
def _inject_intraday_signal_columns(self, minute_df: pl.DataFrame) -> pl.DataFrame:
"""向当日分钟K帧注入自定义盘中信号列(csgi_, 当日条件上升沿)。
单点注入: 实盘分钟扫描与分钟回测 worker 共用本方法, 特征计算与
监控评估同源(intraday_features), 保证口径一致。无定义/帧为空时原样返回。
"""
if minute_df is None or minute_df.is_empty() or "datetime" not in minute_df.columns:
return minute_df
data_dir = self._user_data_dir()
if data_dir is None:
return minute_df
try:
from app.strategy import custom_signals
from app.strategy.intraday_features import build_feature_frame
definitions = custom_signals.load_intraday_all(data_dir)
if not definitions:
return minute_df
exprs = custom_signals.build_intraday_expressions(definitions)
if not exprs:
return minute_df
frame = build_feature_frame(minute_df)
if frame.is_empty():
return minute_df
evaluated = custom_signals.apply_intraday_edges(frame, exprs).select(
["symbol", "datetime", *exprs.keys()]
)
return minute_df.join(evaluated, on=["symbol", "datetime"], how="left").with_columns(
[pl.col(name).fill_null(False).cast(pl.Boolean).alias(name) for name in exprs]
)
except Exception as e:
# 注入失败不阻断策略执行: 未注入列会由 required_features 校验兜底报错
logger.warning("intraday signal inject failed: %s", e)
return minute_df
@staticmethod
def _join_basic_columns(df: pl.DataFrame, current: pl.DataFrame) -> pl.DataFrame:
"""把 enriched 快照列按 symbol 联到分钟策略输出上, 只补 df 缺失的列。"""
+178
View File
@@ -0,0 +1,178 @@
"""盘中信号特征帧 — 当日已完成分钟 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()]
)
+107 -70
View File
@@ -1,13 +1,25 @@
"""监控中心专用的日内分时穿越信号。"""
"""监控中心专用的日内分时信号评估器。
v2: 特征计算与条件求值统一走 intraday_features 特征帧 + custom_signals 的
盘中表达式编译 — 与分钟策略执行/分钟回测/回放验证同一条口径。
- 内置 4 个分时穿越信号(signal_intraday_*)由同一表达式机制生成, 列名不变,
存量监控规则零迁移;
- 自定义盘中信号(timeframe="intraday", csgi_ 前缀)与内置信号一并评估注入。
"""
from __future__ import annotations
import math
import logging
from datetime import datetime
from typing import Any
import polars as pl
from app.market_time import CN_TZ
from app.strategy import custom_signals
from app.strategy.intraday_features import build_feature_frame
logger = logging.getLogger(__name__)
INTRADAY_SIGNAL_LABELS: dict[str, str] = {
"signal_intraday_avg_cross_up": "分时价格上穿均价",
@@ -16,22 +28,41 @@ INTRADAY_SIGNAL_LABELS: dict[str, str] = {
"signal_intraday_zero_cross_down": "分时价格下穿0轴",
}
INTRADAY_SIGNAL_FIELDS = frozenset(INTRADAY_SIGNAL_LABELS)
_LEGACY_MIN_BARS = 2 # 旧实现要求至少两根已完成 bar 才判穿越, 语义保持
def uses_intraday_signals(rule: dict) -> bool:
"""规则是否引用盘中信号列(内置 4 个或自定义 csgi_)。"""
return any(
c.get("op") == "truth" and c.get("field") in INTRADAY_SIGNAL_FIELDS
(
isinstance(c, dict)
and c.get("op") == "truth"
and (c.get("field") in INTRADAY_SIGNAL_FIELDS or str(c.get("field", "")).startswith(custom_signals.INTRADAY_PREFIX))
)
for c in rule.get("conditions", [])
if isinstance(c, dict)
)
def _finite(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def _legacy_builtin_definitions() -> list[dict]:
"""内置 4 个分时穿越信号的等价定义(与 v1 逐字节同口径)。
v1 语义: 上穿 = 前一根 bar 未满足且当前 bar 满足 —— 与
build_intraday_expressions 的「条件上升沿」完全一致。
"""
return [
{"id": "signal_intraday_avg_cross_up", "timeframe": "intraday", "enabled": True,
"conditions": [{"left": "price", "op": "cross_up", "right": "field:vwap"}],
"min_bars": _LEGACY_MIN_BARS},
{"id": "signal_intraday_avg_cross_down", "timeframe": "intraday", "enabled": True,
"conditions": [{"left": "price", "op": "cross_down", "right": "field:vwap"}],
"min_bars": _LEGACY_MIN_BARS},
{"id": "signal_intraday_zero_cross_up", "timeframe": "intraday", "enabled": True,
"conditions": [{"left": "pct_vs_prev_close", "op": "cross_up", "right": 0}],
"min_bars": _LEGACY_MIN_BARS},
{"id": "signal_intraday_zero_cross_down", "timeframe": "intraday", "enabled": True,
"conditions": [{"left": "pct_vs_prev_close", "op": "cross_down", "right": 0}],
"min_bars": _LEGACY_MIN_BARS},
]
def _naive_datetime(value: Any) -> datetime | None:
@@ -43,7 +74,7 @@ def _naive_datetime(value: Any) -> datetime | None:
class IntradaySignalEvaluator:
"""按已完成的一分钟 K 线生成边沿触发信号"""
"""按已完成的一分钟 K 线评估盘中信号(边沿触发, 新 bar 出现才可能触发)"""
def __init__(self) -> None:
self._last_bar: dict[tuple[str, str], datetime] = {}
@@ -56,86 +87,92 @@ class IntradaySignalEvaluator:
prev_close: dict[str, float],
asset_type: str,
now: datetime,
signals: list[dict] | None = None,
) -> list[dict[str, Any]]:
"""返回本分钟触发信号的行列表(每 symbol 一行, 仅新出现的 bar 触发)。"""
active_keys = {(asset_type, symbol) for symbol in symbols}
self._last_bar = {
key: value for key, value in self._last_bar.items()
if key[0] != asset_type or key in active_keys
}
required = {"symbol", "datetime", "close", "volume", "amount"}
if not symbols or minute_df.is_empty() or not required.issubset(minute_df.columns):
definitions = _legacy_builtin_definitions() + list(signals or [])
if not symbols:
return []
frame = build_feature_frame(
minute_df.filter(pl.col("symbol").cast(pl.Utf8).is_in(sorted(symbols))),
prev_close=prev_close,
cutoff=now,
)
if frame.is_empty():
return []
exprs = custom_signals.build_intraday_expressions(definitions)
if not exprs:
return []
# 内置 4 信号保留历史列名(不带 csgi_ 前缀) — 存量监控规则零迁移
for legacy_id in INTRADAY_SIGNAL_FIELDS:
prefixed = custom_signals.intraday_column_name(legacy_id)
if prefixed in exprs:
exprs[legacy_id] = exprs.pop(prefixed)
min_bars_by_col = {
custom_signals.intraday_column_name(d["id"]): int(d.get("min_bars", 0) or 0)
for d in definitions
}
min_bars_by_col.update({
name: _LEGACY_MIN_BARS for name in INTRADAY_SIGNAL_FIELDS
})
evaluated = custom_signals.apply_intraday_edges(frame, exprs)
# min_bars 门槛: 当日已完成 bar 数不足时强制不触发
evaluated = evaluated.with_columns(
pl.int_range(pl.len()).over(["symbol", "date"]).alias("_bar_idx")
)
for name, min_bars in min_bars_by_col.items():
if name in evaluated.columns and min_bars > 0:
evaluated = evaluated.with_columns(
pl.when(pl.col("_bar_idx") + 1 >= min_bars)
.then(pl.col(name))
.otherwise(False)
.alias(name)
)
cutoff = _naive_datetime(now)
if cutoff is None:
return []
cutoff = cutoff.replace(second=0, microsecond=0)
scoped = minute_df.filter(pl.col("symbol").cast(pl.Utf8).is_in(sorted(symbols)))
if scoped.is_empty():
return []
results: list[dict[str, Any]] = []
for part in scoped.partition_by("symbol", maintain_order=False):
signal_cols = [name for name in exprs if name in evaluated.columns]
for part in evaluated.partition_by("symbol", maintain_order=False):
part = part.sort("datetime")
symbol = str(part["symbol"][0])
points: list[tuple[datetime, float, float | None]] = []
cumulative_amount = 0.0
cumulative_volume = 0.0
for row in part.iter_rows(named=True):
bar_time = _naive_datetime(row.get("datetime"))
price = _finite(row.get("close"))
volume = _finite(row.get("volume"))
amount = _finite(row.get("amount"))
if bar_time is None or bar_time.date() != cutoff.date() or bar_time >= cutoff or price is None:
last_time = part["datetime"][-1]
if cutoff is not None and last_time.date() != cutoff.date():
continue
if volume is not None and volume > 0 and amount is not None and amount >= 0:
cumulative_volume += volume
cumulative_amount += amount
average = (
cumulative_amount / (cumulative_volume * 100.0)
if cumulative_volume > 0 and cumulative_amount > 0
else None
)
points.append((bar_time, price, average))
if not points:
continue
current = points[-1]
key = (asset_type, symbol)
last_bar = self._last_bar.get(key)
self._last_bar[key] = current[0]
if last_bar is None or last_bar.date() != current[0].date() or current[0] <= last_bar:
last_seen = self._last_bar.get(key)
self._last_bar[key] = last_time
# 只有出现新 bar 才可能触发; 首次见到该标的只建状态不发信号
if last_seen is None or last_time <= last_seen or last_time.date() != last_seen.date():
continue
if len(points) < 2:
continue
previous = points[-2]
baseline = _finite(prev_close.get(symbol))
avg_up = previous[2] is not None and current[2] is not None and previous[1] <= previous[2] and current[1] > current[2]
avg_down = previous[2] is not None and current[2] is not None and previous[1] >= previous[2] and current[1] < current[2]
zero_up = baseline is not None and baseline > 0 and previous[1] <= baseline and current[1] > baseline
zero_down = baseline is not None and baseline > 0 and previous[1] >= baseline and current[1] < baseline
if avg_up or avg_down or zero_up or zero_down:
results.append({
"symbol": symbol,
"signal_intraday_avg_cross_up": avg_up,
"signal_intraday_avg_cross_down": avg_down,
"signal_intraday_zero_cross_up": zero_up,
"signal_intraday_zero_cross_down": zero_down,
})
row = {name: bool(part[name][-1]) for name in signal_cols}
if any(row.values()):
row["symbol"] = symbol
results.append(row)
return results
@staticmethod
def inject(df: pl.DataFrame, signals: list[dict[str, Any]]) -> pl.DataFrame:
existing = [field for field in INTRADAY_SIGNAL_FIELDS if field in df.columns]
"""把本分钟触发的信号以布尔列注入 enriched 快照(缺省 False)。"""
fields = sorted(INTRADAY_SIGNAL_FIELDS | {f for s in signals for f in s if f != "symbol"})
existing = [field for field in fields if field in df.columns]
out = df.drop(existing) if existing else df
if signals:
out = out.join(pl.DataFrame(signals), on="symbol", how="left")
else:
cols = sorted({f for s in signals for f in s if f != "symbol"})
out = out.join(pl.DataFrame(signals).select(["symbol", *cols]), on="symbol", how="left")
out = out.with_columns([
pl.lit(False).alias(field) for field in INTRADAY_SIGNAL_FIELDS
])
return out.with_columns([
(
pl.col(field).fill_null(False).cast(pl.Boolean).alias(field)
for field in INTRADAY_SIGNAL_FIELDS
if field in out.columns
else pl.lit(False, dtype=pl.Boolean).alias(field)
)
for field in fields
])
return out
+371
View File
@@ -0,0 +1,371 @@
"""盘中信号(分钟K)测试 — 特征帧 / 编译 / 旧 4 信号等价 / 引擎注入 / 回放。
口径单源的核心保证: 测试内复刻 v1 盘中评估器的累计循环作为黄金参照,
新评估器(v2, 特征帧 + 表达式)必须与它对任意序列产出完全一致的触发集合。
"""
from __future__ import annotations
import json
from datetime import date, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
import polars as pl
import pytest
from app.strategy import custom_signals
from app.strategy.intraday_features import build_feature_frame
from app.strategy.intraday_signals import IntradaySignalEvaluator, uses_intraday_signals
SYMBOL = "000001.SZ"
DAY = date(2026, 9, 4)
def _bars(
prices: list[float],
volumes: list[float] | None = None,
start: datetime = datetime(2026, 9, 4, 9, 30),
symbol: str = SYMBOL,
) -> pl.DataFrame:
"""构造规范的上午分钟序列: volume 单位为手, amount = close*volume*100(元)。"""
volumes = volumes or [100.0] * len(prices)
# 跳过午休: 09:30+120 根后进入 13:00
times = []
for i in range(len(prices)):
if i < 120:
times.append(start + timedelta(minutes=i))
else:
times.append(datetime(start.year, start.month, start.day, 13, 0) + timedelta(minutes=i - 120))
return pl.DataFrame({
"symbol": [symbol] * len(prices),
"datetime": times,
"open": prices,
"high": [p * 1.01 for p in prices],
"low": [p * 0.99 for p in prices],
"close": prices,
"volume": volumes,
"amount": [p * v * 100.0 for p, v in zip(prices, volumes, strict=True)],
})
# ══ 特征帧 ═══════════════════════════════════════════════
def test_feature_frame_vwap_and_pct():
df = _bars([10.0, 10.2, 9.8])
frame = build_feature_frame(df, prev_close={SYMBOL: 10.0})
# vwap = 累计成交额 / (累计量(手)*100)
assert frame["vwap"][2] == pytest.approx((10.0 + 10.2 + 9.8) / 3)
assert frame["pct_vs_prev_close"][2] == pytest.approx(9.8 / 10.0 - 1)
assert frame["pct_from_open"][2] == pytest.approx(9.8 / 10.0 - 1)
assert frame["price"][2] == 9.8
# 缺昨收 → null
frame2 = build_feature_frame(df)
assert frame2["pct_vs_prev_close"][2] is None
def test_feature_frame_vol_ratio_windows_and_nulls():
# 量比: 当前N根量和 / 此前N根量和; 前 2N-1 根为 null
prices = [10.0] * 12
volumes = [100.0, 100.0, 100.0, 100.0, 100.0, 300.0, 100.0, 100.0, 900.0, 100.0, 100.0, 100.0]
frame = build_feature_frame(_bars(prices, volumes))
vr1 = frame["vol_ratio_1m_today"].to_list()
assert vr1[0] is None # 无前值
assert vr1[1] == pytest.approx(100.0 / 100.0)
assert vr1[5] == pytest.approx(300.0 / 100.0)
assert vr1[8] == pytest.approx(900.0 / 100.0)
vr3 = frame["vol_ratio_3m_today"].to_list()
assert vr3[0] is None and vr3[4] is None # 3+3-1=5 根前不足
# 第 6 根(idx5): [3,4,5]=100+100+300 vs [0,1,2]=300
assert vr3[5] == pytest.approx(500.0 / 300.0)
# 前窗口量为 0 → null(不伪装成 0)
zero_start = build_feature_frame(_bars([10.0, 10.0, 10.0], [0.0, 0.0, 500.0]))
assert zero_start["vol_ratio_1m_today"][2] is None
def test_feature_frame_rolling_window_never_crosses_lunch():
# 上午最后一根的 5 分钟窗口不回看跨日; 下午重新预热
n_am, n_pm = 8, 4
am = _bars([10.0] * n_am, [100.0] * n_am) # 09:30-09:37
pm = _bars([10.0] * n_pm, [100.0] * n_pm, start=datetime(2026, 9, 4, 13, 0))
frame = build_feature_frame(pl.concat([am, pm]))
vr = frame["vol_ratio_3m_today"].to_list()
# 上午 8 根: idx5 起 3/3 窗口成立(idx5,6,7 非 null); 下午 4 根全部 null(窗口不足)
assert vr[5] is not None and vr[7] is not None
assert all(v is None for v in vr[n_am:n_am + n_pm])
def test_feature_frame_open_30m_and_day_extremes():
frame = build_feature_frame(_bars([10.0 + 0.1 * i for i in range(32)]))
o30h = frame["open_30m_high_dist"].to_list()
assert all(v is None for v in o30h[:29]) # 开盘未满 30 分钟
# _bars 里 high = price*1.01 → 第 30 根 close/开盘30分钟最高 = 1/1.01
assert o30h[29] == pytest.approx(1.0 / 1.01 - 1)
assert o30h[31] == pytest.approx(13.1 / (12.9 * 1.01) - 1)
assert frame["day_high_dist"][5] == pytest.approx(1.0 / 1.01 - 1)
# 累计最低 = 首根 low(10.0*0.99), close_5 = 10.5
assert frame["day_low_dist"][5] == pytest.approx(10.5 / 9.9 - 1)
def test_feature_frame_cutoff_drops_incomplete_bar():
df = _bars([10.0, 10.1, 10.2])
frame = build_feature_frame(df, cutoff=datetime(2026, 9, 4, 9, 32))
assert frame.height == 2
# ══ 校验与编译 ═══════════════════════════════════════════
def _intraday_sig(**overrides) -> dict:
sig = {
"id": "test_sig", "name": "测试", "kind": "entry",
"timeframe": "intraday",
"conditions": [{"left": "price", "op": "cross_up", "right": "field:vwap"}],
}
sig.update(overrides)
return sig
def test_validate_intraday_accepts_and_rejects():
custom_signals.validate(_intraday_sig())
custom_signals.validate(_intraday_sig(
conditions=[{"left": "vol_ratio_3m_today", "op": ">", "right": "3"}],
min_bars=10,
))
with pytest.raises(ValueError, match="盘中字段"):
custom_signals.validate(_intraday_sig(conditions=[{"left": "ma5", "op": ">", "right": "1"}]))
with pytest.raises(ValueError, match="运算符"):
custom_signals.validate(_intraday_sig(conditions=[{"left": "price", "op": "~", "right": "1"}]))
with pytest.raises(ValueError, match="日期偏移"):
custom_signals.validate(_intraday_sig(
conditions=[{"left": "price", "op": ">", "right": "1", "leftDays": 1}]
))
with pytest.raises(ValueError, match="min_bars"):
custom_signals.validate(_intraday_sig(min_bars=500))
with pytest.raises(ValueError, match="timeframe"):
custom_signals.validate(_intraday_sig(timeframe="weekly"))
def test_build_intraday_expressions_edge_semantics():
# 价格下探再上穿 vwap: 上升沿恰好只在上穿那根 bar 为 true
prices = [10.0, 10.0, 10.0, 10.0, 9.8, 9.7, 9.6, 9.6, 9.6, 10.0, 10.2, 10.3]
frame = build_feature_frame(_bars(prices))
exprs = custom_signals.build_intraday_expressions([_intraday_sig()])
col = custom_signals.intraday_column_name("test_sig")
assert col == "csgi_test_sig"
out = custom_signals.apply_intraday_edges(frame, exprs)
fired = [i for i, v in enumerate(out[col].to_list()) if v]
assert len(fired) == 1
# idx8: 9.6 < vwap; idx9: 10.0 > vwap → 上穿在第 9 根(0 起)
assert fired[0] == 9
# 比较条件的上升沿: 持续满足只触发一次(本序列条件自首根即为真 → 只在回升的 idx9 触发)
sig2 = _intraday_sig(id="test_state", conditions=[{"left": "price", "op": ">", "right": "9.65"}])
exprs2 = custom_signals.build_intraday_expressions([sig2])
out2 = custom_signals.apply_intraday_edges(frame, exprs2)
fired2 = [i for i, v in enumerate(out2["csgi_test_state"].to_list()) if v]
assert fired2 == [9]
# 振荡序列: 每次 false→true 各触发一次
osc = build_feature_frame(_bars([10.0, 9.6, 10.0, 9.6, 10.0]))
exprs3 = custom_signals.build_intraday_expressions([sig2])
out3 = custom_signals.apply_intraday_edges(osc, exprs3)
assert [i for i, v in enumerate(out3["csgi_test_state"].to_list()) if v] == [2, 4]
# ══ v1 黄金等价: 旧累计循环算法作参照 ═════════════════════
def _legacy_v1_triggers(prices: list[float], prev_close: float | None) -> dict[str, int]:
"""复刻 v1 IntradaySignalEvaluator 的判定(逐 bar 累计, 边沿触发)。"""
cum_vol = cum_amt = 0.0
fired: dict[str, int] = {}
prev_price = prev_vwap = None
for i, p in enumerate(prices):
cum_vol += 100.0
cum_amt += p * 100.0 * 100.0
vwap = cum_amt / (cum_vol * 100.0)
if i >= 1:
if prev_price <= prev_vwap and p > vwap:
fired.setdefault("avg_up", i)
if prev_price >= prev_vwap and p < vwap:
fired.setdefault("avg_down", i)
if prev_close and prev_price <= prev_close and p > prev_close:
fired.setdefault("zero_up", i)
if prev_close and prev_price >= prev_close and p < prev_close:
fired.setdefault("zero_down", i)
prev_price, prev_vwap = p, vwap
return fired
@pytest.mark.parametrize("prices,prev_close", [
([10.0, 10.0, 10.0, 10.0, 9.8, 9.7, 9.6, 9.6, 9.6, 10.0, 10.2, 10.3], 10.0),
([9.0, 9.1, 9.2, 9.3, 9.4, 9.3, 9.2, 9.1, 9.0, 8.9, 8.8, 8.7], 9.25),
([10.0, 9.9, 10.1, 9.8, 10.2, 9.7, 10.3, 9.6, 10.4, 9.5, 10.5, 9.4], 9.95),
])
def test_evaluator_matches_legacy_v1(prices, prev_close):
"""新评估器逐 bar 喂入, 触发时点必须与 v1 算法完全一致。"""
df = _bars(prices)
evaluator = IntradaySignalEvaluator()
got: dict[str, int] = {}
for t in range(1, len(prices) + 1):
now = df["datetime"][t - 1] + timedelta(minutes=1)
rows = evaluator.evaluate(
df.head(t), symbols={SYMBOL}, prev_close={SYMBOL: prev_close},
asset_type="stock", now=now,
)
for r in rows:
if r.get("signal_intraday_avg_cross_up"):
got.setdefault("avg_up", t - 1)
if r.get("signal_intraday_avg_cross_down"):
got.setdefault("avg_down", t - 1)
if r.get("signal_intraday_zero_cross_up"):
got.setdefault("zero_up", t - 1)
if r.get("signal_intraday_zero_cross_down"):
got.setdefault("zero_down", t - 1)
assert got == _legacy_v1_triggers(prices, prev_close)
def test_evaluator_no_refire_without_new_bar():
prices = [10.0, 9.0, 10.5]
df = _bars(prices)
evaluator = IntradaySignalEvaluator()
# 逐根喂入: 首轮只建状态; 新 bar 出现才可能触发; 同批 bar 重跑不重复触发
fired: list[dict] = []
for t in range(1, len(prices) + 1):
now = df["datetime"][t - 1] + timedelta(minutes=1)
fired += evaluator.evaluate(
df.head(t), symbols={SYMBOL}, prev_close={SYMBOL: 10.0}, asset_type="stock", now=now,
)
assert fired # 9.0 下穿 / 10.5 上穿均有触发
now3 = df["datetime"][2] + timedelta(minutes=1)
again = evaluator.evaluate(df, symbols={SYMBOL}, prev_close={SYMBOL: 10.0}, asset_type="stock", now=now3)
assert again == [] # 无新 bar → 不重复触发
def test_evaluator_custom_csgi_signal_and_inject():
sig = _intraday_sig(id="my_intraday", conditions=[{"left": "price", "op": "cross_up", "right": 10.05}])
prices = [10.0, 9.9, 9.8, 10.1, 10.2, 10.3]
df = _bars(prices)
evaluator = IntradaySignalEvaluator()
fired_rows = []
for t in range(1, len(prices) + 1):
now = df["datetime"][t - 1] + timedelta(minutes=1)
fired_rows += evaluator.evaluate(
df.head(t), symbols={SYMBOL}, prev_close={}, asset_type="stock",
now=now, signals=[sig],
)
# 上穿 10.05 发生在 idx3 (10.1)
assert any(r.get("csgi_my_intraday") for r in fired_rows)
assert len([r for r in fired_rows if r.get("csgi_my_intraday")]) == 1
# inject 的契约是"单桶结果": 只传最后一个触发桶的行
last_bucket = fired_rows[-1:] if fired_rows else []
enriched = pl.DataFrame({"symbol": [SYMBOL, "999999.SZ"], "close": [10.0, 5.0]})
injected = evaluator.inject(enriched, last_bucket)
assert injected.height == 2 # 单桶单行, join 不膨胀
assert injected["csgi_my_intraday"].to_list() == [True, False] # 未触发标的补 False
assert set(injected.columns) >= {"signal_intraday_avg_cross_up", "signal_intraday_avg_cross_down"}
def test_uses_intraday_signals_matches_csgi_fields():
assert uses_intraday_signals({"conditions": [{"op": "truth", "field": "signal_intraday_avg_cross_up"}]})
assert uses_intraday_signals({"conditions": [{"op": "truth", "field": "csgi_my_intraday"}]})
assert not uses_intraday_signals({"conditions": [{"op": "truth", "field": "csg_daily_sig"}]})
assert not uses_intraday_signals({"conditions": [{"op": ">", "field": "close", "value": 1}]})
# ══ 引擎注入 + 加载缓存 ═══════════════════════════════════
def _engine(tmp_path: Path):
from app.strategy.engine import StrategyEngine
custom_dir = tmp_path / "strategies" / "custom"
custom_dir.mkdir(parents=True, exist_ok=True)
return StrategyEngine(strategy_dirs=[custom_dir])
def test_engine_injects_csgi_columns_into_minute_frame(tmp_path: Path):
sig = _intraday_sig(id="eng_sig", conditions=[{"left": "price", "op": "cross_up", "right": "field:vwap"}])
sig_dir = tmp_path / "user_data" / "custom_signals"
sig_dir.mkdir(parents=True, exist_ok=True)
(sig_dir / "eng_sig.json").write_text(json.dumps(sig), encoding="utf-8")
engine = _engine(tmp_path)
assert engine._user_data_dir() == tmp_path
prices = [10.0, 10.0, 9.0, 9.0, 10.5, 10.6]
injected = engine._inject_intraday_signal_columns(_bars(prices))
assert "csgi_eng_sig" in injected.columns
fired = [i for i, v in enumerate(injected["csgi_eng_sig"].to_list()) if v]
assert fired == [4] # idx4 上穿 vwap
# 定义缓存指纹失效: 改文件后引擎立刻读到新定义
(sig_dir / "eng_sig.json").write_text(json.dumps(
_intraday_sig(id="eng_sig", conditions=[{"left": "price", "op": "<", "right": "9.5"}]),
), encoding="utf-8")
injected2 = engine._inject_intraday_signal_columns(_bars(prices))
assert injected2["csgi_eng_sig"].to_list()[2] is True or injected2["csgi_eng_sig"].to_list()[2] == True # noqa: E712
def test_engine_skips_injection_without_definitions(tmp_path: Path):
engine = _engine(tmp_path)
df = _bars([10.0, 10.1])
out = engine._inject_intraday_signal_columns(df)
assert out is df or out.equals(df)
# ══ 回放 API ═════════════════════════════════════════════
def _seed_repo(tmp_path: Path):
from app.tickflow.repository import DataStore, KlineRepository
def minute(day: str, prices: list[float]):
part = tmp_path / "kline_minute" / f"date={day}" / "part.parquet"
part.parent.mkdir(parents=True, exist_ok=True)
_bars(prices, start=datetime.fromisoformat(f"{day}T09:30:00")).with_columns(
pl.col("datetime").cast(pl.Datetime("us"))
).write_parquet(part)
def daily(day: str, closes: dict[str, float]):
part = tmp_path / "kline_daily" / f"date={day}" / "part.parquet"
part.parent.mkdir(parents=True, exist_ok=True)
pl.DataFrame({"symbol": list(closes), "close": list(closes.values())}).write_parquet(part)
minute("2026-09-03", [10.0, 9.9, 9.8, 9.9, 10.0, 10.1])
minute("2026-09-04", [10.0, 10.0, 9.0, 9.0, 10.5, 10.6])
daily("2026-09-02", {SYMBOL: 9.95})
daily("2026-09-03", {SYMBOL: 10.0})
return KlineRepository(DataStore(tmp_path))
def test_intraday_replay_endpoint(tmp_path: Path):
from app.api.signals import IntradayReplayRequest, intraday_replay
repo = _seed_repo(tmp_path)
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo)))
sig = _intraday_sig(id="replay_sig")
sig_dir = tmp_path / "user_data" / "custom_signals"
sig_dir.mkdir(parents=True, exist_ok=True)
(sig_dir / "replay_sig.json").write_text(json.dumps(sig), encoding="utf-8")
daily_sig = {
"id": "daily_sig", "name": "日线信号", "kind": "entry",
"conditions": [{"left": "close", "op": ">", "right": "1"}],
}
(sig_dir / "daily_sig.json").write_text(json.dumps(daily_sig), encoding="utf-8")
result = intraday_replay(IntradayReplayRequest(
signal_id="replay_sig", start_date="2026-09-03", end_date="2026-09-04",
symbols=[SYMBOL],
), request)
assert result["days_scanned"] == 2
times = [(t["date"], t["time"]) for t in result["triggers"]]
# 09-03: 10.0→9.x→10.0 上穿在 idx4(09:34); 09-04: 上穿在 idx4(09:34)
assert times == [("2026-09-03", "09:34:00"), ("2026-09-04", "09:34:00")]
# 日线信号不可回放
with pytest.raises(Exception, match="盘中"):
intraday_replay(IntradayReplayRequest(
signal_id="daily_sig", start_date="2026-09-03", end_date="2026-09-04",
symbols=[SYMBOL],
), request)
+10
View File
@@ -89,6 +89,16 @@
---
## 🚦 盘中信号(分钟K)
信号库支持 `timeframe=intraday` 的盘中信号:基于当日**已完成**的一分钟 K 计算特征——分时均价、相对昨收/开盘涨跌幅、1/3/5 分钟放量比(今日基准,滚动窗口、每分钟刷新)、距当日与开盘 30 分钟高低点。条件支持数值比较与 `cross_up / cross_down`(上穿/下穿另一特征或阈值);信号输出为**当日条件上升沿**(条件 false→true 的那一根 bar 才为真,首根不触发;窗口不足、基准为零、缺昨收时特征为 null,判 false 绝不误报)。
- **口径单源**:监控评估、分钟策略执行、分钟回测、历史回放共用同一特征构造器(`app/strategy/intraday_features.py`),四条路径对同一段分钟数据产出完全一致(含防未来函数:只用已收盘 bar,滚动窗口不跨午休/跨日);
- **消费入口**:监控规则字段引用 `csgi_*` 列(与内置 4 个分时穿越信号并列,旧信号列名不变、存量规则零迁移);分钟策略(`timeframes=['1m']`)在 `filter_minute_history` 帧上直接引用 `csgi_*` 列(引擎单点注入);日线策略引用盘中信号会显式报错,不支持;
- **回放验证**:`POST /api/custom-signals/intraday/replay` 用本地历史分钟 K 重放信号触发时点(区间 ≤60 天、标的 ≤200),先验证再配置监控,不消耗盘中数据能力;
- **能力门槛**:盘中评估复用分时信号监控门控——分钟 K 能力(订阅池,单轮标的上限默认 100)或全量分钟能力(落盘服务健康时读本地分区,覆盖全市场);回放与分钟回测只需本地已有分钟 K 历史。
## 📡 监控中心(Monitor)
统一规则引擎,一个页面管理**四类监控**: