mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 22:34:18 +08:00
* feat(screener): 选股引擎支持 ETF - 12 个内置策略打 asset_types 白名单 + strategy_supports_asset;涨停类 (连板/断板反包)仅股票,其余 10 个技术类对 ETF 开放 - ScreenerService(repo, asset_type) 分流取数,ETF 复用 kline_etf_enriched, 跳过股票专用历史缓存与涨停信号;进程级 _history_cache key 含 asset_type - API /run、/run_preset 透传 asset_type;/strategies 按资产过滤; 股票专有策略在 ETF 下返回空 - 新增 enriched_dirname(asset_type) 共享 helper;get_enriched_latest_asset 增 refresh 参数(供轮询线程避免冷缓存同步重算) - 前端「策略」页加 股票/ETF 切换,ETF 走实时单跑(空日期→用 ETF 自身最新日); QK.screenerStrategies 按 asset_type keyed - 测试:test_screener_etf.py Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(backtest): 回测支持 ETF(个股/因子/策略组合) - 三条回测路径 + 共用 BacktestEngine 面板加载按 asset_type 路由到 kline_etf_enriched(复用 enriched_dirname);PanelCache key 隔离资产; ETF 跳过股票专用 get_enriched_range 缓存 - 面板 compute_all/名称 JOIN 按 asset_type 取维表(get_instruments_asset), 修复 ETF 策略回测用错股票维表致名称为空/涨停信号算错 - BacktestConfig/FactorConfig/StrategyBacktestConfig 增 asset_type - 三个回测 API + SSE stream 透传 asset_type;_make_job_key 纳入 asset_type (修复 stream 与 cancel job_key 不对齐致取消失效的回归) - 前端策略组合页/因子页加 股票/ETF 切换,标的搜索与策略列表跟随资产; assetType 持久化 - 测试:test_backtest_etf.py(含 job_key 一致性回归);既有回测测试替身同步 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(monitor): 监控规则支持 ETF - engine.evaluate(df, asset_type) 按规则 asset_type 分轮评估;quote_service 增开 ETF 评估轮(用 ETF enriched 快照),股票轮不受影响、不重置其策略结果 - ETF 评估轮独立 try(异常不丢弃已算出的股票告警)+ refresh=False(不在轮询 线程触发 ETF 冷缓存同步重算) - ETF 版历史加载器(main.py 注入)+ 按规则 asset_type 选加载器 - _strategy_pools 按 (sid, asset_type) 键,避免同策略股票/ETF 规则互相覆盖 - name_map 仅在有 ETF 规则时补 ETF 维表, setdefault 保股票名优先 - RuleModel/normalize 增 asset_type(默认 stock,持久化往返) - 前端 RuleEditor 加 股票/ETF 选择,策略列表与标的搜索跟随资产 - 测试:test_monitor_etf.py Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(etf): 前端 API 绑定透传 asset_type + 文档 - api.ts: screener/backtest 绑定加 assetType 参数,MonitorRule 类型加 asset_type - docs/features.md: 标注选股/回测/监控的 ETF 支持范围与前提 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reliability): 管道并发/原子写/能力探测/监控告警多处加固 后端可靠性专项修复(均带回归测试, backend 全套 64 passed): 并发与数据完整性: - 盘后管道单飞: JobStore.create() 去重纳入 pending∨running, 关闭"两次快速点击" 并发双跑窗口; 新增 _heavy_run_lock 执行槽挡住 reap 后僵尸线程并发写 parquet - adj_factor/minute 全部改走原子写(tmp+replace), 消除 kill/断电致 all.parquet 损坏 - 分块拉取失败聚合 WARNING 可见化(不再静默当成功); 复权失败标的会保持旧价已提示 能力探测: - 周期重探(60min)热更新 app.state.capabilities, 付费 Key 过期/续费无需重启即可见 - 瞬时探测失败(超时/连接/5xx, 按 _is_transient 判定)不降级、保留旧付费档; 真 401/无权限仍正常降级回落 free-api 监控告警: - 评估仅在连续竞价(9:30-11:30/13:00-15:00)+ 快照当日新鲜度下进行, 避开集合竞价/ 收盘后陈旧价与节假日误告警 - scope=sector fail-closed(validate 拒绝新建 + _apply_scope 返回空), 修复板块规则 对全市场刷屏 - 飞书 webhook 加退避重试并移到独立线程池 fire-and-forget, 不再阻塞行情轮询线程 单标的新鲜度: 新增 repo.symbols_lagging() 检测掉队标的并 WARNING + 计入 job 结果 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
424 lines
13 KiB
Python
424 lines
13 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import date, timedelta
|
||
|
||
import polars as pl
|
||
|
||
from app.backtest.engine import BacktestEngine, MatcherConfig
|
||
|
||
|
||
def _panel(symbols: list[str], days: int = 4, price: float = 10.0, overrides: dict[tuple[str, int], dict] | None = None) -> pl.DataFrame:
|
||
overrides = overrides or {}
|
||
start = date(2024, 1, 1)
|
||
rows = []
|
||
for sym in symbols:
|
||
for i in range(days):
|
||
patch = overrides.get((sym, i), {})
|
||
rows.append({
|
||
"symbol": sym,
|
||
"name": sym,
|
||
"date": start + timedelta(days=i),
|
||
"open": patch.get("open", price),
|
||
"high": patch.get("high", price),
|
||
"low": patch.get("low", price),
|
||
"close": patch.get("close", price),
|
||
"volume": patch.get("volume", 100_000),
|
||
"score": patch.get("score", {"A": 4, "B": 3, "C": 2, "D": 1}.get(sym, 0)),
|
||
"signal_limit_up": patch.get("signal_limit_up", False),
|
||
"signal_limit_down": patch.get("signal_limit_down", False),
|
||
})
|
||
return pl.DataFrame(rows).sort(["symbol", "date"])
|
||
|
||
|
||
def _mask(panel: pl.DataFrame, marks: set[tuple[str, int]]) -> pl.Series:
|
||
values = []
|
||
base = date(2024, 1, 1)
|
||
for row in panel.select(["symbol", "date"]).iter_rows(named=True):
|
||
day = (row["date"] - base).days
|
||
values.append((row["symbol"], day) in marks)
|
||
return pl.Series(values, dtype=pl.Boolean)
|
||
|
||
|
||
def _engine() -> BacktestEngine:
|
||
return BacktestEngine(repo=None) # simulate_portfolio 不访问 repo
|
||
|
||
|
||
def test_max_exposure_sets_target_position_and_caps_count():
|
||
panel = _panel(["A", "B", "C", "D"], days=3)
|
||
entries = _mask(panel, {("A", 0), ("B", 0), ("C", 0), ("D", 0)})
|
||
exits = _mask(panel, set())
|
||
|
||
result = _engine().simulate_portfolio(
|
||
panel,
|
||
entries,
|
||
exits,
|
||
MatcherConfig(
|
||
matching="open_t+1",
|
||
fees_pct=0,
|
||
slippage_bps=0,
|
||
max_positions=3,
|
||
max_exposure_pct=0.6,
|
||
initial_capital=100_000,
|
||
),
|
||
)
|
||
|
||
assert len(result.trades) == 3
|
||
assert {t.symbol for t in result.trades} == {"A", "B", "C"}
|
||
assert all(abs(t.position_pct - 0.2) < 0.001 for t in result.trades)
|
||
assert result.stats["max_exposure"] <= 0.61
|
||
|
||
|
||
def test_one_price_limit_up_blocks_buy():
|
||
panel = _panel(
|
||
["A"],
|
||
days=3,
|
||
overrides={
|
||
("A", 1): {"open": 11, "high": 11, "low": 11, "close": 11, "signal_limit_up": True},
|
||
},
|
||
)
|
||
entries = _mask(panel, {("A", 0)})
|
||
exits = _mask(panel, set())
|
||
|
||
result = _engine().simulate_portfolio(
|
||
panel,
|
||
entries,
|
||
exits,
|
||
MatcherConfig(matching="open_t+1", fees_pct=0, slippage_bps=0, max_positions=1, initial_capital=100_000),
|
||
)
|
||
|
||
assert result.trades == []
|
||
assert result.stats["execution"]["buy_limit_up"] == 1
|
||
|
||
|
||
def test_failed_open_exit_keeps_slot_and_blocks_replacement_buy():
|
||
panel = _panel(
|
||
["A", "B", "C", "D"],
|
||
days=4,
|
||
overrides={
|
||
("A", 2): {"open": 9, "high": 9, "low": 9, "close": 9, "signal_limit_down": True},
|
||
},
|
||
)
|
||
entries = _mask(panel, {
|
||
("A", 0), ("B", 0), ("C", 0),
|
||
("D", 1),
|
||
})
|
||
exits = _mask(panel, {("A", 1)})
|
||
|
||
result = _engine().simulate_portfolio(
|
||
panel,
|
||
entries,
|
||
exits,
|
||
MatcherConfig(
|
||
matching="open_t+1",
|
||
fees_pct=0,
|
||
slippage_bps=0,
|
||
max_positions=3,
|
||
max_exposure_pct=0.6,
|
||
initial_capital=100_000,
|
||
),
|
||
)
|
||
|
||
assert "D" not in {t.symbol for t in result.trades}
|
||
assert result.stats["execution"]["sell_limit_down"] == 1
|
||
assert result.stats["execution"]["pending_exit"] == 1
|
||
assert result.stats["execution"]["buy_no_slot"] >= 1
|
||
a_trade = next(t for t in result.trades if t.symbol == "A")
|
||
assert a_trade.blocked_exit_days == 1
|
||
assert a_trade.exit_reason == "signal"
|
||
|
||
|
||
def test_trailing_stop_uses_high_water_mark():
|
||
panel = _panel(
|
||
["A"],
|
||
days=5,
|
||
overrides={
|
||
("A", 2): {"open": 10, "high": 12, "low": 11.8, "close": 12},
|
||
("A", 3): {"open": 12, "high": 12, "low": 11.3, "close": 11.3},
|
||
},
|
||
)
|
||
entries = _mask(panel, {("A", 0)})
|
||
exits = _mask(panel, set())
|
||
|
||
result = _engine().simulate_portfolio(
|
||
panel,
|
||
entries,
|
||
exits,
|
||
MatcherConfig(
|
||
matching="open_t+1",
|
||
fees_pct=0,
|
||
slippage_bps=0,
|
||
max_positions=1,
|
||
initial_capital=100_000,
|
||
trailing_stop_pct=0.05,
|
||
),
|
||
)
|
||
|
||
assert len(result.trades) == 1
|
||
trade = result.trades[0]
|
||
assert trade.exit_reason == "trailing_stop"
|
||
assert trade.exit_price == 11.4
|
||
|
||
|
||
def test_trailing_take_profit_requires_activation():
|
||
panel = _panel(
|
||
["A"],
|
||
days=5,
|
||
overrides={
|
||
("A", 2): {"open": 10, "high": 10.8, "low": 10.4, "close": 10.8},
|
||
("A", 3): {"open": 10.8, "high": 10.8, "low": 10.4, "close": 10.4},
|
||
},
|
||
)
|
||
entries = _mask(panel, {("A", 0)})
|
||
exits = _mask(panel, set())
|
||
|
||
result = _engine().simulate_portfolio(
|
||
panel,
|
||
entries,
|
||
exits,
|
||
MatcherConfig(
|
||
matching="open_t+1",
|
||
fees_pct=0,
|
||
slippage_bps=0,
|
||
max_positions=1,
|
||
initial_capital=100_000,
|
||
trailing_take_profit_activate_pct=0.10,
|
||
trailing_take_profit_drawdown_pct=0.03,
|
||
),
|
||
)
|
||
|
||
assert result.trades[0].exit_reason == "end"
|
||
|
||
|
||
def test_trailing_take_profit_exits_after_activation():
|
||
panel = _panel(
|
||
["A"],
|
||
days=5,
|
||
overrides={
|
||
("A", 2): {"open": 10, "high": 12, "low": 11.8, "close": 12},
|
||
("A", 3): {"open": 12, "high": 12, "low": 11.5, "close": 11.5},
|
||
},
|
||
)
|
||
entries = _mask(panel, {("A", 0)})
|
||
exits = _mask(panel, set())
|
||
|
||
result = _engine().simulate_portfolio(
|
||
panel,
|
||
entries,
|
||
exits,
|
||
MatcherConfig(
|
||
matching="open_t+1",
|
||
fees_pct=0,
|
||
slippage_bps=0,
|
||
max_positions=1,
|
||
initial_capital=100_000,
|
||
trailing_take_profit_activate_pct=0.10,
|
||
trailing_take_profit_drawdown_pct=0.03,
|
||
),
|
||
)
|
||
|
||
assert len(result.trades) == 1
|
||
trade = result.trades[0]
|
||
assert trade.exit_reason == "trailing_take_profit"
|
||
# 纯峰值口径 (跟随 upstream): 触发线 = 峰值价 × (1 - 回撤%) = 12 × 0.97 = 11.64
|
||
assert trade.exit_price == 11.64
|
||
|
||
|
||
def test_score_filter_uses_signal_day_score_range():
|
||
panel = _panel(
|
||
["A", "B", "C"],
|
||
days=3,
|
||
overrides={
|
||
("A", 0): {"score": 70},
|
||
("B", 0): {"score": 80},
|
||
("C", 0): {"score": 90},
|
||
("A", 1): {"score": 100},
|
||
("B", 1): {"score": 1},
|
||
("C", 1): {"score": 1},
|
||
},
|
||
)
|
||
entries = _mask(panel, {("A", 0), ("B", 0), ("C", 0)})
|
||
exits = _mask(panel, set())
|
||
|
||
result = _engine().simulate_portfolio(
|
||
panel,
|
||
entries,
|
||
exits,
|
||
MatcherConfig(
|
||
matching="open_t+1",
|
||
fees_pct=0,
|
||
slippage_bps=0,
|
||
max_positions=3,
|
||
initial_capital=100_000,
|
||
score_min=71,
|
||
score_max=85,
|
||
),
|
||
)
|
||
|
||
assert {t.symbol for t in result.trades} == {"B"}
|
||
assert result.trades[0].entry_score == 80
|
||
assert result.stats["execution"]["buy_score_filter"] == 2
|
||
|
||
|
||
def test_independent_candidates_allow_overlapping_same_symbol_trades():
|
||
panel = _panel(
|
||
["A"],
|
||
days=5,
|
||
overrides={
|
||
("A", 0): {"close": 10},
|
||
("A", 1): {"close": 11},
|
||
("A", 2): {"close": 12},
|
||
("A", 3): {"close": 13},
|
||
("A", 4): {"close": 14},
|
||
},
|
||
)
|
||
entries = _mask(panel, {("A", 0), ("A", 1)})
|
||
exits = _mask(panel, set())
|
||
|
||
result = _engine().simulate_independent_candidates(
|
||
panel,
|
||
entries,
|
||
exits,
|
||
MatcherConfig(matching="close_t", fees_pct=0, slippage_bps=0, max_hold_days=2),
|
||
)
|
||
|
||
assert result.stats["full_kind"] == "candidate_execution"
|
||
assert result.stats["n_candidates"] == 2
|
||
assert len(result.trades) == 2
|
||
assert [t.entry_date for t in result.trades] == ["2024-01-01", "2024-01-02"]
|
||
assert [t.exit_date for t in result.trades] == ["2024-01-03", "2024-01-04"]
|
||
assert all(t.exit_reason == "max_hold" for t in result.trades)
|
||
|
||
|
||
def test_independent_candidates_apply_stop_loss():
|
||
panel = _panel(
|
||
["A"],
|
||
days=4,
|
||
overrides={
|
||
("A", 0): {"close": 10, "low": 10},
|
||
("A", 1): {"open": 10, "high": 10, "low": 8.9, "close": 9},
|
||
},
|
||
)
|
||
entries = _mask(panel, {("A", 0)})
|
||
exits = _mask(panel, set())
|
||
|
||
result = _engine().simulate_independent_candidates(
|
||
panel,
|
||
entries,
|
||
exits,
|
||
MatcherConfig(matching="close_t", fees_pct=0, slippage_bps=0, stop_loss_pct=0.1),
|
||
)
|
||
|
||
assert len(result.trades) == 1
|
||
assert result.trades[0].exit_reason == "stop_loss"
|
||
assert result.trades[0].exit_price == 9.0
|
||
|
||
|
||
def test_signal_exit_takes_priority_over_max_hold():
|
||
"""同一日既有卖点信号又到期 → 应按 signal 平仓 (卖点优先于 max_hold 兜底)。"""
|
||
panel = _panel(
|
||
["A"],
|
||
days=4,
|
||
overrides={
|
||
# day1 次日开盘买入 (open_t+1), 价 10
|
||
("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10},
|
||
# day2 持有 (hold_days 计到 1)
|
||
("A", 2): {"open": 11, "high": 11, "low": 11, "close": 11},
|
||
# day3: 既到期 (hold_days=2 >= max_hold_days=2) 又有卖点信号 → signal 优先
|
||
("A", 3): {"open": 12, "high": 12, "low": 12, "close": 12},
|
||
},
|
||
)
|
||
entries = _mask(panel, {("A", 0)}) # day0 收盘确认 → day1 开盘买
|
||
exits = _mask(panel, {("A", 2)}) # day2 收盘确认卖点 → day3 开盘卖
|
||
|
||
result = _engine().simulate_portfolio(
|
||
panel,
|
||
entries,
|
||
exits,
|
||
MatcherConfig(
|
||
matching="open_t+1",
|
||
fees_pct=0,
|
||
slippage_bps=0,
|
||
max_positions=1,
|
||
max_hold_days=2,
|
||
initial_capital=100_000,
|
||
),
|
||
)
|
||
|
||
assert len(result.trades) == 1
|
||
trade = result.trades[0]
|
||
assert trade.exit_reason == "signal"
|
||
assert trade.exit_price == 12.0 # 卖点用 day3 开盘 (exit_fill 跟随 matching=open_t+1)
|
||
|
||
|
||
def test_stop_loss_triggers_even_when_expired_in_open_mode():
|
||
"""open_t+1 模式下仓位到期且当日破止损 → 应按 stop_loss 平仓 (风控优先于 max_hold)。"""
|
||
panel = _panel(
|
||
["A"],
|
||
days=4,
|
||
overrides={
|
||
("A", 1): {"open": 10, "high": 10, "low": 10, "close": 10},
|
||
# day3 开盘跳空跌破止损 (-10%): open=8.9 < 9.0 止损线, low=8.5
|
||
("A", 3): {"open": 8.9, "high": 8.9, "low": 8.5, "close": 8.7},
|
||
},
|
||
)
|
||
entries = _mask(panel, {("A", 0)})
|
||
exits = _mask(panel, set())
|
||
|
||
result = _engine().simulate_portfolio(
|
||
panel,
|
||
entries,
|
||
exits,
|
||
MatcherConfig(
|
||
matching="open_t+1",
|
||
fees_pct=0,
|
||
slippage_bps=0,
|
||
max_positions=1,
|
||
max_hold_days=2,
|
||
stop_loss_pct=0.1,
|
||
initial_capital=100_000,
|
||
),
|
||
)
|
||
|
||
assert len(result.trades) == 1
|
||
trade = result.trades[0]
|
||
assert trade.exit_reason == "stop_loss"
|
||
# 风控盘中触发: 开盘价 8.9 <= 止损线 9.0 → 按开盘价 8.9 成交
|
||
assert trade.exit_price == 8.9
|
||
|
||
|
||
def test_default_fill_is_buy_open_sell_close():
|
||
"""拆分口径: 建仓=次日开盘, 清仓=收盘。entry_price 用次日 open, exit_price 用收盘价。"""
|
||
panel = _panel(
|
||
["A"],
|
||
days=4,
|
||
overrides={
|
||
# day1: 次日开盘买入, 开盘 10
|
||
("A", 1): {"open": 10, "high": 10.5, "low": 9.5, "close": 10.2},
|
||
# day2: 到期 (max_hold_days=1), 收盘卖
|
||
("A", 2): {"open": 11, "high": 11, "low": 10, "close": 10.8},
|
||
},
|
||
)
|
||
entries = _mask(panel, {("A", 0)}) # day0 收盘确认
|
||
exits = _mask(panel, set())
|
||
|
||
result = _engine().simulate_portfolio(
|
||
panel,
|
||
entries,
|
||
exits,
|
||
MatcherConfig(
|
||
entry_fill="open_t+1",
|
||
exit_fill="close_t",
|
||
fees_pct=0,
|
||
slippage_bps=0,
|
||
max_positions=1,
|
||
max_hold_days=1,
|
||
initial_capital=100_000,
|
||
),
|
||
)
|
||
|
||
assert len(result.trades) == 1
|
||
trade = result.trades[0]
|
||
assert trade.entry_price == 10.0 # 次日开盘
|
||
assert trade.exit_price == 10.8 # 到期日收盘
|
||
assert trade.exit_reason == "max_hold"
|