@@ -0,0 +1,422 @@
|
||||
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"
|
||||
assert trade.exit_price == 11.7
|
||||
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,59 @@
|
||||
"""全量模拟 (full mode) 尾部执行回归测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.backtest.engine import BacktestEngine, MatcherConfig
|
||||
|
||||
|
||||
def _panel_with_tail(symbols: list[str], n_data_days: int) -> pl.DataFrame:
|
||||
start = date(2024, 1, 1)
|
||||
rows = []
|
||||
for sym in symbols:
|
||||
for i in range(n_data_days):
|
||||
px = 10.0 + i
|
||||
rows.append({
|
||||
"symbol": sym,
|
||||
"date": start + timedelta(days=i),
|
||||
"open": px,
|
||||
"high": px,
|
||||
"low": px,
|
||||
"close": px,
|
||||
"volume": 100_000,
|
||||
"signal_limit_up": False,
|
||||
"signal_limit_down": False,
|
||||
})
|
||||
return pl.DataFrame(rows).sort(["symbol", "date"])
|
||||
|
||||
|
||||
def test_full_simulation_executes_signal_at_tail():
|
||||
"""信号集中在正式区间最后一天时, tail 数据应允许次日开盘买入并按策略退出。"""
|
||||
n_days = 6
|
||||
panel = _panel_with_tail(["A"], n_days + 3)
|
||||
|
||||
start = date(2024, 1, 1)
|
||||
end = start + timedelta(days=n_days - 1)
|
||||
entry_vals = []
|
||||
for row in panel.select(["symbol", "date"]).iter_rows(named=True):
|
||||
entry_vals.append(row["date"] == end)
|
||||
entry_mask = pl.Series(entry_vals, dtype=pl.Boolean)
|
||||
exit_mask = pl.Series([False] * len(panel), dtype=pl.Boolean)
|
||||
|
||||
result = BacktestEngine(repo=None).simulate_independent_candidates( # type: ignore[arg-type]
|
||||
panel,
|
||||
entry_mask,
|
||||
exit_mask,
|
||||
MatcherConfig(matching="open_t+1", fees_pct=0, slippage_bps=0, max_hold_days=2),
|
||||
)
|
||||
|
||||
assert not result.stats.get("error"), f"unexpected error: {result.stats.get('error')}"
|
||||
assert result.stats.get("full_kind") == "candidate_execution"
|
||||
assert result.stats.get("n_candidates") == 1
|
||||
assert result.stats.get("n_trades") == 1
|
||||
assert len(result.trades) == 1
|
||||
trade = result.trades[0]
|
||||
assert trade.entry_signal_date == str(end)
|
||||
assert trade.entry_date == str(end + timedelta(days=1))
|
||||
assert trade.exit_reason == "max_hold"
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.backtest.engine import BacktestEngine, SimResult
|
||||
from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService
|
||||
from app.strategy.engine import StrategyDef
|
||||
|
||||
|
||||
def _strategy(**kwargs) -> StrategyDef:
|
||||
defaults = dict(
|
||||
meta={"id": "test", "name": "test", "scoring": {}, "params": [], "limit": 100},
|
||||
basic_filter={"enabled": True, "amount_min": 100.0},
|
||||
entry_signals=[],
|
||||
exit_signals=[],
|
||||
stop_loss=None,
|
||||
trailing_stop=None,
|
||||
trailing_take_profit_activate=None,
|
||||
trailing_take_profit_drawdown=None,
|
||||
max_hold_days=None,
|
||||
alerts=[],
|
||||
filter_fn=lambda df, params: pl.lit(True),
|
||||
filter_history_fn=None,
|
||||
lookback_days=1,
|
||||
source="custom",
|
||||
file_path=None,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return StrategyDef(**defaults)
|
||||
|
||||
|
||||
class _StrategyEngineStub:
|
||||
def __init__(self, strategy: StrategyDef) -> None:
|
||||
self.strategy = strategy
|
||||
|
||||
def get(self, strategy_id: str) -> StrategyDef:
|
||||
return self.strategy
|
||||
|
||||
|
||||
class _RepoStub:
|
||||
def get_index_daily(self, *args, **kwargs) -> pl.DataFrame:
|
||||
return pl.DataFrame()
|
||||
|
||||
|
||||
class _EngineStub:
|
||||
def __init__(self, panel: pl.DataFrame) -> None:
|
||||
self.panel = panel
|
||||
self.repo = _RepoStub()
|
||||
self.load_args = None
|
||||
self.sim_panel: pl.DataFrame | None = None
|
||||
self.sim_entries: pl.Series | None = None
|
||||
|
||||
def load_panel(self, symbols, start: date, end: date) -> pl.DataFrame:
|
||||
self.load_args = (symbols, start, end)
|
||||
return self.panel
|
||||
|
||||
def simulate_portfolio(self, panel, entries, exits, config, progress_cb=None, cancel_event=None) -> SimResult:
|
||||
self.sim_panel = panel
|
||||
self.sim_entries = entries
|
||||
return SimResult(
|
||||
equity_curve=[{"date": "2024-01-01", "value": config.initial_capital}],
|
||||
drawdown_curve=[{"date": "2024-01-01", "value": 0.0}],
|
||||
trades=[],
|
||||
per_symbol_stats=[],
|
||||
stats={"total_return": 0.0, "n_trades": 0},
|
||||
)
|
||||
|
||||
|
||||
def test_basic_filter_only_limits_entries_not_panel_rows():
|
||||
start = date(2024, 1, 1)
|
||||
rows = []
|
||||
for i, amount in enumerate([1000.0, 0.0, 1000.0]):
|
||||
rows.append({
|
||||
"symbol": "A",
|
||||
"name": "A",
|
||||
"date": start + timedelta(days=i),
|
||||
"open": 10.0 + i,
|
||||
"high": 10.0 + i,
|
||||
"low": 10.0 + i,
|
||||
"close": 10.0 + i,
|
||||
"volume": 100_000,
|
||||
"amount": amount,
|
||||
"signal_limit_up": False,
|
||||
"signal_limit_down": False,
|
||||
})
|
||||
panel = pl.DataFrame(rows).sort(["symbol", "date"])
|
||||
engine = _EngineStub(panel)
|
||||
service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(_strategy()))
|
||||
|
||||
result = service.run(StrategyBacktestConfig(
|
||||
strategy_id="test",
|
||||
symbols=None,
|
||||
start=start,
|
||||
end=start + timedelta(days=2),
|
||||
matching="close_t",
|
||||
mode="position",
|
||||
))
|
||||
|
||||
assert result.error is None
|
||||
assert engine.sim_panel is not None
|
||||
assert engine.sim_panel.height == 3
|
||||
assert engine.sim_panel.filter(pl.col("amount") == 0.0).height == 1
|
||||
assert engine.sim_entries is not None
|
||||
assert engine.sim_entries.to_list() == [True, False, True]
|
||||
assert engine.load_args is not None
|
||||
assert engine.load_args[1] < start # warmup 只用于计算, 不参与正式交易
|
||||
|
||||
|
||||
def test_score_normalizes_inside_strategy_candidate_universe():
|
||||
panel = pl.DataFrame({
|
||||
"symbol": ["A", "B", "C"],
|
||||
"date": [date(2024, 1, 1)] * 3,
|
||||
"factor": [10.0, 20.0, 1000.0],
|
||||
})
|
||||
universe = pl.Series([True, True, False], dtype=pl.Boolean)
|
||||
strategy = SimpleNamespace(meta={"scoring": {"factor": 1.0}, "order_by": "score", "descending": True})
|
||||
|
||||
scored = StrategyBacktestService._apply_score(panel, strategy, None, universe_mask=universe)
|
||||
scores = dict(zip(scored["symbol"].to_list(), scored["score"].to_list()))
|
||||
|
||||
assert scores["A"] == 0.0
|
||||
assert scores["B"] == 100.0
|
||||
assert scores["C"] == 0.0
|
||||
|
||||
|
||||
def test_full_mode_executes_every_candidate_with_strategy_rules():
|
||||
start = date(2024, 1, 1)
|
||||
panel = pl.DataFrame([
|
||||
{"symbol": "A", "name": "A", "date": start, "open": 10.0, "high": 10.0, "low": 10.0, "close": 10.0, "volume": 1, "amount": 1000.0, "signal_limit_up": False, "signal_limit_down": False},
|
||||
{"symbol": "A", "name": "A", "date": start + timedelta(days=1), "open": 11.0, "high": 11.0, "low": 11.0, "close": 11.0, "volume": 1, "amount": 0.0, "signal_limit_up": False, "signal_limit_down": False},
|
||||
{"symbol": "A", "name": "A", "date": start + timedelta(days=2), "open": 20.0, "high": 20.0, "low": 20.0, "close": 20.0, "volume": 1, "amount": 1000.0, "signal_limit_up": False, "signal_limit_down": False},
|
||||
]).sort(["symbol", "date"])
|
||||
|
||||
engine = BacktestEngine(repo=None) # type: ignore[arg-type]
|
||||
engine.load_panel = lambda symbols, s, e: panel # type: ignore[method-assign]
|
||||
strategy = _strategy(
|
||||
filter_fn=lambda df, params: pl.col("date") == start,
|
||||
max_hold_days=1,
|
||||
)
|
||||
service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(strategy))
|
||||
|
||||
result = service.run(StrategyBacktestConfig(
|
||||
strategy_id="test",
|
||||
symbols=None,
|
||||
start=start,
|
||||
end=start,
|
||||
mode="full",
|
||||
matching="open_t+1",
|
||||
fees_pct=0,
|
||||
slippage_bps=0,
|
||||
holding_days=1,
|
||||
))
|
||||
|
||||
assert result.error is None
|
||||
assert result.stats["full_kind"] == "candidate_execution"
|
||||
assert result.stats["n_candidates"] == 1
|
||||
assert result.stats["n_trades"] == 1
|
||||
assert result.trades[0]["entry_date"] == str(start + timedelta(days=1))
|
||||
assert result.trades[0]["exit_reason"] == "max_hold"
|
||||
assert result.stats["avg_return"] == round(20 / 11 - 1, 4)
|
||||
Reference in New Issue
Block a user