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 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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