feat: add backtesting functionality with UI and API endpoints
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:
2026-06-02 09:28:22 +02:00
parent f612c8533a
commit 38e1d64437
17 changed files with 1089 additions and 165 deletions
+63 -6
View File
@@ -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"
+17
View File
@@ -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",
)
+66
View File
@@ -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")