feat: Enhance dashboard with live overview panel and control features
This commit is contained in:
@@ -23,3 +23,5 @@
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from arbitrade.api.control_state import DashboardControlState
|
||||
from arbitrade.api.routes import router
|
||||
from arbitrade.config.settings import Settings
|
||||
from arbitrade.logging_setup import configure_logging
|
||||
@@ -16,7 +17,11 @@ def create_app(settings: Settings) -> FastAPI:
|
||||
db.migrate()
|
||||
|
||||
app = FastAPI(title="arbitrade", version="0.1.0")
|
||||
app.state.settings = settings
|
||||
app.state.store = db
|
||||
app.state.metrics = MetricsCalculator(db)
|
||||
app.state.dashboard_controls = DashboardControlState(
|
||||
is_running=not settings.kill_switch_active,
|
||||
)
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
@@ -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)
|
||||
@@ -4,11 +4,15 @@ import json
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from arbitrade.api.control_state import DashboardControlState
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(
|
||||
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)
|
||||
async def home(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
@@ -57,7 +193,10 @@ async def home(request: Request) -> HTMLResponse:
|
||||
"title": "Arbitrade Dashboard",
|
||||
"request": request,
|
||||
"metrics_endpoint": "/dashboard/fragment/metrics",
|
||||
"overview_endpoint": "/dashboard/fragment/overview",
|
||||
"controls_endpoint": "/dashboard/fragment/controls",
|
||||
"stream_endpoint": "/dashboard/stream/metrics",
|
||||
"overview_stream_endpoint": "/dashboard/stream/overview",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -71,7 +210,10 @@ async def dashboard(request: Request) -> HTMLResponse:
|
||||
"title": "Arbitrade Dashboard",
|
||||
"request": request,
|
||||
"metrics_endpoint": "/dashboard/fragment/metrics",
|
||||
"overview_endpoint": "/dashboard/fragment/overview",
|
||||
"controls_endpoint": "/dashboard/fragment/controls",
|
||||
"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")
|
||||
async def dashboard_metrics_stream(request: Request) -> StreamingResponse:
|
||||
fragment = (
|
||||
@@ -104,6 +331,22 @@ async def dashboard_metrics_stream(request: Request) -> StreamingResponse:
|
||||
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)
|
||||
async def health() -> JSONResponse:
|
||||
return JSONResponse({"status": "ok", "service": "arbitrade"})
|
||||
|
||||
+102
-2
@@ -84,11 +84,46 @@ def _seed_metrics_data(app) -> None:
|
||||
started,
|
||||
],
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_snapshots (
|
||||
snapshot_at,
|
||||
balances,
|
||||
total_value_usd
|
||||
) VALUES (?, ?, ?)
|
||||
""",
|
||||
[started, '{"USD": 1000.0, "BTC": 0.25}', 1250.0],
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO trades (
|
||||
trade_ref,
|
||||
started_at,
|
||||
finished_at,
|
||||
status,
|
||||
realized_pnl,
|
||||
estimated_pnl,
|
||||
capital_used,
|
||||
cycle,
|
||||
leg_count
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
"trade-open",
|
||||
started,
|
||||
None,
|
||||
"open",
|
||||
None,
|
||||
5.0,
|
||||
50.0,
|
||||
"USD->BTC->ETH->USD",
|
||||
3,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def test_dashboard_page_and_fragment_and_sse(tmp_path) -> None:
|
||||
app = create_app(
|
||||
Settings(_env_file=None, DUCKDB_PATH=tmp_path / "dash.duckdb"))
|
||||
app = create_app(Settings(DUCKDB_PATH=tmp_path / "dash.duckdb"))
|
||||
_seed_metrics_data(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("/")
|
||||
fragment = await client.get("/dashboard/fragment/metrics")
|
||||
stream = await client.get("/dashboard/stream/metrics")
|
||||
overview = await client.get("/dashboard/fragment/overview")
|
||||
overview_stream = await client.get("/dashboard/stream/overview")
|
||||
controls = await client.get("/dashboard/fragment/controls")
|
||||
|
||||
assert page.status_code == 200
|
||||
assert "EventSource" 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 "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 "event: metrics" in stream.text
|
||||
assert "Realized P&L" in stream.text
|
||||
|
||||
assert overview.status_code == 200
|
||||
assert "live" in overview.text
|
||||
assert "Balances Snapshot" in overview.text
|
||||
assert "Open Trades" in overview.text
|
||||
assert "Opportunity Feed" in overview.text
|
||||
assert "1250.00 USD" in overview.text
|
||||
assert "trade-open" in overview.text
|
||||
|
||||
assert overview_stream.status_code == 200
|
||||
assert overview_stream.headers["content-type"].startswith(
|
||||
"text/event-stream")
|
||||
assert "event: overview" in overview_stream.text
|
||||
assert "trade-open" in overview_stream.text
|
||||
|
||||
assert controls.status_code == 200
|
||||
assert "Runtime Status" in controls.text
|
||||
assert ">running<" in controls.text
|
||||
assert "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
|
||||
|
||||
@@ -68,19 +68,62 @@
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar form {
|
||||
margin: 0;
|
||||
}
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
background: #2d6cdf;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font: inherit;
|
||||
}
|
||||
.button.secondary {
|
||||
background: transparent;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -113,6 +156,26 @@
|
||||
{% include "partials/metrics.html" %}
|
||||
</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>
|
||||
const stream = new EventSource("{{ stream_endpoint }}");
|
||||
stream.addEventListener("metrics", (event) => {
|
||||
@@ -121,6 +184,15 @@
|
||||
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>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -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>
|
||||
@@ -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&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>
|
||||
Reference in New Issue
Block a user