Files
tick-stock-panel/backend/tests/test_screener_etf.py
T
shy3130 5e2358ce48 feat(minute): 分钟策略执行后端 + 分钟红7策略 + 盘中增量落盘 (Expert)
一期 · 分钟策略执行链路:
- 引擎新增 minute_filter 执行后端: 策略声明 filter_minute_history(df, params),
  timeframes 必须且仅为 ["1m"]; 输入为当日分钟K窗口, 命中行事后联表 enriched
  快照补基础过滤列 (name/total_shares/change_pct), close 用最新分钟价
- 内置策略「分钟红7」(minute_red_streak): 最近 N 根(默认7)分钟K至少 5 根
  close>open, 且按最高价排序的最高的 2 根全红; 全向量化, 671k 行约 230ms;
  参数 bars/min_red/top_red/rank_by_close, 不足 N 根不触发, 同值取更晚K线
- ScreenerService 1m context: 优先读 as_of 当日 kline_minute 分区, 缺失回退
  全市场最近分区; 单分区直读与全量 glob 解耦
- run_preset/run_all 分钟周期结果不写日线盘后缓存 (语义隔离)

二期 · 盘中分钟增量落盘 (Expert 专有):
- kline_sync.fetch_intraday_full_market_burst: intraday.batch 独立限流池,
  全市场 5546/200=28 块线程池一次打出, 轮内不重试
- MinuteRefreshService: 后台线程, 门控链 = 开关→自定义分钟源让位→INTRADAY_BATCH
  能力→连续竞价时段(9:30-11:30/13:00-15:00); 固定节奏 next=max(起点+间隔,完成),
  不补跑; 每轮单次合并落盘 (_write_minute_partition unique 幂等)
- 偏好 minute_refresh_enabled(默认关)/minute_refresh_interval([60,300]s 默认60);
  GET /api/settings/minute-refresh/status 状态端点

前端:
- 策略页日线/分钟周期切换 (1m 下 ETF 置灰、不触发盘后 runAll、prune 仅日线),
  策略卡片「分钟」徽章, 策略池对话框按周期拉取
- 数据页分钟K设置弹窗新增盘中增量区块: 开关/间隔(60-300s)/能力缺失置灰/
  服务状态行(运行中·时段暂停·最近一轮)

测试: 26 项新增 (形态7/引擎4/context4/服务11), 更新 3 个 matrix 不变量测试;
全量 1112 passed; 前端 build 通过; 浏览器端到端实测通过
2026-08-30 19:05:05 +08:00

193 lines
6.7 KiB
Python

