mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
一期 · 分钟策略执行链路: - 引擎新增 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 通过; 浏览器端到端实测通过
193 lines
6.7 KiB
Python
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]}),
|
|
),
|
|
)
|