feat: add backtesting functionality with UI and API endpoints
CI / lint-test-build (push) Successful in 2m31s
CI / lint-test-build (push) Successful in 2m31s
- Introduced backtesting page and fragment in the dashboard for running backtests and viewing recent reports. - Implemented backtest run logic with configuration options including event path, starting balances, trade capital, and fee profiles. - Added recent backtest reports storage and retrieval. - Created a new strategy module for statistical arbitrage experiments with validation on configuration parameters. - Updated settings to include parameters for the statistical arbitrage strategy. - Enhanced dashboard controls to support the new strategy mode. - Added unit tests for backtesting functionality and strategy validation. - Updated templates for backtesting UI integration.
This commit is contained in:
+63
-6
@@ -191,7 +191,8 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
|
||||
assert "trade-open" in overview.text
|
||||
|
||||
assert overview_stream.status_code == 200
|
||||
assert overview_stream.headers["content-type"].startswith("text/event-stream")
|
||||
assert overview_stream.headers["content-type"].startswith(
|
||||
"text/event-stream")
|
||||
assert "event: overview" in overview_stream.text
|
||||
assert "trade-open" in overview_stream.text
|
||||
|
||||
@@ -261,7 +262,8 @@ async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> N
|
||||
assert app.state.settings.max_trade_capital_usd == 300.0
|
||||
assert app.state.settings.max_concurrent_trades == 4
|
||||
assert app.state.settings.paper_trading_mode is True
|
||||
assert app.state.dashboard_controls.tradable_pairs == ["BTC/USD", "ETH/BTC"]
|
||||
assert app.state.dashboard_controls.tradable_pairs == [
|
||||
"BTC/USD", "ETH/BTC"]
|
||||
assert app.state.dashboard_controls.strategy_mode == "paper"
|
||||
assert app.state.dashboard_controls.strategy_profit_threshold == 0.0025
|
||||
assert app.state.dashboard_controls.strategy_max_depth_levels == 7
|
||||
@@ -273,10 +275,14 @@ async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> N
|
||||
assert audit_recent.status_code == 200
|
||||
entries = audit_recent.json()["entries"]
|
||||
assert len(entries) >= 4
|
||||
assert any(entry["event_type"] == "dashboard.control.stop" for entry in entries)
|
||||
assert any(entry["event_type"] == "dashboard.control.start" for entry in entries)
|
||||
assert any(entry["event_type"] == "dashboard.control.kill_switch" for entry in entries)
|
||||
assert any(entry["event_type"] == "dashboard.control.config" for entry in entries)
|
||||
assert any(entry["event_type"] ==
|
||||
"dashboard.control.stop" for entry in entries)
|
||||
assert any(entry["event_type"] ==
|
||||
"dashboard.control.start" for entry in entries)
|
||||
assert any(entry["event_type"] ==
|
||||
"dashboard.control.kill_switch" for entry in entries)
|
||||
assert any(entry["event_type"] ==
|
||||
"dashboard.control.config" for entry in entries)
|
||||
|
||||
|
||||
async def test_dashboard_controls_emit_alerts(tmp_path) -> None:
|
||||
@@ -333,3 +339,54 @@ async def test_dashboard_alert_status_api_exposes_notifier_snapshot(tmp_path) ->
|
||||
assert payload["enabled"] is True
|
||||
assert "configured_channels" in payload
|
||||
assert "last_result" in payload
|
||||
|
||||
|
||||
async def test_backtesting_page_run_and_recent_reports_api(tmp_path) -> None:
|
||||
app = create_app(Settings(DUCKDB_PATH=tmp_path / "backtesting-ui.duckdb"))
|
||||
|
||||
events_file = tmp_path / "replay.jsonl"
|
||||
events_file.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
'{"timestamp":"2026-06-01T12:00:00Z","symbol":"BTC/USD","bids":[[99.5,10.0]],"asks":[[100.0,10.0]]}',
|
||||
'{"timestamp":"2026-06-01T12:00:01Z","symbol":"ETH/BTC","bids":[[0.051,10.0]],"asks":[[0.050,10.0]]}',
|
||||
'{"timestamp":"2026-06-01T12:00:02Z","symbol":"ETH/USD","bids":[[110.0,10.0]],"asks":[[110.5,10.0]]}',
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
page = await client.get("/dashboard/backtesting")
|
||||
fragment = await client.get("/dashboard/fragment/backtesting")
|
||||
run = await client.post(
|
||||
"/dashboard/backtesting/run",
|
||||
data={
|
||||
"events_path": str(events_file),
|
||||
"starting_balances": "USD=1000.0",
|
||||
"trade_capital": "100.0",
|
||||
"min_profit_threshold": "0.0005",
|
||||
"fee_profile": "standard",
|
||||
"slippage_bps": "4.0",
|
||||
"execution_latency_ms": "20.0",
|
||||
},
|
||||
)
|
||||
reports = await client.get("/dashboard/api/backtesting/reports")
|
||||
|
||||
assert page.status_code == 200
|
||||
assert "Backtesting" in page.text
|
||||
assert "/dashboard/fragment/backtesting" in page.text
|
||||
|
||||
assert fragment.status_code == 200
|
||||
assert "Run Backtest" in fragment.text
|
||||
assert "Recent Runs" in fragment.text
|
||||
|
||||
assert run.status_code == 200
|
||||
assert "completed" in run.text
|
||||
assert "Processed:" in run.text
|
||||
|
||||
assert reports.status_code == 200
|
||||
payload = reports.json()
|
||||
assert len(payload["reports"]) >= 1
|
||||
assert payload["reports"][0]["status"] == "completed"
|
||||
|
||||
@@ -53,3 +53,20 @@ def test_valid_security_configuration_passes() -> None:
|
||||
)
|
||||
|
||||
assert settings.kraken_api_key_permissions == "query,trade"
|
||||
|
||||
|
||||
def test_stat_arb_entry_zscore_must_exceed_exit_zscore() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(
|
||||
_env_file=None,
|
||||
STRATEGY_STAT_ARB_ENTRY_ZSCORE="0.5",
|
||||
STRATEGY_STAT_ARB_EXIT_ZSCORE="0.5",
|
||||
)
|
||||
|
||||
|
||||
def test_stat_arb_lookback_window_must_be_at_least_two() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(
|
||||
_env_file=None,
|
||||
STRATEGY_STAT_ARB_LOOKBACK_WINDOW="1",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from arbitrade.strategy.stat_arb import StatArbExperiment, StatArbExperimentConfig
|
||||
|
||||
|
||||
def test_stat_arb_experiment_warmup_then_entry_and_exit() -> None:
|
||||
started_at = datetime(2026, 6, 2, 12, 0, tzinfo=UTC)
|
||||
experiment = StatArbExperiment(
|
||||
StatArbExperimentConfig(
|
||||
pair_a="BTC/USD",
|
||||
pair_b="ETH/USD",
|
||||
lookback_window=5,
|
||||
entry_zscore=1.5,
|
||||
exit_zscore=0.2,
|
||||
max_holding_seconds=0.5,
|
||||
)
|
||||
)
|
||||
|
||||
# Warmup with nearly stationary spread around 0.
|
||||
for idx in range(5):
|
||||
signal = experiment.observe(
|
||||
price_a=100.0 + (0.02 * idx),
|
||||
price_b=100.0,
|
||||
observed_at=started_at + timedelta(seconds=idx),
|
||||
)
|
||||
|
||||
assert signal.action in {"warmup", "hold"}
|
||||
|
||||
# Large positive spread should trigger short-spread entry.
|
||||
entry = experiment.observe(
|
||||
price_a=104.0,
|
||||
price_b=100.0,
|
||||
observed_at=started_at + timedelta(seconds=10),
|
||||
)
|
||||
assert entry.action == "enter_short_spread"
|
||||
assert entry.position == "short"
|
||||
assert entry.zscore is not None
|
||||
|
||||
# Mean reversion toward center should trigger exit.
|
||||
exit_signal = experiment.observe(
|
||||
price_a=100.05,
|
||||
price_b=100.0,
|
||||
observed_at=started_at + timedelta(seconds=11),
|
||||
)
|
||||
assert exit_signal.action == "exit_position"
|
||||
assert exit_signal.position == "flat"
|
||||
|
||||
|
||||
def test_stat_arb_experiment_rejects_invalid_prices() -> None:
|
||||
experiment = StatArbExperiment(
|
||||
StatArbExperimentConfig(
|
||||
pair_a="BTC/USD",
|
||||
pair_b="ETH/USD",
|
||||
lookback_window=5,
|
||||
)
|
||||
)
|
||||
|
||||
at = datetime(2026, 6, 2, 12, 0, tzinfo=UTC)
|
||||
try:
|
||||
experiment.observe(price_a=0.0, price_b=100.0, observed_at=at)
|
||||
except ValueError as exc:
|
||||
assert "prices must be > 0" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Expected ValueError for non-positive price")
|
||||
Reference in New Issue
Block a user