mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
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:
+129
-4
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 毫秒。"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 缺失的列。"""
|
||||
|
||||
@@ -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()]
|
||||
)
|
||||
@@ -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:
|
||||
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:
|
||||
last_time = part["datetime"][-1]
|
||||
if cutoff is not None and last_time.date() != cutoff.date():
|
||||
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:
|
||||
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
|
||||
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.col(field).fill_null(False).cast(pl.Boolean).alias(field)
|
||||
if field in out.columns
|
||||
else pl.lit(False, dtype=pl.Boolean).alias(field)
|
||||
)
|
||||
for field in fields
|
||||
])
|
||||
return out
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
统一规则引擎,一个页面管理**四类监控**:
|
||||
|
||||
Reference in New Issue
Block a user