Files
arbitrade/tests/test_dashboard.py
T
zwitschi 38e1d64437
CI / lint-test-build (push) Successful in 2m31s
feat: add backtesting functionality with UI and API endpoints
- 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.
2026-06-02 09:28:22 +02:00

393 lines
14 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
from arbitrade.api.app import create_app
from arbitrade.config.settings import Settings
class _FakeAlertNotifier:
def __init__(self) -> None:
self.events: list[dict[str, Any]] = []
async def notify(
self,
*,
category: str,
severity: str,
title: str,
message: str,
details: dict[str, str] | None = None,
) -> bool:
self.events.append(
{
"category": category,
"severity": severity,
"title": title,
"message": message,
"details": details or {},
}
)
return True
def _seed_metrics_data(app) -> None:
store = app.state.store
started = datetime.now(UTC)
finished = started + timedelta(seconds=20)
with store.connect() as conn:
conn.execute(
"""
INSERT INTO trades (
trade_ref,
started_at,
finished_at,
status,
realized_pnl,
estimated_pnl,
capital_used,
cycle,
leg_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
"trade-1",
started,
finished,
"filled",
15.0,
10.0,
100.0,
"USD->BTC->ETH->USD",
3,
],
)
conn.execute(
"""
INSERT INTO opportunities (
detected_at,
cycle,
gross_pct,
net_pct,
est_profit,
executed
) VALUES (?, ?, ?, ?, ?, ?)
""",
[started, "USD->BTC->ETH->USD", 4.0, 3.0, 0.03, True],
)
conn.execute(
"""
INSERT INTO orders (
trade_ref,
order_ref,
leg_index,
pair,
side,
volume,
user_ref,
status,
filled_volume,
avg_price,
raw_response,
recorded_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
"trade-1",
"order-1",
0,
"BTC/USD",
"buy",
2.0,
100,
"closed",
2.0,
100.0,
"{}",
started,
],
)
conn.execute(
"""
INSERT INTO portfolio_snapshots (
snapshot_at,
balances,
total_value_usd
) VALUES (?, ?, ?)
""",
[started, '{"USD": 1000.0, "BTC": 0.25}', 1250.0],
)
conn.execute(
"""
INSERT INTO trades (
trade_ref,
started_at,
finished_at,
status,
realized_pnl,
estimated_pnl,
capital_used,
cycle,
leg_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
"trade-open",
started,
None,
"open",
None,
5.0,
50.0,
"USD->BTC->ETH->USD",
3,
],
)
async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
app = create_app(Settings(DUCKDB_PATH=tmp_path / "dash.duckdb"))
_seed_metrics_data(app)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
page = await client.get("/")
fragment = await client.get("/dashboard/fragment/metrics")
stream = await client.get("/dashboard/stream/metrics")
overview = await client.get("/dashboard/fragment/overview")
overview_stream = await client.get("/dashboard/stream/overview")
controls = await client.get("/dashboard/fragment/controls")
charts = await client.get("/dashboard/fragment/charts")
audit = await client.get("/dashboard/fragment/audit")
assert page.status_code == 200
assert "EventSource" in page.text
assert "alpinejs" in page.text.lower()
assert "Chart.js" in page.text or "chart.umd.min.js" in page.text
assert 'hx-get="/dashboard/fragment/metrics"' in page.text
assert 'hx-get="/dashboard/fragment/controls"' in page.text
assert 'hx-get="/dashboard/fragment/charts"' in page.text
assert 'hx-get="/dashboard/fragment/audit"' in page.text
assert fragment.status_code == 200
assert "Realized P&L" in fragment.text
assert "15.00 USD" in fragment.text
assert "100.0%" in fragment.text
assert stream.status_code == 200
assert stream.headers["content-type"].startswith("text/event-stream")
assert "event: metrics" in stream.text
assert "Realized P&L" in stream.text
assert overview.status_code == 200
assert "live" in overview.text
assert "Balances Snapshot" in overview.text
assert "Open Trades" in overview.text
assert "Opportunity Feed" in overview.text
assert "1250.00 USD" in overview.text
assert "trade-open" in overview.text
assert overview_stream.status_code == 200
assert overview_stream.headers["content-type"].startswith(
"text/event-stream")
assert "event: overview" in overview_stream.text
assert "trade-open" in overview_stream.text
assert controls.status_code == 200
assert "Runtime Status" in controls.text
assert ">running<" in controls.text
assert "Alerting" in controls.text
assert "Last result" in controls.text
assert "Paper trading mode" in controls.text
assert "Trade capital USD" in controls.text
assert "Tradable pairs" in controls.text
assert "Strategy mode" in controls.text
assert charts.status_code == 200
assert "Opportunity Trend" in charts.text
assert "opportunity-chart" in charts.text
assert "Hide chart" in charts.text or "Show chart" in charts.text
assert audit.status_code == 200
assert "Audit Trail" in audit.text
async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> None:
app = create_app(Settings(DUCKDB_PATH=tmp_path / "controls.duckdb"))
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
stop_response = await client.post("/dashboard/control/stop")
start_response = await client.post("/dashboard/control/start")
kill_response = await client.post(
"/dashboard/control/kill-switch",
data={"reason": "manual"},
)
config_response = await client.post(
"/dashboard/control/config",
data={
"trade_capital_usd": "250.50",
"max_trade_capital_usd": "300.00",
"max_concurrent_trades": "4",
"tradable_pairs": "BTC/USD, ETH/BTC, BTC/USD",
"strategy_mode": "paper",
"strategy_profit_threshold": "0.0025",
"strategy_max_depth_levels": "7",
"paper_trading_mode": "on",
},
)
assert stop_response.status_code == 200
assert ">stopped<" in stop_response.text
assert start_response.status_code == 200
assert ">running<" in start_response.text
assert kill_response.status_code == 200
assert ">active<" in kill_response.text
assert "manual" in kill_response.text
assert config_response.status_code == 200
assert "250.50 USD" in config_response.text
assert "300.00 USD" in config_response.text
assert "4" in config_response.text
assert "BTC/USD, ETH/BTC" in config_response.text
assert "paper" in config_response.text
assert "0.002500" in config_response.text
assert "7" in config_response.text
assert app.state.settings.trade_capital_usd == 250.5
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.strategy_mode == "paper"
assert app.state.dashboard_controls.strategy_profit_threshold == 0.0025
assert app.state.dashboard_controls.strategy_max_depth_levels == 7
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
audit_recent = await client.get("/dashboard/api/audit/recent")
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)
async def test_dashboard_controls_emit_alerts(tmp_path) -> None:
app = create_app(Settings(DUCKDB_PATH=tmp_path / "alerts.duckdb"))
fake_notifier = _FakeAlertNotifier()
app.state.alert_notifier = fake_notifier
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
await client.post("/dashboard/control/start")
await client.post("/dashboard/control/stop")
await client.post("/dashboard/control/kill-switch", data={"reason": "manual-test"})
assert len(fake_notifier.events) == 3
assert fake_notifier.events[0]["title"] == "Execution started"
assert fake_notifier.events[1]["title"] == "Execution stopped"
assert fake_notifier.events[2]["title"] == "Kill switch activated"
assert fake_notifier.events[2]["details"]["reason"] == "manual-test"
async def test_dashboard_requires_basic_auth_when_configured(tmp_path) -> None:
app = create_app(
Settings(
DUCKDB_PATH=tmp_path / "auth.duckdb",
DASHBOARD_AUTH_USERNAME="admin",
DASHBOARD_AUTH_PASSWORD="secret",
)
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
unauthenticated = await client.get("/dashboard/fragment/overview")
authenticated = await client.get(
"/dashboard/fragment/overview",
auth=("admin", "secret"),
)
health = await client.get("/health")
assert unauthenticated.status_code == 401
assert unauthenticated.headers["www-authenticate"] == 'Basic realm="Arbitrade Dashboard"'
assert authenticated.status_code == 200
assert health.status_code == 200
async def test_dashboard_alert_status_api_exposes_notifier_snapshot(tmp_path) -> None:
app = create_app(Settings(DUCKDB_PATH=tmp_path / "alerts-status.duckdb"))
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/dashboard/api/alerts/status")
assert response.status_code == 200
payload = response.json()
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"