Add integration tests for execution persistence, metrics, and opportunity writing
CI / lint-test-build (push) Failing after 1m23s
CI / lint-test-build (push) Failing after 1m23s
- Implemented integration tests for the execution writer to ensure trade orders and PnL are persisted correctly. - Created integration tests for the metrics calculator to summarize execution data accurately. - Added integration tests for the opportunity writer to verify event persistence. - Established PostgreSQL schema validation tests to ensure all expected tables, columns, and constraints exist. - Removed outdated unit tests that relied on DuckDB and replaced them with tests using PgStore.
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
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"
|
||||
Reference in New Issue
Block a user