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 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" 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={ "source": "db", "starting_balances": "USD=1000.0", "trade_capital": "100.0", "min_profit_threshold": "0.0005", "fee_profile": "api", "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 Jobs" in fragment.text assert run.status_code == 200 assert "submitted" in run.text assert "queued" in run.text assert reports.status_code == 200 payload = reports.json() assert len(payload["reports"]) >= 1 assert payload["reports"][0]["status"] == "pending"