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:
2026-06-01 14:18:12 +02:00
parent b413c66ca4
commit c17f41aaf8
34 changed files with 2608 additions and 60 deletions
+78 -2
View File
@@ -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
+131
View File
@@ -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
+34
View File
@@ -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"
+35
View File
@@ -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:
+115
View File
@@ -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"
+41
View File
@@ -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"
+52
View File
@@ -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"
+140
View File
@@ -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
+51
View File
@@ -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"
+42
View File
@@ -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"
+43
View File
@@ -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"