feat: Add performance metrics dashboard and metrics calculator
This commit is contained in:
@@ -21,3 +21,5 @@
|
|||||||
- Added deterministic order idempotency via Kraken userref plus reconciliation helpers for Kraken order history responses.
|
- Added deterministic order idempotency via Kraken userref plus reconciliation helpers for Kraken order history responses.
|
||||||
- Added execution journaling for trades, orders, and estimated P&L, plus a DuckDB startup fallback when the default file path is unavailable.
|
- Added execution journaling for trades, orders, and estimated P&L, plus a DuckDB startup fallback when the default file path is unavailable.
|
||||||
- Added a mocked execution integration test that drives the triangular sequencer through the execution journal and DuckDB persistence.
|
- Added a mocked execution integration test that drives the triangular sequencer through the execution journal and DuckDB persistence.
|
||||||
|
- Added a DuckDB-backed performance metrics calculator for realized P&L, win rate, trade duration, opportunities/min, fill rate, and latency percentiles.
|
||||||
|
- Added dashboard page plus HTMX metrics fragment and SSE metrics stream.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from fastapi import FastAPI
|
|||||||
from arbitrade.api.routes import router
|
from arbitrade.api.routes import router
|
||||||
from arbitrade.config.settings import Settings
|
from arbitrade.config.settings import Settings
|
||||||
from arbitrade.logging_setup import configure_logging
|
from arbitrade.logging_setup import configure_logging
|
||||||
|
from arbitrade.metrics import MetricsCalculator
|
||||||
from arbitrade.storage.db import DuckDBStore
|
from arbitrade.storage.db import DuckDBStore
|
||||||
|
|
||||||
|
|
||||||
@@ -15,5 +16,7 @@ def create_app(settings: Settings) -> FastAPI:
|
|||||||
db.migrate()
|
db.migrate()
|
||||||
|
|
||||||
app = FastAPI(title="arbitrade", version="0.1.0")
|
app = FastAPI(title="arbitrade", version="0.1.0")
|
||||||
|
app.state.store = db
|
||||||
|
app.state.metrics = MetricsCalculator(db)
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -1,28 +1,109 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="web/templates")
|
templates = Jinja2Templates(
|
||||||
|
directory=str(Path(__file__).resolve().parents[3] / "web" / "templates")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_metric(value: float | None, *, precision: int = 2, suffix: str = "") -> str:
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
return f"{value:.{precision}f}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _dashboard_metrics(request: Request) -> dict[str, str]:
|
||||||
|
metrics = request.app.state.metrics.compute()
|
||||||
|
return {
|
||||||
|
"realized_pnl": _format_metric(metrics.realized_pnl_usd, precision=2, suffix=" USD"),
|
||||||
|
"win_rate": _format_metric(
|
||||||
|
metrics.win_rate * 100.0 if metrics.win_rate is not None else None,
|
||||||
|
precision=1,
|
||||||
|
suffix="%",
|
||||||
|
),
|
||||||
|
"avg_trade_duration": _format_metric(
|
||||||
|
metrics.avg_trade_duration_seconds, precision=1, suffix=" s"
|
||||||
|
),
|
||||||
|
"opportunities_per_minute": _format_metric(
|
||||||
|
metrics.opportunities_per_minute, precision=1, suffix=" /min"
|
||||||
|
),
|
||||||
|
"fill_rate": _format_metric(
|
||||||
|
metrics.fill_rate * 100.0 if metrics.fill_rate is not None else None,
|
||||||
|
precision=1,
|
||||||
|
suffix="%",
|
||||||
|
),
|
||||||
|
"latency_p50": _format_metric(metrics.latency_p50_seconds, precision=3, suffix=" s"),
|
||||||
|
"latency_p95": _format_metric(metrics.latency_p95_seconds, precision=3, suffix=" s"),
|
||||||
|
"latency_p99": _format_metric(metrics.latency_p99_seconds, precision=3, suffix=" s"),
|
||||||
|
"generated_at": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def home(request: Request) -> HTMLResponse:
|
async def home(request: Request) -> HTMLResponse:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="health.html",
|
name="dashboard.html",
|
||||||
context={
|
context={
|
||||||
"status": "ok",
|
"title": "Arbitrade Dashboard",
|
||||||
"time": datetime.now(UTC).isoformat(),
|
"request": request,
|
||||||
"title": "Arbitrade Health",
|
"metrics_endpoint": "/dashboard/fragment/metrics",
|
||||||
|
"stream_endpoint": "/dashboard/stream/metrics",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard", response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="dashboard.html",
|
||||||
|
context={
|
||||||
|
"title": "Arbitrade Dashboard",
|
||||||
|
"request": request,
|
||||||
|
"metrics_endpoint": "/dashboard/fragment/metrics",
|
||||||
|
"stream_endpoint": "/dashboard/stream/metrics",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/fragment/metrics", response_class=HTMLResponse)
|
||||||
|
async def dashboard_metrics(request: Request) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="partials/metrics.html",
|
||||||
|
context={"request": request, **_dashboard_metrics(request)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/stream/metrics")
|
||||||
|
async def dashboard_metrics_stream(request: Request) -> StreamingResponse:
|
||||||
|
fragment = (
|
||||||
|
templates.get_template("partials/metrics.html")
|
||||||
|
.render(
|
||||||
|
request=request,
|
||||||
|
**_dashboard_metrics(request),
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.replace("\n", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _event_stream() -> AsyncIterator[bytes]:
|
||||||
|
payload = json.dumps(fragment)
|
||||||
|
yield f"event: metrics\ndata: {payload}\n\n".encode()
|
||||||
|
|
||||||
|
return StreamingResponse(_event_stream(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health", response_class=JSONResponse)
|
@router.get("/health", response_class=JSONResponse)
|
||||||
async def health() -> JSONResponse:
|
async def health() -> JSONResponse:
|
||||||
return JSONResponse({"status": "ok", "service": "arbitrade"})
|
return JSONResponse({"status": "ok", "service": "arbitrade"})
|
||||||
|
|||||||
@@ -40,13 +40,11 @@ class TriangularExecutionSequencer:
|
|||||||
rest_client: SupportsOrderPlacement,
|
rest_client: SupportsOrderPlacement,
|
||||||
*,
|
*,
|
||||||
available_pairs: Sequence[str],
|
available_pairs: Sequence[str],
|
||||||
volume_for_leg: Callable[[OpportunityEvent,
|
volume_for_leg: Callable[[OpportunityEvent, ExecutionLeg, int], float] | None = None,
|
||||||
ExecutionLeg, int], float] | None = None,
|
|
||||||
execution_writer: AsyncExecutionWriter | None = None,
|
execution_writer: AsyncExecutionWriter | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._rest_client = rest_client
|
self._rest_client = rest_client
|
||||||
self._available_pairs = {self._normalize_pair(
|
self._available_pairs = {self._normalize_pair(pair) for pair in available_pairs}
|
||||||
pair) for pair in available_pairs}
|
|
||||||
self._volume_for_leg = volume_for_leg or self._default_volume_for_leg
|
self._volume_for_leg = volume_for_leg or self._default_volume_for_leg
|
||||||
self._execution_writer = execution_writer
|
self._execution_writer = execution_writer
|
||||||
|
|
||||||
@@ -91,15 +89,12 @@ class TriangularExecutionSequencer:
|
|||||||
raise ValueError(f"No tradable pair for leg {from_cur}->{to_cur}")
|
raise ValueError(f"No tradable pair for leg {from_cur}->{to_cur}")
|
||||||
|
|
||||||
def _build_legs(self, event: OpportunityEvent) -> tuple[ExecutionLeg, ...]:
|
def _build_legs(self, event: OpportunityEvent) -> tuple[ExecutionLeg, ...]:
|
||||||
currencies = [part.strip().upper()
|
currencies = [part.strip().upper() for part in event.cycle.split("->") if part.strip()]
|
||||||
for part in event.cycle.split("->") if part.strip()]
|
|
||||||
if len(currencies) < 4 or currencies[0] != currencies[-1]:
|
if len(currencies) < 4 or currencies[0] != currencies[-1]:
|
||||||
raise ValueError(
|
raise ValueError("cycle must be a closed triangular path like A->B->C->A")
|
||||||
"cycle must be a closed triangular path like A->B->C->A")
|
|
||||||
|
|
||||||
if len(currencies) != 4:
|
if len(currencies) != 4:
|
||||||
raise ValueError(
|
raise ValueError("cycle must contain exactly three unique currencies")
|
||||||
"cycle must contain exactly three unique currencies")
|
|
||||||
|
|
||||||
legs: list[ExecutionLeg] = []
|
legs: list[ExecutionLeg] = []
|
||||||
for idx in range(3):
|
for idx in range(3):
|
||||||
@@ -114,8 +109,7 @@ class TriangularExecutionSequencer:
|
|||||||
)
|
)
|
||||||
volume = self._volume_for_leg(event, placeholder_leg, idx)
|
volume = self._volume_for_leg(event, placeholder_leg, idx)
|
||||||
if volume <= 0.0:
|
if volume <= 0.0:
|
||||||
raise ValueError(
|
raise ValueError("volume_for_leg must return a positive volume")
|
||||||
"volume_for_leg must return a positive volume")
|
|
||||||
legs.append(self._resolve_leg(from_currency, to_currency, volume))
|
legs.append(self._resolve_leg(from_currency, to_currency, volume))
|
||||||
|
|
||||||
return tuple(legs)
|
return tuple(legs)
|
||||||
@@ -177,8 +171,7 @@ class TriangularExecutionSequencer:
|
|||||||
responses.append(response)
|
responses.append(response)
|
||||||
|
|
||||||
if self._execution_writer is not None:
|
if self._execution_writer is not None:
|
||||||
order_ref = self._order_ref_from_response(
|
order_ref = self._order_ref_from_response(response, f"leg-{idx}")
|
||||||
response, f"leg-{idx}")
|
|
||||||
await self._execution_writer.enqueue(
|
await self._execution_writer.enqueue(
|
||||||
OrderRecord(
|
OrderRecord(
|
||||||
trade_ref=trade_ref,
|
trade_ref=trade_ref,
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from statistics import fmean
|
||||||
|
|
||||||
|
from arbitrade.storage.db import DuckDBStore
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PerformanceMetrics:
|
||||||
|
realized_pnl_usd: float
|
||||||
|
win_rate: float | None
|
||||||
|
avg_trade_duration_seconds: float | None
|
||||||
|
opportunities_per_minute: float | None
|
||||||
|
fill_rate: float | None
|
||||||
|
latency_p50_seconds: float | None
|
||||||
|
latency_p95_seconds: float | None
|
||||||
|
latency_p99_seconds: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsCalculator:
|
||||||
|
def __init__(self, store: DuckDBStore) -> None:
|
||||||
|
self._store = store
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _percentile(values: Iterable[float], percentile: float) -> float | None:
|
||||||
|
samples = sorted(values)
|
||||||
|
if not samples:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if percentile <= 0.0:
|
||||||
|
return samples[0]
|
||||||
|
if percentile >= 100.0:
|
||||||
|
return samples[-1]
|
||||||
|
|
||||||
|
rank = (len(samples) - 1) * (percentile / 100.0)
|
||||||
|
lower_index = int(rank)
|
||||||
|
upper_index = min(lower_index + 1, len(samples) - 1)
|
||||||
|
weight = rank - lower_index
|
||||||
|
return samples[lower_index] * (1.0 - weight) + samples[upper_index] * weight
|
||||||
|
|
||||||
|
def compute(self) -> PerformanceMetrics:
|
||||||
|
with self._store.connect() as conn:
|
||||||
|
trade_rows = conn.execute("""
|
||||||
|
SELECT started_at, finished_at, realized_pnl
|
||||||
|
FROM trades
|
||||||
|
WHERE finished_at IS NOT NULL
|
||||||
|
""").fetchall()
|
||||||
|
opportunity_rows = conn.execute("""
|
||||||
|
SELECT detected_at
|
||||||
|
FROM opportunities
|
||||||
|
""").fetchall()
|
||||||
|
order_rows = conn.execute("""
|
||||||
|
SELECT volume, filled_volume
|
||||||
|
FROM orders
|
||||||
|
WHERE volume > 0
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
realized_values = [float(row[2]) for row in trade_rows if row[2] is not None]
|
||||||
|
realized_pnl_usd = sum(realized_values)
|
||||||
|
|
||||||
|
total_trades = len(trade_rows)
|
||||||
|
winning_trades = sum(1 for row in trade_rows if row[2] is not None and float(row[2]) > 0.0)
|
||||||
|
win_rate = winning_trades / total_trades if total_trades > 0 else None
|
||||||
|
|
||||||
|
durations_seconds = [
|
||||||
|
(row[1] - row[0]).total_seconds()
|
||||||
|
for row in trade_rows
|
||||||
|
if isinstance(row[0], datetime) and isinstance(row[1], datetime)
|
||||||
|
]
|
||||||
|
avg_trade_duration_seconds = fmean(durations_seconds) if durations_seconds else None
|
||||||
|
|
||||||
|
opportunity_times = [row[0] for row in opportunity_rows if isinstance(row[0], datetime)]
|
||||||
|
opportunities_per_minute: float | None
|
||||||
|
if len(opportunity_times) >= 2:
|
||||||
|
span_seconds = (max(opportunity_times) - min(opportunity_times)).total_seconds()
|
||||||
|
opportunities_per_minute = (
|
||||||
|
len(opportunity_times) / (span_seconds / 60.0)
|
||||||
|
if span_seconds > 0.0
|
||||||
|
else float(len(opportunity_times))
|
||||||
|
)
|
||||||
|
elif len(opportunity_times) == 1:
|
||||||
|
opportunities_per_minute = 60.0
|
||||||
|
else:
|
||||||
|
opportunities_per_minute = None
|
||||||
|
|
||||||
|
fill_samples = [
|
||||||
|
float(filled) / float(volume)
|
||||||
|
for volume, filled in order_rows
|
||||||
|
if filled is not None and float(volume) > 0.0
|
||||||
|
]
|
||||||
|
fill_rate = fmean(fill_samples) if fill_samples else None
|
||||||
|
|
||||||
|
latency_p50_seconds = self._percentile(durations_seconds, 50.0)
|
||||||
|
latency_p95_seconds = self._percentile(durations_seconds, 95.0)
|
||||||
|
latency_p99_seconds = self._percentile(durations_seconds, 99.0)
|
||||||
|
|
||||||
|
return PerformanceMetrics(
|
||||||
|
realized_pnl_usd=realized_pnl_usd,
|
||||||
|
win_rate=win_rate,
|
||||||
|
avg_trade_duration_seconds=avg_trade_duration_seconds,
|
||||||
|
opportunities_per_minute=opportunities_per_minute,
|
||||||
|
fill_rate=fill_rate,
|
||||||
|
latency_p50_seconds=latency_p50_seconds,
|
||||||
|
latency_p95_seconds=latency_p95_seconds,
|
||||||
|
latency_p99_seconds=latency_p99_seconds,
|
||||||
|
)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from arbitrade.api.app import create_app
|
||||||
|
from arbitrade.config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
|
||||||
|
app = create_app(
|
||||||
|
Settings(_env_file=None, 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")
|
||||||
|
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert "EventSource" in page.text
|
||||||
|
assert 'hx-get="/dashboard/fragment/metrics"' 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
|
||||||
@@ -69,8 +69,7 @@ async def test_execution_writer_persists_trade_order_and_pnl(tmp_path) -> None:
|
|||||||
"SELECT trade_ref, order_ref, leg_index, pair, side, volume, status "
|
"SELECT trade_ref, order_ref, leg_index, pair, side, volume, status "
|
||||||
"FROM orders ORDER BY leg_index"
|
"FROM orders ORDER BY leg_index"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
pnls = conn.execute(
|
pnls = conn.execute("SELECT trade_ref, kind, pnl_usd, source FROM pnl_events").fetchall()
|
||||||
"SELECT trade_ref, kind, pnl_usd, source FROM pnl_events").fetchall()
|
|
||||||
|
|
||||||
assert len(trades) == 1
|
assert len(trades) == 1
|
||||||
assert trades[0][1] == "filled"
|
assert trades[0][1] == "filled"
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arbitrade.config.settings import Settings
|
||||||
|
from arbitrade.metrics import MetricsCalculator
|
||||||
|
from arbitrade.storage.db import DuckDBStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_calculator_summarizes_execution_data(tmp_path) -> None:
|
||||||
|
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "metrics.duckdb")
|
||||||
|
store = DuckDBStore(settings)
|
||||||
|
store.migrate()
|
||||||
|
|
||||||
|
started = datetime.now(UTC)
|
||||||
|
finished = started + timedelta(seconds=30)
|
||||||
|
started_two = started + timedelta(minutes=1)
|
||||||
|
finished_two = started_two + timedelta(seconds=90)
|
||||||
|
|
||||||
|
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",
|
||||||
|
12.5,
|
||||||
|
10.0,
|
||||||
|
100.0,
|
||||||
|
"USD->BTC->ETH->USD",
|
||||||
|
3,
|
||||||
|
"trade-2",
|
||||||
|
started_two,
|
||||||
|
finished_two,
|
||||||
|
"filled",
|
||||||
|
-4.5,
|
||||||
|
-2.0,
|
||||||
|
200.0,
|
||||||
|
"USD->ETH->BTC->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,
|
||||||
|
started_two,
|
||||||
|
"USD->ETH->BTC->USD",
|
||||||
|
2.0,
|
||||||
|
1.0,
|
||||||
|
0.01,
|
||||||
|
False,
|
||||||
|
started_two + timedelta(seconds=30),
|
||||||
|
"USD->BTC->ETH->USD",
|
||||||
|
5.0,
|
||||||
|
4.0,
|
||||||
|
0.04,
|
||||||
|
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,
|
||||||
|
101,
|
||||||
|
"closed",
|
||||||
|
2.0,
|
||||||
|
100.0,
|
||||||
|
"{}",
|
||||||
|
started,
|
||||||
|
"trade-2",
|
||||||
|
"order-2",
|
||||||
|
0,
|
||||||
|
"ETH/USD",
|
||||||
|
"sell",
|
||||||
|
4.0,
|
||||||
|
202,
|
||||||
|
"closed",
|
||||||
|
3.0,
|
||||||
|
200.0,
|
||||||
|
"{}",
|
||||||
|
started_two,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = MetricsCalculator(store).compute()
|
||||||
|
|
||||||
|
assert metrics.realized_pnl_usd == 8.0
|
||||||
|
assert metrics.win_rate == 0.5
|
||||||
|
assert metrics.avg_trade_duration_seconds == 60.0
|
||||||
|
assert metrics.opportunities_per_minute == 2.0
|
||||||
|
assert metrics.fill_rate == 0.875
|
||||||
|
assert metrics.latency_p50_seconds == 60.0
|
||||||
|
assert metrics.latency_p95_seconds == 87.0
|
||||||
|
assert metrics.latency_p99_seconds == pytest.approx(89.4)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e5eefb;
|
||||||
|
}
|
||||||
|
.shell {
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 48px;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: #9fb2d0;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
color: #9fb2d0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #7f95b7;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2d6cdf;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">Arbitrade Dashboard</h1>
|
||||||
|
<p class="subtitle">Live execution, P&L, and system state.</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<a
|
||||||
|
class="button"
|
||||||
|
href="{{ metrics_endpoint }}"
|
||||||
|
hx-get="{{ metrics_endpoint }}"
|
||||||
|
hx-target="#metrics-panel"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>Refresh metrics</a
|
||||||
|
>
|
||||||
|
<a class="button secondary" href="/health">Health</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="metrics-shell"
|
||||||
|
hx-get="{{ metrics_endpoint }}"
|
||||||
|
hx-target="this"
|
||||||
|
hx-trigger="load, every 15s"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
{% include "partials/metrics.html" %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const stream = new EventSource("{{ stream_endpoint }}");
|
||||||
|
stream.addEventListener("metrics", (event) => {
|
||||||
|
const panel = document.getElementById("metrics-panel");
|
||||||
|
if (panel) {
|
||||||
|
panel.outerHTML = JSON.parse(event.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<div id="metrics-panel" class="panel">
|
||||||
|
<div class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<div class="label">Realized P&L</div>
|
||||||
|
<div class="value">{{ realized_pnl }}</div>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<div class="label">Win Rate</div>
|
||||||
|
<div class="value">{{ win_rate }}</div>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<div class="label">Avg Trade Duration</div>
|
||||||
|
<div class="value">{{ avg_trade_duration }}</div>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<div class="label">Opportunities / Min</div>
|
||||||
|
<div class="value">{{ opportunities_per_minute }}</div>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<div class="label">Fill Rate</div>
|
||||||
|
<div class="value">{{ fill_rate }}</div>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<div class="label">Latency p50 / p95 / p99</div>
|
||||||
|
<div class="value">
|
||||||
|
{{ latency_p50 }} | {{ latency_p95 }} | {{ latency_p99 }}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="meta">Updated {{ generated_at }}</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user