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.
This commit is contained in:
+78
-2
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -8,6 +9,31 @@ 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)
|
||||
@@ -135,6 +161,7 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
|
||||
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
|
||||
@@ -143,6 +170,7 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
|
||||
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
|
||||
@@ -163,14 +191,15 @@ 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
|
||||
|
||||
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
|
||||
|
||||
@@ -179,6 +208,9 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
|
||||
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"))
|
||||
@@ -220,6 +252,36 @@ async def test_dashboard_controls_update_runtime_state_and_config(tmp_path) -> N
|
||||
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(
|
||||
@@ -243,3 +305,17 @@ async def test_dashboard_requires_basic_auth_when_configured(tmp_path) -> None:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
|
||||
from arbitrade.alerting.notifier import AlertEvent, AlertNotifier
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _FakeChannel:
|
||||
events: list[AlertEvent] = field(default_factory=list)
|
||||
fail: bool = False
|
||||
|
||||
async def send(self, event: AlertEvent) -> None:
|
||||
if self.fail:
|
||||
raise RuntimeError("channel send failed")
|
||||
self.events.append(event)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alert_notifier_sends_event_when_enabled() -> None:
|
||||
channel = _FakeChannel()
|
||||
notifier = AlertNotifier([channel], enabled=True, min_severity="info")
|
||||
|
||||
sent = await notifier.notify(
|
||||
category="trade",
|
||||
severity="info",
|
||||
title="Trade complete",
|
||||
message="Completed all legs.",
|
||||
)
|
||||
|
||||
assert sent is True
|
||||
assert len(channel.events) == 1
|
||||
assert channel.events[0].category == "trade"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alert_notifier_respects_severity_and_category_filters() -> None:
|
||||
channel = _FakeChannel()
|
||||
notifier = AlertNotifier(
|
||||
[channel],
|
||||
enabled=True,
|
||||
min_severity="error",
|
||||
category_flags={"trade": False, "error": True},
|
||||
)
|
||||
|
||||
low = await notifier.notify(
|
||||
category="error",
|
||||
severity="warning",
|
||||
title="Low",
|
||||
message="Ignored by severity.",
|
||||
)
|
||||
filtered = await notifier.notify(
|
||||
category="trade",
|
||||
severity="critical",
|
||||
title="Trade",
|
||||
message="Ignored by category.",
|
||||
)
|
||||
high = await notifier.notify(
|
||||
category="error",
|
||||
severity="critical",
|
||||
title="High",
|
||||
message="Delivered.",
|
||||
)
|
||||
|
||||
assert low is False
|
||||
assert filtered is False
|
||||
assert high is True
|
||||
assert len(channel.events) == 1
|
||||
assert channel.events[0].title == "High"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alert_notifier_applies_dedup_window() -> None:
|
||||
channel = _FakeChannel()
|
||||
notifier = AlertNotifier([channel], dedup_seconds=60.0)
|
||||
|
||||
first = await notifier.notify(
|
||||
category="error",
|
||||
severity="error",
|
||||
title="Burst",
|
||||
message="Same message",
|
||||
)
|
||||
second = await notifier.notify(
|
||||
category="error",
|
||||
severity="error",
|
||||
title="Burst",
|
||||
message="Same message",
|
||||
)
|
||||
|
||||
assert first is True
|
||||
assert second is False
|
||||
assert len(channel.events) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alert_notifier_returns_false_when_all_channels_fail() -> None:
|
||||
notifier = AlertNotifier([_FakeChannel(fail=True), _FakeChannel(fail=True)])
|
||||
|
||||
sent = await notifier.notify(
|
||||
category="error",
|
||||
severity="critical",
|
||||
title="Failure",
|
||||
message="Both channels fail.",
|
||||
)
|
||||
|
||||
assert sent is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alert_notifier_exposes_status_snapshot_for_dashboard() -> None:
|
||||
channel = _FakeChannel()
|
||||
notifier = AlertNotifier([channel], enabled=True, min_severity="info", dedup_seconds=30.0)
|
||||
|
||||
await notifier.notify(
|
||||
category="system",
|
||||
severity="warning",
|
||||
title="Reconnect",
|
||||
message="Socket restored.",
|
||||
)
|
||||
|
||||
status = notifier.status_snapshot()
|
||||
|
||||
assert status["enabled"] is True
|
||||
assert status["has_channels"] is True
|
||||
assert status["configured_channels"] == ["_FakeChannel"]
|
||||
assert status["last_result"] == "success"
|
||||
assert status["last_attempted_at"] is not None
|
||||
assert status["last_success_at"] is not None
|
||||
assert status["last_event"] is not None
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from arbitrade.config.settings import Settings
|
||||
from arbitrade.storage.db import DuckDBStore
|
||||
from arbitrade.storage.repositories import AuditRecord, AuditRepository
|
||||
|
||||
|
||||
def test_audit_repository_inserts_and_lists_recent(tmp_path) -> None:
|
||||
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "audit.duckdb")
|
||||
store = DuckDBStore(settings)
|
||||
store.migrate()
|
||||
repository = AuditRepository(store)
|
||||
|
||||
repository.insert(
|
||||
AuditRecord(
|
||||
occurred_at=datetime.now(UTC),
|
||||
actor="dashboard_user",
|
||||
event_type="dashboard.control.start",
|
||||
decision="approved",
|
||||
payload={"execution_status": "running"},
|
||||
correlation_id="req-1",
|
||||
)
|
||||
)
|
||||
|
||||
recent = repository.list_recent(limit=5)
|
||||
|
||||
assert len(recent) == 1
|
||||
assert recent[0].actor == "dashboard_user"
|
||||
assert recent[0].event_type == "dashboard.control.start"
|
||||
assert recent[0].decision == "approved"
|
||||
assert recent[0].payload == {"execution_status": "running"}
|
||||
assert recent[0].correlation_id == "req-1"
|
||||
@@ -10,6 +10,31 @@ from arbitrade.detection.engine import OpportunityEvent
|
||||
from arbitrade.execution.sequencer import TriangularExecutionSequencer
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _FakeAlertNotifier:
|
||||
events: list[dict[str, str]] = field(default_factory=list)
|
||||
|
||||
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 or {}),
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _FakeRestClient:
|
||||
fail_at_call: int | None = None
|
||||
@@ -42,9 +67,11 @@ def _sample_event(cycle: str = "USD->BTC->ETH->USD") -> OpportunityEvent:
|
||||
@pytest.mark.asyncio
|
||||
async def test_triangular_sequencer_executes_legs_in_order() -> None:
|
||||
client = _FakeRestClient()
|
||||
notifier = _FakeAlertNotifier()
|
||||
sequencer = TriangularExecutionSequencer(
|
||||
client,
|
||||
available_pairs=["BTC/USD", "ETH/BTC", "ETH/USD"],
|
||||
alert_notifier=notifier,
|
||||
)
|
||||
|
||||
result = await sequencer.execute(_sample_event())
|
||||
@@ -53,14 +80,19 @@ async def test_triangular_sequencer_executes_legs_in_order() -> None:
|
||||
assert result.completed_legs == 3
|
||||
assert [call["pair"] for call in client.calls] == ["BTC/USD", "ETH/BTC", "ETH/USD"]
|
||||
assert [call["side"] for call in client.calls] == ["buy", "buy", "sell"]
|
||||
assert len(notifier.events) == 1
|
||||
assert notifier.events[0]["category"] == "trade"
|
||||
assert notifier.events[0]["title"] == "Trade execution completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triangular_sequencer_stops_on_failed_leg() -> None:
|
||||
client = _FakeRestClient(fail_at_call=2)
|
||||
notifier = _FakeAlertNotifier()
|
||||
sequencer = TriangularExecutionSequencer(
|
||||
client,
|
||||
available_pairs=["BTC/USD", "ETH/BTC", "ETH/USD"],
|
||||
alert_notifier=notifier,
|
||||
)
|
||||
|
||||
result = await sequencer.execute(_sample_event())
|
||||
@@ -69,6 +101,9 @@ async def test_triangular_sequencer_stops_on_failed_leg() -> None:
|
||||
assert result.completed_legs == 1
|
||||
assert result.failure_reason is not None
|
||||
assert len(client.calls) == 1
|
||||
assert len(notifier.events) == 1
|
||||
assert notifier.events[0]["category"] == "error"
|
||||
assert notifier.events[0]["title"] == "Trade execution failed"
|
||||
|
||||
|
||||
def test_triangular_sequencer_rejects_non_closed_cycle() -> None:
|
||||
|
||||
@@ -1,7 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
import pytest
|
||||
|
||||
from arbitrade.config.settings import Settings
|
||||
from arbitrade.exchange.kraken_ws import KrakenWsClient
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _FakeAlertNotifier:
|
||||
events: list[dict[str, str]] = field(default_factory=list)
|
||||
|
||||
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 or {}),
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
def __init__(self, messages: list[Any]) -> None:
|
||||
self._messages = messages
|
||||
|
||||
async def recv(self) -> str:
|
||||
if not self._messages:
|
||||
await asyncio.sleep(0)
|
||||
return orjson.dumps({"channel": "heartbeat"}).decode("utf-8")
|
||||
next_item = self._messages.pop(0)
|
||||
if isinstance(next_item, Exception):
|
||||
raise next_item
|
||||
return next_item
|
||||
|
||||
|
||||
class _FakeConnectContext:
|
||||
def __init__(self, ws: _FakeWebSocket) -> None:
|
||||
self._ws = ws
|
||||
|
||||
async def __aenter__(self) -> _FakeWebSocket:
|
||||
return self._ws
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def test_parse_book_delta() -> None:
|
||||
client = KrakenWsClient(Settings())
|
||||
message = {
|
||||
@@ -24,3 +83,59 @@ def test_parse_book_delta() -> None:
|
||||
assert len(delta.bids) == 1
|
||||
assert len(delta.asks) == 1
|
||||
assert delta.checksum == 123
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_stream_emits_disconnect_and_reconnect_alerts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
notifier = _FakeAlertNotifier()
|
||||
settings = Settings(_env_file=None, WS_HEARTBEAT_TIMEOUT_SECONDS=1.0)
|
||||
client = KrakenWsClient(settings, alert_notifier=notifier)
|
||||
|
||||
first_payload = orjson.dumps(
|
||||
{"channel": "book", "symbol": "BTC/USD", "data": [{"bids": [], "asks": []}]}
|
||||
).decode("utf-8")
|
||||
second_payload = orjson.dumps(
|
||||
{"channel": "book", "symbol": "ETH/USD", "data": [{"bids": [], "asks": []}]}
|
||||
).decode("utf-8")
|
||||
|
||||
sessions = [
|
||||
_FakeWebSocket([first_payload, RuntimeError("socket dropped")]),
|
||||
_FakeWebSocket([second_payload]),
|
||||
]
|
||||
|
||||
def _fake_connect(*_args: object, **_kwargs: object) -> _FakeConnectContext:
|
||||
return _FakeConnectContext(sessions.pop(0))
|
||||
|
||||
monkeypatch.setattr("arbitrade.exchange.kraken_ws.websockets.connect", _fake_connect)
|
||||
|
||||
stream = client.connect_stream()
|
||||
first = await anext(stream)
|
||||
second = await anext(stream)
|
||||
await client.stop()
|
||||
await stream.aclose()
|
||||
|
||||
assert first.payload["symbol"] == "BTC/USD"
|
||||
assert second.payload["symbol"] == "ETH/USD"
|
||||
titles = [event["title"] for event in notifier.events]
|
||||
assert "WebSocket disconnected" in titles
|
||||
assert "WebSocket reconnected" in titles
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recv_loop_emits_staleness_alert_on_timeout() -> None:
|
||||
notifier = _FakeAlertNotifier()
|
||||
settings = Settings(_env_file=None, WS_HEARTBEAT_TIMEOUT_SECONDS=0.001)
|
||||
client = KrakenWsClient(settings, alert_notifier=notifier)
|
||||
|
||||
class _NeverReturnsWebSocket:
|
||||
async def recv(self) -> str:
|
||||
await asyncio.sleep(1)
|
||||
return "{}"
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
await anext(client._recv_loop(_NeverReturnsWebSocket()))
|
||||
|
||||
assert len(notifier.events) == 1
|
||||
assert notifier.events[0]["title"] == "WebSocket staleness abort"
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from arbitrade.risk.loss_limits import LossLimitGuard
|
||||
|
||||
|
||||
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 test_loss_limit_guard_tracks_daily_and_cumulative_pnl() -> None:
|
||||
guard = LossLimitGuard(daily_loss_limit=100.0, cumulative_loss_limit=200.0)
|
||||
t0 = datetime.now(UTC)
|
||||
@@ -47,3 +74,17 @@ def test_loss_limit_guard_rejects_invalid_limits() -> None:
|
||||
|
||||
with pytest.raises(ValueError, match="cumulative_loss_limit"):
|
||||
LossLimitGuard(cumulative_loss_limit=-1.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loss_limit_guard_emits_alert_on_breach() -> None:
|
||||
notifier = _FakeAlertNotifier()
|
||||
guard = LossLimitGuard(daily_loss_limit=50.0, alert_notifier=notifier)
|
||||
|
||||
guard.register_realized_pnl(-60.0, at=datetime.now(UTC))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert guard.is_halted
|
||||
assert len(notifier.events) == 1
|
||||
assert notifier.events[0]["category"] == "threshold"
|
||||
assert notifier.events[0]["title"] == "Daily loss limit breached"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
@@ -83,6 +84,31 @@ class _FakeFailingExecutor:
|
||||
raise RuntimeError("executor failure")
|
||||
|
||||
|
||||
class _FakeAlertNotifier:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[dict[str, str]] = []
|
||||
|
||||
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 or {}),
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _FakeWsClientTwoMessages:
|
||||
delta: BookDelta
|
||||
@@ -430,3 +456,29 @@ async def test_market_data_feed_halts_on_repeated_execution_failures() -> None:
|
||||
assert stop_guard.halted_reason == "consecutive_failures_limit_breached"
|
||||
assert kill_switch.is_active
|
||||
assert kill_switch.reason == "consecutive_failures_limit_breached"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_market_data_feed_emits_critical_alert_on_executor_exception() -> None:
|
||||
event = _sample_event(allocated_capital=75.0)
|
||||
detector = _FakeDetector(event)
|
||||
executor = _FakeFailingExecutor()
|
||||
notifier = _FakeAlertNotifier()
|
||||
feed = MarketDataFeed(
|
||||
ws_client=_FakeWsClient(_sample_delta()),
|
||||
snapshot_writer=_FakeSnapshotWriter(),
|
||||
detector=detector,
|
||||
opportunity_writer=_FakeOpportunityWriter(),
|
||||
paper_trading_mode=False,
|
||||
opportunity_executor=executor.execute,
|
||||
alert_notifier=notifier,
|
||||
)
|
||||
|
||||
await feed.run()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert executor.calls == 1
|
||||
assert len(notifier.events) == 1
|
||||
assert notifier.events[0]["category"] == "system"
|
||||
assert notifier.events[0]["severity"] == "critical"
|
||||
assert notifier.events[0]["title"] == "Critical execution exception"
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from arbitrade.api.app import create_app
|
||||
from arbitrade.config.settings import Settings
|
||||
from arbitrade.runtime.lifecycle import (
|
||||
graceful_shutdown,
|
||||
persist_runtime_snapshot,
|
||||
restore_runtime_state,
|
||||
)
|
||||
from arbitrade.storage.repositories import RuntimeStateRecord
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _FakeWorker:
|
||||
stopped: bool = False
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.stopped = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _FakeStartupReconciler:
|
||||
called: bool = False
|
||||
|
||||
async def reconcile_open_trades(self) -> None:
|
||||
self.called = True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_runtime_snapshot_writes_record(tmp_path) -> None:
|
||||
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "runtime.duckdb"))
|
||||
|
||||
app.state.dashboard_controls.is_running = True
|
||||
app.state.dashboard_controls.kill_switch.deactivate()
|
||||
|
||||
snapshot = persist_runtime_snapshot(app, note="unit-test")
|
||||
|
||||
assert snapshot is not None
|
||||
assert snapshot.note == "unit-test"
|
||||
|
||||
latest = app.state.runtime_state_repository.latest()
|
||||
assert latest is not None
|
||||
assert latest.note == "unit-test"
|
||||
assert latest.is_running is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_runtime_state_applies_snapshot(tmp_path) -> None:
|
||||
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "restore.duckdb"))
|
||||
app.state.runtime_state_repository.insert(
|
||||
RuntimeStateRecord(
|
||||
snapshot_at=datetime.now(UTC),
|
||||
is_running=False,
|
||||
kill_switch_active=True,
|
||||
kill_switch_reason="manual-stop",
|
||||
open_trade_count=0,
|
||||
last_known_balances={"USD": 100.0},
|
||||
note="seed",
|
||||
)
|
||||
)
|
||||
|
||||
report = await restore_runtime_state(app)
|
||||
|
||||
assert report.restored_from_snapshot is True
|
||||
assert app.state.dashboard_controls.is_running is False
|
||||
assert app.state.dashboard_controls.kill_switch.is_active is True
|
||||
assert app.state.dashboard_controls.kill_switch.reason == "manual-stop"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_runtime_state_enables_restart_guard_for_open_trades(tmp_path) -> None:
|
||||
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "open-trades.duckdb"))
|
||||
|
||||
with app.state.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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
"open-trade-1",
|
||||
datetime.now(UTC),
|
||||
None,
|
||||
"open",
|
||||
None,
|
||||
1.0,
|
||||
100.0,
|
||||
"USD->BTC->ETH->USD",
|
||||
3,
|
||||
],
|
||||
)
|
||||
|
||||
report = await restore_runtime_state(app)
|
||||
|
||||
assert report.open_trades_detected == 1
|
||||
assert report.restart_guard_active is True
|
||||
assert app.state.dashboard_controls.is_running is False
|
||||
assert app.state.dashboard_controls.kill_switch.is_active is True
|
||||
assert app.state.dashboard_controls.kill_switch.reason == "recovery_open_trades_detected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graceful_shutdown_drains_workers_and_persists_snapshot(tmp_path) -> None:
|
||||
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "shutdown.duckdb"))
|
||||
worker = _FakeWorker()
|
||||
app.state.background_workers = [worker]
|
||||
app.state.dashboard_controls.is_running = True
|
||||
|
||||
await graceful_shutdown(app)
|
||||
|
||||
assert worker.stopped is True
|
||||
assert app.state.dashboard_controls.is_running is False
|
||||
latest = app.state.runtime_state_repository.latest()
|
||||
assert latest is not None
|
||||
assert latest.note == "graceful_shutdown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_runtime_state_calls_startup_reconciler(tmp_path) -> None:
|
||||
app = create_app(Settings(_env_file=None, DUCKDB_PATH=tmp_path / "reconciler.duckdb"))
|
||||
reconciler = _FakeStartupReconciler()
|
||||
app.state.startup_reconciler = reconciler
|
||||
|
||||
await restore_runtime_state(app)
|
||||
|
||||
assert reconciler.called is True
|
||||
@@ -0,0 +1,51 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from arbitrade.config.settings import Settings
|
||||
|
||||
|
||||
def test_dashboard_auth_requires_both_fields() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(_env_file=None, DASHBOARD_AUTH_USERNAME="admin")
|
||||
|
||||
|
||||
def test_kraken_api_auth_requires_key_and_secret() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(_env_file=None, KRAKEN_API_KEY="key-only")
|
||||
|
||||
|
||||
def test_kraken_permissions_require_query_and_trade() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(
|
||||
_env_file=None,
|
||||
KRAKEN_API_KEY="k",
|
||||
KRAKEN_API_SECRET="s",
|
||||
KRAKEN_API_KEY_PERMISSIONS="query",
|
||||
)
|
||||
|
||||
|
||||
def test_kraken_permissions_forbid_withdrawal_scope() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(
|
||||
_env_file=None,
|
||||
KRAKEN_API_KEY="k",
|
||||
KRAKEN_API_SECRET="s",
|
||||
KRAKEN_API_KEY_PERMISSIONS="query,trade,withdraw",
|
||||
)
|
||||
|
||||
|
||||
def test_alert_min_severity_is_validated() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(_env_file=None, ALERT_MIN_SEVERITY="nope")
|
||||
|
||||
|
||||
def test_valid_security_configuration_passes() -> None:
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
KRAKEN_API_KEY="k",
|
||||
KRAKEN_API_SECRET="s",
|
||||
KRAKEN_API_KEY_PERMISSIONS="query,trade",
|
||||
ALERT_MIN_SEVERITY="warning",
|
||||
)
|
||||
|
||||
assert settings.kraken_api_key_permissions == "query,trade"
|
||||
@@ -1,10 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from arbitrade.risk.stop_conditions import StopConditionsGuard
|
||||
|
||||
|
||||
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 test_stop_conditions_guard_halts_on_source_latency_breach() -> None:
|
||||
guard = StopConditionsGuard(max_source_latency_ms=50.0)
|
||||
|
||||
@@ -55,3 +83,17 @@ def test_stop_conditions_guard_rejects_invalid_configuration() -> None:
|
||||
|
||||
with pytest.raises(ValueError, match="max_consecutive_failures"):
|
||||
StopConditionsGuard(max_consecutive_failures=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_conditions_guard_emits_alert_on_failure_threshold() -> None:
|
||||
notifier = _FakeAlertNotifier()
|
||||
guard = StopConditionsGuard(max_consecutive_failures=1, alert_notifier=notifier)
|
||||
|
||||
guard.register_failure()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert guard.is_halted
|
||||
assert len(notifier.events) == 1
|
||||
assert notifier.events[0]["category"] == "threshold"
|
||||
assert notifier.events[0]["title"] == "Consecutive failures limit breached"
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from arbitrade.risk.trade_limits import TradeLimitsGuard
|
||||
|
||||
|
||||
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 test_trade_limits_guard_blocks_when_max_concurrent_reached() -> None:
|
||||
guard = TradeLimitsGuard(max_concurrent_trades=1)
|
||||
|
||||
@@ -39,3 +67,18 @@ def test_trade_limits_guard_rejects_invalid_configuration() -> None:
|
||||
|
||||
with pytest.raises(ValueError, match="max_exposure_per_asset"):
|
||||
TradeLimitsGuard(max_exposure_per_asset=0.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trade_limits_guard_emits_alert_when_rejecting_trade() -> None:
|
||||
notifier = _FakeAlertNotifier()
|
||||
guard = TradeLimitsGuard(max_concurrent_trades=1, alert_notifier=notifier)
|
||||
|
||||
guard.open_trade({"BTC": 10.0})
|
||||
allowed = guard.is_trade_allowed({"BTC": 1.0})
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert not allowed
|
||||
assert len(notifier.events) == 1
|
||||
assert notifier.events[0]["category"] == "threshold"
|
||||
assert notifier.events[0]["title"] == "Concurrent trade limit reached"
|
||||
|
||||
Reference in New Issue
Block a user