feat: Enhance dashboard with live overview panel and control features

This commit is contained in:
2026-06-01 12:20:28 +02:00
parent 0c232b7aee
commit cde181f343
8 changed files with 612 additions and 2 deletions
+2
View File
@@ -23,3 +23,5 @@
- 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 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. - Added dashboard page plus HTMX metrics fragment and SSE metrics stream.
- Added dashboard live overview panel for status, balances, open trades, realized P&L, and opportunity feed with HTMX refresh and SSE updates.
- Added dashboard controls for start/stop, config edits, and manual kill-switch triggering via HTMX POST forms.
+5
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from fastapi import FastAPI from fastapi import FastAPI
from arbitrade.api.control_state import DashboardControlState
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
@@ -16,7 +17,11 @@ 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.settings = settings
app.state.store = db app.state.store = db
app.state.metrics = MetricsCalculator(db) app.state.metrics = MetricsCalculator(db)
app.state.dashboard_controls = DashboardControlState(
is_running=not settings.kill_switch_active,
)
app.include_router(router) app.include_router(router)
return app return app
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from arbitrade.risk.kill_switch import KillSwitch
@dataclass(slots=True)
class DashboardControlState:
is_running: bool = True
kill_switch: KillSwitch = field(default_factory=KillSwitch)
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
def mark_updated(self) -> None:
self.updated_at = datetime.now(UTC)
+243
View File
@@ -4,11 +4,15 @@ import json
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import cast
from urllib.parse import parse_qs
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from arbitrade.api.control_state import DashboardControlState
router = APIRouter() router = APIRouter()
templates = Jinja2Templates( templates = Jinja2Templates(
directory=str(Path(__file__).resolve().parents[3] / "web" / "templates") directory=str(Path(__file__).resolve().parents[3] / "web" / "templates")
@@ -48,6 +52,138 @@ def _dashboard_metrics(request: Request) -> dict[str, str]:
} }
def _table_columns(conn, table_name: str) -> set[str]:
rows = conn.execute(f"PRAGMA table_info('{table_name}')").fetchall()
return {str(row[1]) for row in rows}
def _dashboard_overview(request: Request) -> dict[str, object]:
store = request.app.state.store
with store.connect() as conn:
trade_columns = _table_columns(conn, "trades")
trade_ref_expr = "trade_ref" if "trade_ref" in trade_columns else "CAST(id AS VARCHAR)"
cycle_expr = "cycle" if "cycle" in trade_columns else "NULL"
if "finished_at" in trade_columns:
open_trade_filter = "finished_at IS NULL"
else:
open_trade_filter = "LOWER(status) NOT IN ('filled', 'closed', 'cancelled', 'canceled')"
portfolio_row = conn.execute("""
SELECT balances, total_value_usd
FROM portfolio_snapshots
ORDER BY snapshot_at DESC
LIMIT 1
""").fetchone()
open_trades = conn.execute(
f"""
SELECT {trade_ref_expr}, status, started_at, {cycle_expr}
FROM trades
WHERE {open_trade_filter}
ORDER BY started_at DESC
LIMIT 5
"""
).fetchall()
pnl_total_row = conn.execute("""
SELECT COALESCE(SUM(COALESCE(realized_pnl, 0)), 0)
FROM trades
""").fetchone()
latest_opportunities = conn.execute("""
SELECT cycle, net_pct, est_profit, detected_at
FROM opportunities
ORDER BY detected_at DESC
LIMIT 5
""").fetchall()
balances_value = ""
total_value = ""
if portfolio_row is not None:
balances_raw, total_value_raw = portfolio_row
balances_value = str(balances_raw) if balances_raw is not None else ""
if total_value_raw is not None:
total_value = f"{float(total_value_raw):.2f} USD"
open_trade_rows = [
{
"trade_ref": str(row[0]),
"status": str(row[1]),
"started_at": row[2].isoformat() if isinstance(row[2], datetime) else "",
"cycle": str(row[3]) if row[3] is not None else "",
}
for row in open_trades
]
opportunity_rows = [
{
"cycle": str(row[0]),
"net_pct": f"{float(row[1]):.2f}%" if row[1] is not None else "",
"est_profit": f"{float(row[2]):.2f} USD" if row[2] is not None else "",
"detected_at": row[3].isoformat() if isinstance(row[3], datetime) else "",
}
for row in latest_opportunities
]
return {
"status": "live",
"generated_at": datetime.now(UTC).isoformat(),
"balances": balances_value,
"total_value": total_value,
"open_trade_count": len(open_trade_rows),
"open_trades": open_trade_rows,
"realized_pnl_total": f"{float(pnl_total_row[0]):.2f} USD" if pnl_total_row else "",
"opportunities": opportunity_rows,
}
def _dashboard_controls_state(request: Request) -> DashboardControlState:
return cast(DashboardControlState, request.app.state.dashboard_controls)
def _dashboard_controls(request: Request) -> dict[str, object]:
controls = _dashboard_controls_state(request)
settings = request.app.state.settings
return {
"execution_status": "running" if controls.is_running else "stopped",
"kill_switch_status": "active" if controls.kill_switch.is_active else "inactive",
"kill_switch_reason": controls.kill_switch.reason or "",
"paper_trading_mode": "enabled" if settings.paper_trading_mode else "disabled",
"trade_capital_usd": f"{float(settings.trade_capital_usd):.2f} USD",
"trade_capital_usd_value": f"{float(settings.trade_capital_usd):.2f}",
"max_trade_capital_usd": (
""
if settings.max_trade_capital_usd is None
else f"{float(settings.max_trade_capital_usd):.2f} USD"
),
"max_trade_capital_usd_value": (
""
if settings.max_trade_capital_usd is None
else f"{float(settings.max_trade_capital_usd):.2f}"
),
"max_concurrent_trades": (
"" if settings.max_concurrent_trades is None else str(
settings.max_concurrent_trades)
),
"max_concurrent_trades_value": (
"" if settings.max_concurrent_trades is None else str(
settings.max_concurrent_trades)
),
"updated_at": controls.updated_at.isoformat(),
"start_endpoint": "/dashboard/control/start",
"stop_endpoint": "/dashboard/control/stop",
"kill_switch_endpoint": "/dashboard/control/kill-switch",
"config_endpoint": "/dashboard/control/config",
}
def _parse_form_body(body: bytes) -> dict[str, str]:
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
return {key: values[-1] for key, values in parsed.items() if values}
def _form_bool(value: str | None) -> bool:
if value is None:
return False
return value.lower() in {"1", "true", "yes", "on"}
@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(
@@ -57,7 +193,10 @@ async def home(request: Request) -> HTMLResponse:
"title": "Arbitrade Dashboard", "title": "Arbitrade Dashboard",
"request": request, "request": request,
"metrics_endpoint": "/dashboard/fragment/metrics", "metrics_endpoint": "/dashboard/fragment/metrics",
"overview_endpoint": "/dashboard/fragment/overview",
"controls_endpoint": "/dashboard/fragment/controls",
"stream_endpoint": "/dashboard/stream/metrics", "stream_endpoint": "/dashboard/stream/metrics",
"overview_stream_endpoint": "/dashboard/stream/overview",
}, },
) )
@@ -71,7 +210,10 @@ async def dashboard(request: Request) -> HTMLResponse:
"title": "Arbitrade Dashboard", "title": "Arbitrade Dashboard",
"request": request, "request": request,
"metrics_endpoint": "/dashboard/fragment/metrics", "metrics_endpoint": "/dashboard/fragment/metrics",
"overview_endpoint": "/dashboard/fragment/overview",
"controls_endpoint": "/dashboard/fragment/controls",
"stream_endpoint": "/dashboard/stream/metrics", "stream_endpoint": "/dashboard/stream/metrics",
"overview_stream_endpoint": "/dashboard/stream/overview",
}, },
) )
@@ -85,6 +227,91 @@ async def dashboard_metrics(request: Request) -> HTMLResponse:
) )
@router.get("/dashboard/fragment/overview", response_class=HTMLResponse)
async def dashboard_overview(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/overview.html",
context={"request": request, **_dashboard_overview(request)},
)
@router.get("/dashboard/fragment/controls", response_class=HTMLResponse)
async def dashboard_controls(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="partials/controls.html",
context={"request": request, **_dashboard_controls(request)},
)
@router.post("/dashboard/control/start", response_class=HTMLResponse)
async def dashboard_control_start(request: Request) -> HTMLResponse:
controls = _dashboard_controls_state(request)
controls.is_running = True
controls.mark_updated()
return templates.TemplateResponse(
request=request,
name="partials/controls.html",
context={"request": request, **_dashboard_controls(request)},
)
@router.post("/dashboard/control/stop", response_class=HTMLResponse)
async def dashboard_control_stop(request: Request) -> HTMLResponse:
controls = _dashboard_controls_state(request)
controls.is_running = False
controls.mark_updated()
return templates.TemplateResponse(
request=request,
name="partials/controls.html",
context={"request": request, **_dashboard_controls(request)},
)
@router.post("/dashboard/control/kill-switch", response_class=HTMLResponse)
async def dashboard_control_kill_switch(request: Request) -> HTMLResponse:
controls = _dashboard_controls_state(request)
form = _parse_form_body(await request.body())
reason = form.get("reason") or "manual"
controls.kill_switch.activate(reason=reason)
controls.is_running = False
controls.mark_updated()
return templates.TemplateResponse(
request=request,
name="partials/controls.html",
context={"request": request, **_dashboard_controls(request)},
)
@router.post("/dashboard/control/config", response_class=HTMLResponse)
async def dashboard_control_config(request: Request) -> HTMLResponse:
controls = _dashboard_controls_state(request)
settings = request.app.state.settings
form = _parse_form_body(await request.body())
if "trade_capital_usd" in form and form["trade_capital_usd"]:
settings.trade_capital_usd = float(form["trade_capital_usd"])
if "max_trade_capital_usd" in form:
max_trade_capital_value = form["max_trade_capital_usd"].strip()
settings.max_trade_capital_usd = (
float(max_trade_capital_value) if max_trade_capital_value else None
)
if "max_concurrent_trades" in form:
max_concurrent_value = form["max_concurrent_trades"].strip()
settings.max_concurrent_trades = int(
max_concurrent_value) if max_concurrent_value else None
settings.paper_trading_mode = _form_bool(form.get("paper_trading_mode"))
controls.mark_updated()
return templates.TemplateResponse(
request=request,
name="partials/controls.html",
context={"request": request, **_dashboard_controls(request)},
)
@router.get("/dashboard/stream/metrics") @router.get("/dashboard/stream/metrics")
async def dashboard_metrics_stream(request: Request) -> StreamingResponse: async def dashboard_metrics_stream(request: Request) -> StreamingResponse:
fragment = ( fragment = (
@@ -104,6 +331,22 @@ async def dashboard_metrics_stream(request: Request) -> StreamingResponse:
return StreamingResponse(_event_stream(), media_type="text/event-stream") return StreamingResponse(_event_stream(), media_type="text/event-stream")
@router.get("/dashboard/stream/overview")
async def dashboard_overview_stream(request: Request) -> StreamingResponse:
fragment = (
templates.get_template("partials/overview.html")
.render(request=request, **_dashboard_overview(request))
.strip()
.replace("\n", "")
)
async def _event_stream() -> AsyncIterator[bytes]:
payload = json.dumps(fragment)
yield f"event: overview\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"})
+102 -2
View File
@@ -84,11 +84,46 @@ def _seed_metrics_data(app) -> None:
started, 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: async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
app = create_app( app = create_app(Settings(DUCKDB_PATH=tmp_path / "dash.duckdb"))
Settings(_env_file=None, DUCKDB_PATH=tmp_path / "dash.duckdb"))
_seed_metrics_data(app) _seed_metrics_data(app)
transport = httpx.ASGITransport(app=app) transport = httpx.ASGITransport(app=app)
@@ -96,10 +131,14 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
page = await client.get("/") page = await client.get("/")
fragment = await client.get("/dashboard/fragment/metrics") fragment = await client.get("/dashboard/fragment/metrics")
stream = await client.get("/dashboard/stream/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")
assert page.status_code == 200 assert page.status_code == 200
assert "EventSource" in page.text assert "EventSource" in page.text
assert 'hx-get="/dashboard/fragment/metrics"' in page.text assert 'hx-get="/dashboard/fragment/metrics"' in page.text
assert 'hx-get="/dashboard/fragment/controls"' in page.text
assert fragment.status_code == 200 assert fragment.status_code == 200
assert "Realized P&L" in fragment.text assert "Realized P&L" in fragment.text
@@ -110,3 +149,64 @@ async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
assert stream.headers["content-type"].startswith("text/event-stream") assert stream.headers["content-type"].startswith("text/event-stream")
assert "event: metrics" in stream.text assert "event: metrics" in stream.text
assert "Realized P&L" 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 "Paper trading mode" in controls.text
assert "Trade capital USD" in controls.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",
"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 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
+72
View File
@@ -68,19 +68,62 @@
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.toolbar form {
margin: 0;
}
.button { .button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
border: 0;
cursor: pointer;
padding: 10px 14px; padding: 10px 14px;
border-radius: 999px; border-radius: 999px;
background: #2d6cdf; background: #2d6cdf;
color: white; color: white;
text-decoration: none; text-decoration: none;
font: inherit;
} }
.button.secondary { .button.secondary {
background: transparent; background: transparent;
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid rgba(255, 255, 255, 0.14);
} }
.button.danger {
background: #ba3d4f;
}
.form-grid {
display: grid;
gap: 12px;
}
.field {
display: grid;
gap: 6px;
color: #9fb2d0;
font-size: 0.9rem;
}
.field input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: #e5eefb;
font: inherit;
}
.field.checkbox {
display: flex;
align-items: center;
gap: 8px;
}
.field.checkbox input {
width: auto;
}
.control-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
</style> </style>
</head> </head>
<body> <body>
@@ -113,6 +156,26 @@
{% include "partials/metrics.html" %} {% include "partials/metrics.html" %}
</section> </section>
<section
id="overview-shell"
hx-get="{{ overview_endpoint }}"
hx-target="this"
hx-trigger="load, every 10s"
hx-swap="outerHTML"
>
{% include "partials/overview.html" %}
</section>
<section
id="controls-shell"
hx-get="{{ controls_endpoint }}"
hx-target="this"
hx-trigger="load, every 20s"
hx-swap="outerHTML"
>
{% include "partials/controls.html" %}
</section>
<script> <script>
const stream = new EventSource("{{ stream_endpoint }}"); const stream = new EventSource("{{ stream_endpoint }}");
stream.addEventListener("metrics", (event) => { stream.addEventListener("metrics", (event) => {
@@ -121,6 +184,15 @@
panel.outerHTML = JSON.parse(event.data); panel.outerHTML = JSON.parse(event.data);
} }
}); });
const overviewStream = new EventSource(
"{{ overview_stream_endpoint }}",
);
overviewStream.addEventListener("overview", (event) => {
const panel = document.getElementById("overview-panel");
if (panel) {
panel.outerHTML = JSON.parse(event.data);
}
});
</script> </script>
</main> </main>
</body> </body>
+105
View File
@@ -0,0 +1,105 @@
<div id="controls-panel" class="panel" style="margin-top: 16px">
<div class="grid">
<article class="card">
<div class="label">Runtime Status</div>
<div class="value">{{ execution_status }}</div>
<div class="meta">Updated {{ updated_at }}</div>
</article>
<article class="card">
<div class="label">Kill Switch</div>
<div class="value">{{ kill_switch_status }}</div>
<div class="meta">Reason {{ kill_switch_reason }}</div>
</article>
<article class="card">
<div class="label">Config Snapshot</div>
<div class="meta">Paper trading: {{ paper_trading_mode }}</div>
<div class="meta">Trade capital: {{ trade_capital_usd }}</div>
<div class="meta">Max trade capital: {{ max_trade_capital_usd }}</div>
<div class="meta">Max concurrent trades: {{ max_concurrent_trades }}</div>
</article>
</div>
<div
class="grid"
style="
margin-top: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
"
>
<article class="card">
<div class="label">Execution Controls</div>
<div class="control-actions">
<form
hx-post="{{ start_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<button type="submit" class="button">Start</button>
</form>
<form
hx-post="{{ stop_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<button type="submit" class="button secondary">Stop</button>
</form>
<form
hx-post="{{ kill_switch_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<input type="hidden" name="reason" value="manual" />
<button type="submit" class="button danger">
Trigger Kill Switch
</button>
</form>
</div>
</article>
<article class="card">
<div class="label">Edit Config</div>
<form
class="form-grid"
hx-post="{{ config_endpoint }}"
hx-target="#controls-panel"
hx-swap="outerHTML"
>
<label class="field">
<span>Trade capital USD</span>
<input
name="trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max trade capital USD</span>
<input
name="max_trade_capital_usd"
type="number"
min="0"
step="0.01"
value="{{ max_trade_capital_usd_value }}"
/>
</label>
<label class="field">
<span>Max concurrent trades</span>
<input
name="max_concurrent_trades"
type="number"
min="1"
step="1"
value="{{ max_concurrent_trades_value }}"
/>
</label>
<label class="field checkbox">
{% set check = "checked" if paper_trading_mode == "enabled" else "" %}
<input name="paper_trading_mode" type="checkbox" {{ check }} />
<span>Paper trading mode</span>
</label>
<button type="submit" class="button">Save config</button>
</form>
</article>
</div>
</div>
+67
View File
@@ -0,0 +1,67 @@
<div id="overview-panel" class="panel" style="margin-top: 16px">
<div class="grid">
<article class="card">
<div class="label">Status</div>
<div class="value">{{ status }}</div>
</article>
<article class="card">
<div class="label">Balances</div>
<div class="value">{{ balances }}</div>
</article>
<article class="card">
<div class="label">Open Trades</div>
<div class="value">{{ open_trade_count }}</div>
</article>
<article class="card">
<div class="label">Realized P&amp;L</div>
<div class="value">{{ realized_pnl_total }}</div>
</article>
</div>
<div
class="grid"
style="
margin-top: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
"
>
<article class="card">
<div class="label">Open Trades</div>
<ul>
{% for trade in open_trades %}
<li>
{{ trade.trade_ref }} - {{ trade.status }} - {{ trade.cycle }} - {{
trade.started_at }}
</li>
{% else %}
<li>No open trades.</li>
{% endfor %}
</ul>
</article>
<article class="card">
<div class="label">Balances Snapshot</div>
<div
class="value"
style="font-size: 1rem; font-weight: 500; word-break: break-word"
>
{{ balances }}
</div>
<div class="meta">Total value {{ total_value }}</div>
</article>
<article class="card">
<div class="label">Opportunity Feed</div>
<ul>
{% for opp in opportunities %}
<li>
{{ opp.cycle }} - {{ opp.net_pct }} - {{ opp.est_profit }} - {{
opp.detected_at }}
</li>
{% else %}
<li>No opportunities.</li>
{% endfor %}
</ul>
</article>
</div>
<div class="meta">Updated {{ generated_at }}</div>
</div>