from __future__ import annotations
import types
from datetime import date, timedelta
from pathlib import Path
import polars as pl
import pytest
from app.services.screener import ScreenerService
from app.strategy.engine import StrategyDataContext, StrategyEngine
BUILTIN_DIR = Path(__file__).resolve().parents[1] / "app" / "strategy" / "builtin"
class _FakeRepo:
"""最小 repo 桩: 只实现 screener 数据上下文需要的资产取数接口。"""
def __init__(self, data_dir, enriched=None, instruments=None, latest=None):
self.store = types.SimpleNamespace(data_dir=data_dir)
self._enriched = enriched if enriched is not None else pl.DataFrame()
self._instruments = instruments if instruments is not None else pl.DataFrame()
self._latest = latest
def get_enriched_latest_asset(self, asset_type):
return self._enriched, self._latest
def get_instruments_asset(self, asset_type):
return self._instruments
def get_enriched_history(self, target_date, lookback_days):
return None
def _engine() -> StrategyEngine:
return StrategyEngine(strategy_dirs=[BUILTIN_DIR])
def test_all_builtin_strategies_declare_asset_types_and_timeframes():
engine = _engine()
assert engine.load_errors() == []
for meta in engine.list_strategies():
assert meta["asset_types"]
# 分钟策略 timeframes 为 ["1m"], 日线内置策略为 ["1d"]
assert meta["timeframes"] in (["1d"], ["1m"])
def test_all_builtin_strategies_use_matrix_backend_only():
engine = _engine()
assert engine.load_errors() == []
strategies = [engine.get(meta["id"]) for meta in engine.list_strategies()]
matrix_strategies = [s for s in strategies if s.execution_backend == "matrix_native"]
assert len(matrix_strategies) == 18
assert all(s.matrix_strategy is not None for s in matrix_strategies)
assert all(s.filter_fn is None for s in matrix_strategies)
assert all(s.filter_history_fn is None for s in matrix_strategies)
# 分钟形态策略 (minute_filter) 不参与日线矩阵不变量
assert [s.meta["id"] for s in strategies if s.execution_backend == "minute_filter"] == [
"minute_red_streak"
]
def test_all_builtin_matrix_formulas_accept_base_market_matrix():
rows = []
start = date(2024, 1, 1)
for offset in range(80):
close = 10.0 + offset * 0.04
rows.append({
"symbol": "000001.SZ",
"name": "测试股票",
"date": start + timedelta(days=offset),
"open": close - 0.05,
"high": close + 0.15,
"low": close - 0.15,
"close": close,
"volume": 1000.0 + offset * 5.0,
"amount": 100000.0,
"raw_close": close,
"turnover_rate": 5.0,
"consecutive_limit_ups": 0,
})
panel = pl.DataFrame(rows)
engine = _engine()
from app.backtest.matrix import build_market_data_matrix
fields = set()
matrix_metas = [
m for m in engine.list_strategies()
if engine.get(m["id"]).execution_backend == "matrix_native"
]
for strategy in (engine.get(meta["id"]) for meta in matrix_metas):
fields.update(engine._matrix_field_columns(strategy))
market = build_market_data_matrix(panel, field_columns=fields)
for meta in matrix_metas:
strategy = engine.get(meta["id"])
signals = strategy.matrix_strategy.compute_signals(market, {})
assert signals.shape == market.shape, meta["id"]
def test_limit_up_strategies_are_stock_only():
engine = _engine()
for sid in ("broken_board_recovery", "consecutive_limit_ups"):
assert engine.get(sid).meta["asset_types"] == ["stock"]
def test_pure_technical_strategies_support_etf():
engine = _engine()
for sid in (
"trend_breakout", "ma_golden_cross", "macd_golden",
"volume_price_surge", "low_volatility_leader", "oversold_bounce",
"boll_breakout", "bullish_alignment", "pullback_to_support",
"n_day_low_reversal",
):
assert "etf" in engine.get(sid).meta["asset_types"], sid
def test_custom_strategy_defaults_to_stock_and_daily(tmp_path):
path = tmp_path / "custom_default.py"
path.write_text(
'import polars as pl\n'
'META = {"id": "custom_default", "name": "x"}\n'
'def filter(df, params):\n return pl.lit(True)\n',
encoding="utf-8",
)
strategy = StrategyEngine._load_file(path)
assert strategy.meta["asset_types"] == ["stock"]
assert strategy.meta["timeframes"] == ["1d"]
def test_service_defaults_to_stock_dir(tmp_path):
svc = ScreenerService(_FakeRepo(tmp_path))
assert svc.asset_type == "stock"
assert svc._enriched_dirname == "kline_daily_enriched"
def test_service_etf_uses_etf_dir(tmp_path):
svc = ScreenerService(_FakeRepo(tmp_path), asset_type="etf")
assert svc.asset_type == "etf"
assert svc._enriched_dirname == "kline_etf_enriched"
def test_etf_strategy_runs_through_engine_context(tmp_path):
rows = []
for offset in range(61):
trade_date = date(2025, 11, 3) + timedelta(days=offset)
leader_close = 3.0 + offset / 60.0
weak_close = 3.0 - offset / 60.0
rows.extend([
{
"symbol": "510300", "name": "沪深300ETF", "date": trade_date,
"open": leader_close - 0.01, "high": leader_close + 0.01,
"low": leader_close - 0.02, "close": leader_close,
"volume": 300.0 if offset == 60 else 100.0,
},
{
"symbol": "159915", "name": "创业板ETF", "date": trade_date,
"open": weak_close + 0.01, "high": weak_close + 0.02,
"low": weak_close - 0.01, "close": weak_close,
"volume": 50.0 if offset == 60 else 100.0,
},
])
history = pl.DataFrame(rows)
target_date = history["date"].max()
current = history.filter(pl.col("date") == target_date)
engine = _engine()
result = engine.run(
"trend_breakout",
StrategyDataContext(
asset_type="etf",
timeframe="1d",
as_of=target_date,
current=current,
history=history,
),
overrides={"basic_filter": {"enabled": False}},
)
assert result.total == 1
assert result.rows[0]["symbol"] == "510300"
def test_stock_only_strategy_on_etf_fails_explicitly():
engine = _engine()
with pytest.raises(ValueError, match="does not support asset_type"):
engine.run(
"consecutive_limit_ups",
StrategyDataContext(
asset_type="etf",
timeframe="1d",
as_of=date(2026, 1, 2),
current=pl.DataFrame({"symbol": ["510300"], "close": [4.0]}),
),
)