llm-quant/tests/test_backtest_engine_risk.py
2025-09-30 18:08:15 +08:00

133 lines
3.9 KiB
Python

"""Tests for BacktestEngine risk-aware execution."""
from __future__ import annotations
from datetime import date
import pytest
from app.agents.base import AgentAction, AgentContext
from app.agents.game import Decision
from app.backtest.engine import BacktestEngine, BacktestResult, BtConfig, PortfolioState
def _make_context(price: float, features: dict | None = None) -> AgentContext:
scope_values = {"daily.close": price}
return AgentContext(
ts_code="000001.SZ",
trade_date="2025-01-10",
features=features or {},
market_snapshot={},
raw={"scope_values": scope_values},
)
def _make_decision(action: AgentAction, target_weight: float = 0.0) -> Decision:
return Decision(
action=action,
confidence=0.8,
target_weight=target_weight,
feasible_actions=[],
utilities={},
)
def _engine_with_params(params: dict[str, float]) -> BacktestEngine:
cfg = BtConfig(
id="test",
name="test",
start_date=date(2025, 1, 10),
end_date=date(2025, 1, 10),
universe=["000001.SZ"],
params=params,
)
return BacktestEngine(cfg)
def test_buy_respects_risk_caps():
engine = _engine_with_params(
{
"max_position_weight": 0.2,
"fee_rate": 0.0,
"slippage_bps": 0.0,
"max_daily_turnover_ratio": 1.0,
}
)
state = PortfolioState(cash=100_000.0)
result = BacktestResult()
features = {
"liquidity_score": 0.7,
"risk_penalty": 0.25,
}
context = _make_context(100.0, features)
decision = _make_decision(AgentAction.BUY_L, target_weight=0.5)
engine._apply_portfolio_updates(
date(2025, 1, 10),
state,
[("000001.SZ", context, decision)],
result,
)
expected_qty = (100_000.0 * 0.2 * (1 - 0.25)) / 100.0
assert state.holdings["000001.SZ"] == pytest.approx(expected_qty)
assert state.cash == pytest.approx(100_000.0 - expected_qty * 100.0)
assert result.trades and result.trades[0]["status"] == "executed"
assert result.nav_series[0]["turnover"] == pytest.approx(expected_qty * 100.0)
def test_buy_blocked_by_limit_up_records_risk():
engine = _engine_with_params({})
state = PortfolioState(cash=50_000.0)
result = BacktestResult()
features = {"limit_up": True}
context = _make_context(100.0, features)
decision = _make_decision(AgentAction.BUY_M, target_weight=0.1)
engine._apply_portfolio_updates(
date(2025, 1, 10),
state,
[("000001.SZ", context, decision)],
result,
)
assert "000001.SZ" not in state.holdings
assert not result.trades
assert result.risk_events
assert result.risk_events[0]["reason"] == "limit_up"
def test_sell_applies_slippage_and_fee():
engine = _engine_with_params(
{
"max_position_weight": 0.3,
"fee_rate": 0.001,
"slippage_bps": 20.0,
"max_daily_turnover_ratio": 1.0,
}
)
state = PortfolioState(
cash=0.0,
holdings={"000001.SZ": 100.0},
cost_basis={"000001.SZ": 90.0},
opened_dates={"000001.SZ": "2024-12-01"},
)
result = BacktestResult()
context = _make_context(100.0, {})
decision = _make_decision(AgentAction.SELL)
engine._apply_portfolio_updates(
date(2025, 1, 10),
state,
[("000001.SZ", context, decision)],
result,
)
trade = result.trades[0]
assert pytest.approx(trade["price"], rel=1e-6) == 100.0 * (1 - 0.002)
assert pytest.approx(trade["fee"], rel=1e-6) == trade["value"] * 0.001
assert state.cash == pytest.approx(trade["value"] - trade["fee"])
assert state.realized_pnl == pytest.approx((trade["price"] - 90.0) * 100 - trade["fee"])
assert not state.holdings
assert result.nav_series[0]["turnover"] == pytest.approx(trade["value"])
assert not result.risk_events