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 pydantic import BaseModel
from app.strategy import custom_signals from app.strategy import custom_signals
from app.strategy.intraday_features import INTRADAY_FEATURES
router = APIRouter(prefix="/api/custom-signals", tags=["custom-signals"]) router = APIRouter(prefix="/api/custom-signals", tags=["custom-signals"])
@@ -24,7 +25,9 @@ def _invalidate(request: Request) -> None:
信号增删会改变注入列集合: 只清表达式缓存不够, repo 内存缓存 / 信号增删会改变注入列集合: 只清表达式缓存不够, repo 内存缓存 /
strategy 磁盘缓存里算好的历史窗口仍不含新 csg_ 列 (或仍含已删列), strategy 磁盘缓存里算好的历史窗口仍不含新 csg_ 列 (或仍含已删列),
需要一并清除, 否则创建信号后立即运行策略仍会报缺列。 需要一并清除, 否则创建信号后立即运行策略仍会报缺列。
盘中信号定义缓存(intraday)一并失效, 下一分钟 bucket 即生效。
""" """
custom_signals.invalidate_intraday_cache()
from app.indicators.pipeline import invalidate_custom_signals from app.indicators.pipeline import invalidate_custom_signals
invalidate_custom_signals() invalidate_custom_signals()
from app.services import strategy_cache from app.services import strategy_cache
@@ -35,11 +38,11 @@ def _invalidate(request: Request) -> None:
class ConditionModel(BaseModel): class ConditionModel(BaseModel):
left: str # 字段名(须在白名单) left: str # 字段名(日线在白名单 / 盘中在特征白名单)
op: str # > >= < <= == != op: str # > >= < <= == != ; 盘中额外: cross_up cross_down
right: str # "field:xxx" 或数字字符串 right: str # "field:xxx" 或数字字符串
leftDays: int = 0 # 左字段取几日前 (0=当日, 默认) leftDays: int = 0 # 左字段取几日前 (0=当日, 默认; 盘中信号必须为 0)
rightDays: int = 0 # 右字段取几日前 (仅 right 为字段时有意义) rightDays: int = 0 # 右字段取几日前 (仅 right 为字段时有意义; 盘中信号必须为 0)
class SignalModel(BaseModel): class SignalModel(BaseModel):
@@ -48,6 +51,17 @@ class SignalModel(BaseModel):
kind: str # entry | exit | both kind: str # entry | exit | both
conditions: list[ConditionModel] conditions: list[ConditionModel]
enabled: bool = True 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): class AIGenerateRequest(BaseModel):
@@ -115,6 +129,18 @@ def get_options():
{"key": "exit", "label": "出场"}, {"key": "exit", "label": "出场"},
{"key": "both", "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="信号不存在") raise HTTPException(status_code=404, detail="信号不存在")
_invalidate(request) _invalidate(request)
return {"ok": True} 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, prev_close=prev_close,
asset_type=asset_type, asset_type=asset_type,
now=now, now=now,
signals=self._load_intraday_signal_defs(),
) )
return self._intraday_signal_evaluator.inject(enriched, signals) 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 @staticmethod
def _continuous_session_start_ms() -> float: def _continuous_session_start_ms() -> float:
"""当前连续竞价时段的起点 (北京时间 9:30 或 13:00) 的 epoch 毫秒。""" """当前连续竞价时段的起点 (北京时间 9:30 或 13:00) 的 epoch 毫秒。"""
+172
View File
@@ -202,11 +202,17 @@ def validate(sig: dict) -> None:
raise ValueError("信号 name 不能为空") raise ValueError("信号 name 不能为空")
if sig.get("kind") not in ("entry", "exit", "both"): if sig.get("kind") not in ("entry", "exit", "both"):
raise ValueError("kind 必须是 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") conds = sig.get("conditions")
if not isinstance(conds, list) or len(conds) == 0: if not isinstance(conds, list) or len(conds) == 0:
raise ValueError("conditions 不能为空") raise ValueError("conditions 不能为空")
if len(conds) > 8: if len(conds) > 8:
raise ValueError("conditions 最多 8 条") raise ValueError("conditions 最多 8 条")
if timeframe == TIMEFRAME_INTRADAY:
_validate_intraday(sig)
return
for i, c in enumerate(conds): for i, c in enumerate(conds):
if not isinstance(c, dict): if not isinstance(c, dict):
raise ValueError(f"{i+1} 个条件格式错误") raise ValueError(f"{i+1} 个条件格式错误")
@@ -317,3 +323,169 @@ def _expr_root_columns(expr: pl.Expr) -> set[str]:
return set(names) return set(names)
except Exception: except Exception:
return set() 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, strategy_id=strategy_id,
exit_signal_hits=exit_signal_hits, 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: if s.minute_daily_bars > 0:
df = s.filter_minute_history_fn(history, params, daily=context.daily_history) df = s.filter_minute_history_fn(history, params, daily=context.daily_history)
else: else:
@@ -982,6 +995,15 @@ class StrategyEngine:
+ ", ".join(sorted(missing_csg)) + ", ".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) df = s.filter_history_fn(df, params)
if "date" in df.columns: if "date" in df.columns:
df = df.filter(pl.col("date") == as_of) df = df.filter(pl.col("date") == as_of)
@@ -1555,6 +1577,48 @@ class StrategyEngine:
"turnover_rate", "change_pct", "pre_close", "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 @staticmethod
def _join_basic_columns(df: pl.DataFrame, current: pl.DataFrame) -> pl.DataFrame: def _join_basic_columns(df: pl.DataFrame, current: pl.DataFrame) -> pl.DataFrame:
"""把 enriched 快照列按 symbol 联到分钟策略输出上, 只补 df 缺失的列。""" """把 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()]
)
+109 -72
View File
@@ -1,13 +1,25 @@
"""监控中心专用的日内分时穿越信号。""" """监控中心专用的日内分时信号评估器。
v2: 特征计算与条件求值统一走 intraday_features 特征帧 + custom_signals 的
盘中表达式编译 — 与分钟策略执行/分钟回测/回放验证同一条口径。
- 内置 4 个分时穿越信号(signal_intraday_*)由同一表达式机制生成, 列名不变,
存量监控规则零迁移;
- 自定义盘中信号(timeframe="intraday", csgi_ 前缀)与内置信号一并评估注入。
"""
from __future__ import annotations from __future__ import annotations
import math import logging
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
import polars as pl import polars as pl
from app.market_time import CN_TZ 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] = { INTRADAY_SIGNAL_LABELS: dict[str, str] = {
"signal_intraday_avg_cross_up": "分时价格上穿均价", "signal_intraday_avg_cross_up": "分时价格上穿均价",
@@ -16,22 +28,41 @@ INTRADAY_SIGNAL_LABELS: dict[str, str] = {
"signal_intraday_zero_cross_down": "分时价格下穿0轴", "signal_intraday_zero_cross_down": "分时价格下穿0轴",
} }
INTRADAY_SIGNAL_FIELDS = frozenset(INTRADAY_SIGNAL_LABELS) INTRADAY_SIGNAL_FIELDS = frozenset(INTRADAY_SIGNAL_LABELS)
_LEGACY_MIN_BARS = 2 # 旧实现要求至少两根已完成 bar 才判穿越, 语义保持
def uses_intraday_signals(rule: dict) -> bool: def uses_intraday_signals(rule: dict) -> bool:
"""规则是否引用盘中信号列(内置 4 个或自定义 csgi_)。"""
return any( 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", []) for c in rule.get("conditions", [])
if isinstance(c, dict)
) )
def _finite(value: Any) -> float | None: def _legacy_builtin_definitions() -> list[dict]:
try: """内置 4 个分时穿越信号的等价定义(与 v1 逐字节同口径)。
number = float(value)
except (TypeError, ValueError): v1 语义: 上穿 = 前一根 bar 未满足且当前 bar 满足 —— 与
return None build_intraday_expressions 的「条件上升沿」完全一致。
return number if math.isfinite(number) else None """
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: def _naive_datetime(value: Any) -> datetime | None:
@@ -43,7 +74,7 @@ def _naive_datetime(value: Any) -> datetime | None:
class IntradaySignalEvaluator: class IntradaySignalEvaluator:
"""按已完成的一分钟 K 线生成边沿触发信号""" """按已完成的一分钟 K 线评估盘中信号(边沿触发, 新 bar 出现才可能触发)"""
def __init__(self) -> None: def __init__(self) -> None:
self._last_bar: dict[tuple[str, str], datetime] = {} self._last_bar: dict[tuple[str, str], datetime] = {}
@@ -56,86 +87,92 @@ class IntradaySignalEvaluator:
prev_close: dict[str, float], prev_close: dict[str, float],
asset_type: str, asset_type: str,
now: datetime, now: datetime,
signals: list[dict] | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""返回本分钟触发信号的行列表(每 symbol 一行, 仅新出现的 bar 触发)。"""
active_keys = {(asset_type, symbol) for symbol in symbols} active_keys = {(asset_type, symbol) for symbol in symbols}
self._last_bar = { self._last_bar = {
key: value for key, value in self._last_bar.items() key: value for key, value in self._last_bar.items()
if key[0] != asset_type or key in active_keys if key[0] != asset_type or key in active_keys
} }
required = {"symbol", "datetime", "close", "volume", "amount"} definitions = _legacy_builtin_definitions() + list(signals or [])
if not symbols or minute_df.is_empty() or not required.issubset(minute_df.columns): if not symbols:
return [] 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) 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]] = [] 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") part = part.sort("datetime")
symbol = str(part["symbol"][0]) symbol = str(part["symbol"][0])
points: list[tuple[datetime, float, float | None]] = [] last_time = part["datetime"][-1]
cumulative_amount = 0.0 if cutoff is not None and last_time.date() != cutoff.date():
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:
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 continue
current = points[-1]
key = (asset_type, symbol) key = (asset_type, symbol)
last_bar = self._last_bar.get(key) last_seen = self._last_bar.get(key)
self._last_bar[key] = current[0] self._last_bar[key] = last_time
if last_bar is None or last_bar.date() != current[0].date() or current[0] <= last_bar: # 只有出现新 bar 才可能触发; 首次见到该标的只建状态不发信号
if last_seen is None or last_time <= last_seen or last_time.date() != last_seen.date():
continue continue
if len(points) < 2: row = {name: bool(part[name][-1]) for name in signal_cols}
continue if any(row.values()):
row["symbol"] = symbol
previous = points[-2] results.append(row)
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,
})
return results return results
@staticmethod @staticmethod
def inject(df: pl.DataFrame, signals: list[dict[str, Any]]) -> pl.DataFrame: 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 out = df.drop(existing) if existing else df
if signals: if signals:
out = out.join(pl.DataFrame(signals), on="symbol", how="left") cols = sorted({f for s in signals for f in s if f != "symbol"})
else: out = out.join(pl.DataFrame(signals).select(["symbol", *cols]), on="symbol", how="left")
out = out.with_columns([ out = out.with_columns([
pl.lit(False).alias(field) for field in INTRADAY_SIGNAL_FIELDS (
]) pl.col(field).fill_null(False).cast(pl.Boolean).alias(field)
return out.with_columns([ if field in out.columns
pl.col(field).fill_null(False).cast(pl.Boolean).alias(field) else pl.lit(False, dtype=pl.Boolean).alias(field)
for field in INTRADAY_SIGNAL_FIELDS )
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) ## 📡 监控中心(Monitor)
统一规则引擎,一个页面管理**四类监控**: 统一规则引擎,一个页面管理**四类监控**: