Files
arbitrade/tests/test_dashboard.py
T
zwitschi c17f41aaf8 feat: add audit events and runtime state snapshots to database
- Introduced new tables for audit events and runtime state snapshots in the database schema.
- Created data classes for AuditRecord and RuntimeStateRecord to represent the new entities.
- Implemented AuditRepository and RuntimeStateRepository for inserting and retrieving records.
- Enhanced the dashboard to include an audit trail section, displaying recent audit events.
- Added tests for the new audit repository and runtime lifecycle functionalities.
- Updated settings validation to ensure proper configuration for alerting features.
- Integrated alert notifications across various components, including execution sequencer and loss limits.
2026-06-01 14:18:12 +02:00

322 lines
11 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 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",
"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 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
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