mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
feat(minute): 分钟红7新增「N日内涨停过」条件 + minute_filter 日线窗口契约
分钟策略此前只能访问当日分钟窗口, 无法叠加日线维度条件。本次为 minute_filter 后端扩展可选日线历史契约: - 契约: 策略声明 META["daily_history_bars"] (0-250) + filter_minute_history 接受 daily 关键字 (加载期校验, 纯分钟策略零改动); 引擎聚合各策略声明 (minute_daily_history_bars) 后由 ScreenerService 1m 分支装配 context.daily_history (enriched 日线窗口), run() 以 daily= 注入 - 分钟红7: require_limit_up (默认开) + limit_up_days (5-60, 默认20) 参数; 涨停判定复用 enriched 预计算信号 signal_limit_up (收盘封板) 或 signal_broken_limit_up (炸板盘中触及), 任一命中即算涨停过, 输出 recent_limit_ups 次数列; 日线窗口缺失时失败闭合 (宁可漏过不可错报) - 测试 25 项: 涨停信号过滤/炸板计数/回看窗口边界(第20日含第21日不含)/ 失败闭合/开关旁路/加载校验(缺 daily 关键字与超范围)/引擎注入/服务装配 实盘验证: 用户参数(bars=6)基线 24 只 → 要求涨停过后 6 只, 全部带 recent_limit_ups; 全量 1112 项后端测试通过。
This commit is contained in:
@@ -390,12 +390,20 @@ class ScreenerService:
|
||||
# 分钟策略数据源是本地当日分钟K分区 (单分区文件直读), 与日线
|
||||
# enriched 历史窗口无关, 不走 required_history_bars 日线路径。
|
||||
history = self._load_minute_history(as_of, current)
|
||||
# 策略声明 META["daily_history_bars"] 时额外装配日线 enriched 窗口,
|
||||
# 供分钟策略叠加日线维度条件 (如 N 日内涨停过)。
|
||||
daily_history = None
|
||||
if engine is not None:
|
||||
daily_bars = engine.minute_daily_history_bars(strategy_ids)
|
||||
if daily_bars > 0:
|
||||
daily_history = self._load_enriched_history(as_of, daily_bars)
|
||||
return StrategyDataContext(
|
||||
asset_type=self.asset_type,
|
||||
timeframe=timeframe,
|
||||
as_of=as_of,
|
||||
current=current,
|
||||
history=history,
|
||||
daily_history=daily_history,
|
||||
market=None,
|
||||
cache_key=cache_key,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
(symbol, datetime, open, high, low, close, volume, amount),
|
||||
由 ScreenerService.build_strategy_context 的 1m 分支从本地 kline_minute
|
||||
分区注入; 策略本身不感知数据来源 (本地同步 / 盘中增量刷新对它透明)。
|
||||
|
||||
META["daily_history_bars"] 声明叠加日线维度的条件 (N 日内涨停过):
|
||||
引擎会以 daily= 关键字注入日线 enriched 窗口, 涨停判定直接复用
|
||||
enriched 预计算信号 — signal_limit_up (收盘封板) 或 signal_broken_limit_up
|
||||
(炸板: 盘中触及涨停未封住), 任一命中即算"盘中涨停过"。
|
||||
"""
|
||||
|
||||
import polars as pl
|
||||
@@ -11,10 +16,12 @@ import polars as pl
|
||||
META = {
|
||||
"id": "minute_red_streak",
|
||||
"name": "分钟红7",
|
||||
"description": "开盘前7根1分钟K至少5根收红, 且最高的2根(按最高价)都是红K",
|
||||
"description": "开盘前7根1分钟K至少5根收红, 最高的2根(按最高价)都是红K, 且近20日盘中触及过涨停",
|
||||
"tags": ["分钟", "形态", "短线"],
|
||||
"asset_types": ["stock"],
|
||||
"timeframes": ["1m"],
|
||||
# 日线 enriched 窗口 (交易日语义, 含 as_of): 覆盖 limit_up_days 参数上限
|
||||
"daily_history_bars": 60,
|
||||
"params": [
|
||||
{
|
||||
"id": "bars",
|
||||
@@ -49,6 +56,21 @@ META = {
|
||||
"type": "bool",
|
||||
"default": False,
|
||||
},
|
||||
{
|
||||
"id": "require_limit_up",
|
||||
"label": "要求N日内涨停过",
|
||||
"type": "bool",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"id": "limit_up_days",
|
||||
"label": "涨停回看天数",
|
||||
"type": "int",
|
||||
"default": 20,
|
||||
"min": 5,
|
||||
"max": 60,
|
||||
"step": 1,
|
||||
},
|
||||
],
|
||||
"order_by": "red_count",
|
||||
"descending": True,
|
||||
@@ -60,12 +82,41 @@ ENTRY_SIGNALS: list[str] = []
|
||||
EXIT_SIGNALS: list[str] = []
|
||||
|
||||
|
||||
def filter_minute_history(df: pl.DataFrame, params: dict) -> pl.DataFrame:
|
||||
def _recent_limit_ups(daily: pl.DataFrame | None, lookback: int) -> pl.DataFrame:
|
||||
"""日线窗口 → (symbol, recent_limit_ups) 近 lookback 个交易日的涨停次数。
|
||||
|
||||
涨停过 = signal_limit_up (收盘封板) 或 signal_broken_limit_up (炸板触及)。
|
||||
日线窗口缺失 / 无涨停信号列 → 返回空表 (调用方 inner join 即失败闭合,
|
||||
宁可漏过不可错报)。
|
||||
"""
|
||||
empty = pl.DataFrame(schema={"symbol": pl.Utf8, "recent_limit_ups": pl.UInt32})
|
||||
if daily is None or daily.is_empty():
|
||||
return empty
|
||||
if not {"signal_limit_up", "signal_broken_limit_up"}.issubset(daily.columns):
|
||||
return empty
|
||||
return (
|
||||
daily.select("symbol", "date", "signal_limit_up", "signal_broken_limit_up")
|
||||
.sort(["symbol", "date"])
|
||||
.filter(pl.int_range(pl.len()).over("symbol") >= pl.len().over("symbol") - lookback)
|
||||
.group_by("symbol")
|
||||
.agg(
|
||||
recent_limit_ups=(
|
||||
pl.col("signal_limit_up").fill_null(False)
|
||||
| pl.col("signal_broken_limit_up").fill_null(False)
|
||||
).sum()
|
||||
)
|
||||
.filter(pl.col("recent_limit_ups") > 0)
|
||||
)
|
||||
|
||||
|
||||
def filter_minute_history(df: pl.DataFrame, params: dict, *, daily: pl.DataFrame | None = None) -> pl.DataFrame:
|
||||
"""红K形态过滤: 全向量化, 无逐行 Python 循环。
|
||||
|
||||
- 每标的按时间取当日最早 bars 根 (开盘窗口); 不足 bars 根不触发
|
||||
- 红 = close > open; 窗口内红K数 >= min_red
|
||||
- 按 rank_by (high / close) 降序取前 top_red 根, 同值取时间更晚者, 需全红
|
||||
- require_limit_up: 近 limit_up_days 个交易日盘中触及过涨停 (日线维度,
|
||||
由 daily 窗口的预计算涨停信号判定; 窗口缺失时失败闭合不触发)
|
||||
"""
|
||||
bars = int(params.get("bars") or 7)
|
||||
min_red = min(int(params.get("min_red") or 5), bars)
|
||||
@@ -99,7 +150,7 @@ def filter_minute_history(df: pl.DataFrame, params: dict) -> pl.DataFrame:
|
||||
.agg(top_red_count=pl.col("_red").sum())
|
||||
)
|
||||
|
||||
return (
|
||||
result = (
|
||||
window.join(top, on="symbol", how="inner")
|
||||
.filter(
|
||||
(pl.col("bars_checked") >= bars)
|
||||
@@ -108,3 +159,7 @@ def filter_minute_history(df: pl.DataFrame, params: dict) -> pl.DataFrame:
|
||||
)
|
||||
.drop("bars_checked")
|
||||
)
|
||||
if params.get("require_limit_up", True):
|
||||
lookback = max(5, min(int(params.get("limit_up_days") or 20), 60))
|
||||
result = result.join(_recent_limit_ups(daily, lookback), on="symbol", how="inner")
|
||||
return result
|
||||
|
||||
@@ -155,6 +155,9 @@ class StrategyDataContext:
|
||||
as_of: date
|
||||
current: pl.DataFrame | None = None
|
||||
history: pl.DataFrame | None = None
|
||||
# 仅 1m 分支: 策略声明 META["daily_history_bars"] 时注入的日线 enriched 窗口,
|
||||
# 供分钟策略叠加日线维度条件 (如 N 日内涨停过); 未声明时为 None。
|
||||
daily_history: pl.DataFrame | None = None
|
||||
market: Any | None = None
|
||||
cache_key: str | None = None
|
||||
|
||||
@@ -201,6 +204,9 @@ class StrategyDef:
|
||||
composite: CompositeSpec | None = None # 仅 backend=="composite" 时非空
|
||||
# 仅 backend=="minute_filter" 时非空: 输入为当日分钟K窗口, 输出为命中标的行
|
||||
filter_minute_history_fn: Callable[[pl.DataFrame, dict], pl.DataFrame] | None = None
|
||||
# 仅 minute_filter: META["daily_history_bars"] 声明需要的日线历史窗口 (0=不需要;
|
||||
# >0 时 filter_minute_history 必须接受 daily 关键字, 引擎注入 context.daily_history)
|
||||
minute_daily_bars: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -493,6 +499,7 @@ class StrategyEngine:
|
||||
|
||||
matrix_strategy = getattr(mod, "MATRIX_STRATEGY", None)
|
||||
composite_spec: CompositeSpec | None = None
|
||||
minute_daily_bars = 0
|
||||
if execution_backend == "matrix_native":
|
||||
from app.backtest.matrix import MatrixStrategy
|
||||
|
||||
@@ -535,6 +542,22 @@ class StrategyEngine:
|
||||
raise ValueError(
|
||||
"minute_filter strategy must declare timeframes == ['1m']"
|
||||
)
|
||||
# 可选日线历史窗口: 声明 daily_history_bars 时 fn 必须接受 daily 关键字,
|
||||
# 引擎会把 context.daily_history (enriched 日线窗口) 注入进来。
|
||||
minute_daily_bars = int(meta.get("daily_history_bars") or 0)
|
||||
if minute_daily_bars < 0 or minute_daily_bars > 250:
|
||||
raise ValueError(
|
||||
"minute_filter daily_history_bars must be within [0, 250]"
|
||||
)
|
||||
if minute_daily_bars > 0:
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(filter_minute_history_fn)
|
||||
if "daily" not in sig.parameters:
|
||||
raise ValueError(
|
||||
"minute_filter daily_history_bars requires "
|
||||
"filter_minute_history to accept a 'daily' keyword"
|
||||
)
|
||||
elif filter_history_fn is None or filter_fn is not None:
|
||||
raise ValueError("python_history_legacy strategy must declare only filter_history")
|
||||
|
||||
@@ -559,6 +582,7 @@ class StrategyEngine:
|
||||
matrix_strategy=matrix_strategy,
|
||||
composite=composite_spec,
|
||||
filter_minute_history_fn=filter_minute_history_fn,
|
||||
minute_daily_bars=minute_daily_bars,
|
||||
)
|
||||
|
||||
def reload(self) -> None:
|
||||
@@ -673,6 +697,16 @@ class StrategyEngine:
|
||||
return None
|
||||
return max(0, int(value))
|
||||
|
||||
def minute_daily_history_bars(self, strategy_ids: list[str]) -> int:
|
||||
"""1m 分支需要的日线 enriched 窗口大小: 各 minute_filter 策略声明的
|
||||
META["daily_history_bars"] 取 max, 未声明 (纯分钟策略) 为 0。"""
|
||||
required = 0
|
||||
for strategy_id in strategy_ids:
|
||||
strategy = self.get(strategy_id)
|
||||
if strategy.execution_backend == "minute_filter":
|
||||
required = max(required, strategy.minute_daily_bars)
|
||||
return required
|
||||
|
||||
def required_history_bars(
|
||||
self,
|
||||
strategy_ids: list[str],
|
||||
@@ -917,7 +951,10 @@ class StrategyEngine:
|
||||
strategy_id=strategy_id,
|
||||
exit_signal_hits=exit_signal_hits,
|
||||
)
|
||||
df = s.filter_minute_history_fn(history, params)
|
||||
if s.minute_daily_bars > 0:
|
||||
df = s.filter_minute_history_fn(history, params, daily=context.daily_history)
|
||||
else:
|
||||
df = s.filter_minute_history_fn(history, params)
|
||||
# 基础过滤/展示列 (name/total_shares/change_pct 等) 来自 enriched 快照,
|
||||
# 在命中结果上事后联表, 避免把 enriched 列铺到全市场分钟行上。
|
||||
if current is not None and not current.is_empty():
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_pattern_hits_five_red_of_seven_with_red_top_two():
|
||||
(10.5, 10.7, 10.80), # 红
|
||||
(10.7, 10.8, 10.90), # 红 (最高)
|
||||
]
|
||||
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {})
|
||||
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False})
|
||||
assert out["symbol"].to_list() == ["600000.SH"]
|
||||
row = out.row(0, named=True)
|
||||
assert row["red_count"] == 5
|
||||
@@ -61,7 +61,7 @@ def test_pattern_hits_five_red_of_seven_with_red_top_two():
|
||||
|
||||
|
||||
def test_pattern_insufficient_bars_never_triggers():
|
||||
out = minute_red_streak.filter_minute_history(_bars("600000.SH", [(10.0, 10.2, 10.3)] * 6), {})
|
||||
out = minute_red_streak.filter_minute_history(_bars("600000.SH", [(10.0, 10.2, 10.3)] * 6), {"require_limit_up": False})
|
||||
assert out.is_empty()
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ def test_pattern_green_at_top_blocks_hit():
|
||||
(11.5, 11.0, 12.00), # 绿 (最高)
|
||||
(11.0, 11.4, 11.90), # 红 (次高)
|
||||
]
|
||||
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {})
|
||||
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False})
|
||||
assert out.is_empty()
|
||||
|
||||
|
||||
@@ -91,9 +91,9 @@ def test_pattern_rank_by_close_uses_close_not_high():
|
||||
(11.4, 11.1, 11.50), # 绿 (high 最高, 并列)
|
||||
(11.1, 11.5, 11.55), # 红 (close 最高)
|
||||
]
|
||||
by_high = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {})
|
||||
by_high = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False})
|
||||
by_close = minute_red_streak.filter_minute_history(
|
||||
_bars("600000.SH", candles), {"rank_by_close": True}
|
||||
_bars("600000.SH", candles), {"rank_by_close": True, "require_limit_up": False}
|
||||
)
|
||||
assert by_high.is_empty()
|
||||
assert by_close["symbol"].to_list() == ["600000.SH"]
|
||||
@@ -107,7 +107,7 @@ def test_pattern_sorts_unordered_input_by_datetime():
|
||||
(10.6, 10.5, 10.65), (10.5, 10.7, 10.80), (10.7, 10.8, 10.90),
|
||||
]),
|
||||
]).sample(fraction=1.0, shuffle=True, seed=7)
|
||||
out = minute_red_streak.filter_minute_history(bars, {})
|
||||
out = minute_red_streak.filter_minute_history(bars, {"require_limit_up": False})
|
||||
assert out["symbol"].to_list() == ["600000.SH"]
|
||||
assert out.row(0, named=True)["close"] == 10.8 # 最后一根(时间最大)的收盘
|
||||
|
||||
@@ -124,7 +124,7 @@ def test_pattern_three_way_high_tie_prefers_later_bars():
|
||||
(10.5, 10.6, 10.90), # 红 (并列最高, 中间)
|
||||
(10.6, 10.8, 10.90), # 红 (并列最高, 最晚)
|
||||
]
|
||||
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {})
|
||||
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False})
|
||||
assert out["symbol"].to_list() == ["600000.SH"]
|
||||
assert out.row(0, named=True)["top_red_count"] == 2
|
||||
|
||||
@@ -141,8 +141,8 @@ def test_pattern_min_red_threshold_respected():
|
||||
(10.5, 10.8, 10.90), # 红
|
||||
]
|
||||
bars = _bars("600000.SH", candles)
|
||||
assert minute_red_streak.filter_minute_history(bars, {"min_red": 5}).is_empty()
|
||||
assert not minute_red_streak.filter_minute_history(bars, {"min_red": 4}).is_empty()
|
||||
assert minute_red_streak.filter_minute_history(bars, {"min_red": 5, "require_limit_up": False}).is_empty()
|
||||
assert not minute_red_streak.filter_minute_history(bars, {"min_red": 4, "require_limit_up": False}).is_empty()
|
||||
|
||||
|
||||
def test_pattern_uses_opening_bars_even_if_day_turns_green():
|
||||
@@ -159,7 +159,7 @@ def test_pattern_uses_opening_bars_even_if_day_turns_green():
|
||||
(10.8, 10.0, 10.85), # 开盘窗口外的绿
|
||||
(10.0, 9.5, 10.05), # 开盘窗口外的绿
|
||||
]
|
||||
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {})
|
||||
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False})
|
||||
assert out["symbol"].to_list() == ["600000.SH"]
|
||||
row = out.row(0, named=True)
|
||||
assert row["red_count"] == 5
|
||||
@@ -180,10 +180,84 @@ def test_pattern_opening_window_miss_not_rescued_by_late_reds():
|
||||
(10.8, 10.9, 11.00), # 红 (窗口外)
|
||||
(10.9, 11.0, 11.10), # 红 (窗口外)
|
||||
]
|
||||
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {})
|
||||
out = minute_red_streak.filter_minute_history(_bars("600000.SH", candles), {"require_limit_up": False})
|
||||
assert out.is_empty()
|
||||
|
||||
|
||||
# ── 涨停条件 (日线维度) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
_HIT_CANDLES = [
|
||||
(10.0, 10.2, 10.30), # 红
|
||||
(10.2, 10.1, 10.25), # 绿 (低高点)
|
||||
(10.1, 10.4, 10.50), # 红
|
||||
(10.4, 10.6, 10.70), # 红 (次高)
|
||||
(10.6, 10.5, 10.65), # 绿 (低高点)
|
||||
(10.5, 10.7, 10.80), # 红
|
||||
(10.7, 10.8, 10.90), # 红 (最高)
|
||||
]
|
||||
|
||||
|
||||
def _daily(
|
||||
symbol: str,
|
||||
days: int,
|
||||
flag_on: set[int] | None = None,
|
||||
*,
|
||||
broken: bool = False,
|
||||
) -> pl.DataFrame:
|
||||
"""days 个交易日的日线帧; flag_on 指定第几天 (0=最早) 触发涨停信号。"""
|
||||
flag_on = flag_on or set()
|
||||
base = date(2026, 8, 25)
|
||||
return pl.DataFrame({
|
||||
"symbol": [symbol] * days,
|
||||
"date": [base - _dt.timedelta(days=days - i) for i in range(days)],
|
||||
"signal_limit_up": [i in flag_on and not broken for i in range(days)],
|
||||
"signal_broken_limit_up": [i in flag_on and broken for i in range(days)],
|
||||
})
|
||||
|
||||
|
||||
def test_pattern_limit_up_condition_filters_by_daily_signals():
|
||||
bars = pl.concat([
|
||||
_bars("600001.SH", _HIT_CANDLES),
|
||||
_bars("600002.SH", _HIT_CANDLES),
|
||||
_bars("600003.SH", _HIT_CANDLES),
|
||||
])
|
||||
daily = pl.concat([
|
||||
_daily("600001.SH", 20, {3}), # 收盘涨停 → 过
|
||||
_daily("600002.SH", 20, {15}, broken=True), # 炸板触及 → 过
|
||||
_daily("600003.SH", 20), # 无涨停 → 剔除
|
||||
])
|
||||
out = minute_red_streak.filter_minute_history(bars, {}, daily=daily)
|
||||
assert sorted(out["symbol"].to_list()) == ["600001.SH", "600002.SH"]
|
||||
assert sorted(out["recent_limit_ups"].to_list()) == [1, 1]
|
||||
|
||||
|
||||
def test_pattern_limit_up_lookback_window_boundary():
|
||||
# 25 个交易日, 涨停仅发生在第 5 天 (0=最早): 回看 20 日窗口 = 最后 20 根
|
||||
# (索引 5..24), 第 5 天在窗外 → 不命中; 回看放宽到 25 → 命中
|
||||
bars = _bars("600000.SH", _HIT_CANDLES)
|
||||
daily = _daily("600000.SH", 25, {4})
|
||||
assert minute_red_streak.filter_minute_history(bars, {}, daily=daily).is_empty()
|
||||
out = minute_red_streak.filter_minute_history(
|
||||
bars, {"limit_up_days": 25}, daily=daily
|
||||
)
|
||||
assert out["symbol"].to_list() == ["600000.SH"]
|
||||
|
||||
|
||||
def test_pattern_limit_up_fails_closed_without_daily():
|
||||
# 日线窗口缺失时失败闭合 (宁可漏过不可错报)
|
||||
out = minute_red_streak.filter_minute_history(_bars("600000.SH", _HIT_CANDLES), {})
|
||||
assert out.is_empty()
|
||||
|
||||
|
||||
def test_pattern_limit_up_disabled_ignores_daily():
|
||||
out = minute_red_streak.filter_minute_history(
|
||||
_bars("600000.SH", _HIT_CANDLES), {"require_limit_up": False}
|
||||
)
|
||||
assert out["symbol"].to_list() == ["600000.SH"]
|
||||
assert "recent_limit_ups" not in out.columns
|
||||
|
||||
|
||||
# ── 引擎加载与运行 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -225,6 +299,75 @@ def test_minute_filter_backend_validation(tmp_path):
|
||||
assert any("timeframes" in e["error"] for e in engine.load_errors())
|
||||
|
||||
|
||||
def test_minute_filter_daily_history_validation(tmp_path):
|
||||
# 声明 daily_history_bars: fn 必须接受 daily 关键字, 且范围 [0, 250]
|
||||
(tmp_path / "m_daily_ok.py").write_text(
|
||||
'import polars as pl\n'
|
||||
'META = {"id": "m_daily_ok", "name": "x", "asset_types": ["stock"], '
|
||||
'"timeframes": ["1m"], "daily_history_bars": 20}\n'
|
||||
'EXECUTION_BACKEND = "minute_filter"\n'
|
||||
'def filter_minute_history(df, params, *, daily=None):\n'
|
||||
' return df.group_by("symbol").agg(close=pl.col("close").max())\n'
|
||||
)
|
||||
(tmp_path / "m_daily_kw.py").write_text(
|
||||
'import polars as pl\n'
|
||||
'META = {"id": "m_daily_kw", "name": "x", "asset_types": ["stock"], '
|
||||
'"timeframes": ["1m"], "daily_history_bars": 20}\n'
|
||||
'EXECUTION_BACKEND = "minute_filter"\n'
|
||||
'def filter_minute_history(df, params):\n'
|
||||
' return df.group_by("symbol").agg(close=pl.col("close").max())\n'
|
||||
)
|
||||
(tmp_path / "m_daily_range.py").write_text(
|
||||
'import polars as pl\n'
|
||||
'META = {"id": "m_daily_range", "name": "x", "asset_types": ["stock"], '
|
||||
'"timeframes": ["1m"], "daily_history_bars": 300}\n'
|
||||
'EXECUTION_BACKEND = "minute_filter"\n'
|
||||
'def filter_minute_history(df, params, *, daily=None):\n'
|
||||
' return df.group_by("symbol").agg(close=pl.col("close").max())\n'
|
||||
)
|
||||
engine = StrategyEngine(strategy_dirs=[tmp_path])
|
||||
assert engine.has("m_daily_ok")
|
||||
assert engine.get("m_daily_ok").minute_daily_bars == 20
|
||||
assert not engine.has("m_daily_kw")
|
||||
assert not engine.has("m_daily_range")
|
||||
assert any("'daily' keyword" in e["error"] for e in engine.load_errors())
|
||||
assert any("[0, 250]" in e["error"] for e in engine.load_errors())
|
||||
|
||||
|
||||
def test_minute_run_injects_daily_history(tmp_path):
|
||||
# fn 直接消费 daily (对涨停信号求和), 验证引擎把 context.daily_history 注入
|
||||
(tmp_path / "m_use_daily.py").write_text(
|
||||
'import polars as pl\n'
|
||||
'META = {"id": "m_use_daily", "name": "x", "asset_types": ["stock"], '
|
||||
'"timeframes": ["1m"], "daily_history_bars": 10}\n'
|
||||
'EXECUTION_BACKEND = "minute_filter"\n'
|
||||
'def filter_minute_history(df, params, *, daily=None):\n'
|
||||
' if daily is None:\n'
|
||||
' return pl.DataFrame(schema={"symbol": pl.Utf8})\n'
|
||||
' return daily.group_by("symbol").agg(\n'
|
||||
' close=pl.col("signal_limit_up").sum() + 10.0)\n'
|
||||
)
|
||||
engine = StrategyEngine(strategy_dirs=[tmp_path])
|
||||
context = StrategyDataContext(
|
||||
asset_type="stock",
|
||||
timeframe="1m",
|
||||
as_of=date(2026, 8, 25),
|
||||
current=pl.DataFrame({
|
||||
"symbol": ["600001.SH"],
|
||||
"name": ["正常股"],
|
||||
"total_shares": [1e8],
|
||||
"float_shares": [5e7],
|
||||
"amount": [3e8],
|
||||
"change_pct": [0.01],
|
||||
}),
|
||||
history=_bars("600001.SH", [(10.0, 10.2, 10.3)] * 7),
|
||||
daily_history=_daily("600001.SH", 10, {2}),
|
||||
)
|
||||
result = engine.run("m_use_daily", context)
|
||||
assert result.total == 1
|
||||
assert result.rows[0]["close"] == 11 # 10 + 窗口内 1 次收盘涨停
|
||||
|
||||
|
||||
def test_minute_context_run_applies_enriched_basic_filter(tmp_path):
|
||||
(tmp_path / "m_basic.py").write_text(_minute_code("m_basic"))
|
||||
engine = StrategyEngine(strategy_dirs=[tmp_path])
|
||||
@@ -345,3 +488,30 @@ def test_minute_context_rejects_non_stock_asset():
|
||||
raise AssertionError("expected ValueError")
|
||||
except ValueError as e:
|
||||
assert "A 股" in str(e)
|
||||
|
||||
|
||||
def test_minute_context_loads_daily_history_for_declared_strategies():
|
||||
class _FakeEngine:
|
||||
def minute_daily_history_bars(self, strategy_ids):
|
||||
return 5
|
||||
|
||||
daily = _daily("600001.SH", 6, {1})
|
||||
repo = _FakeMinuteRepo({date(2026, 8, 25): _bars("600001.SH", [(10.0, 10.2, 10.3)] * 3)})
|
||||
repo.get_enriched_history = lambda target_date, lookback_days: daily # type: ignore[method-assign]
|
||||
repo.get_instruments_asset = lambda asset_type: None # type: ignore[method-assign]
|
||||
svc = ScreenerService(repo, asset_type="stock") # type: ignore[arg-type]
|
||||
ctx = svc.build_strategy_context(
|
||||
_FakeEngine(), date(2026, 8, 25), ["m_x"], timeframe="1m",
|
||||
current=pl.DataFrame({"symbol": ["600001.SH"], "name": ["x"]}),
|
||||
)
|
||||
assert ctx.daily_history is not None
|
||||
assert ctx.daily_history.height == 6 # 引擎声明 5 → 装配日线窗口
|
||||
|
||||
|
||||
def test_minute_context_without_engine_skips_daily_history():
|
||||
svc = _svc({date(2026, 8, 25): _bars("600001.SH", [(10.0, 10.2, 10.3)] * 3)})
|
||||
ctx = svc.build_strategy_context(
|
||||
None, date(2026, 8, 25), [], timeframe="1m",
|
||||
current=pl.DataFrame({"symbol": ["600001.SH"]}),
|
||||
)
|
||||
assert ctx.daily_history is None # 无引擎声明 → 不装配日线
|
||||
|
||||
Reference in New Issue
Block a user