first commit
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
"""FastAPI + HTMX dashboard.
|
||||
|
||||
Single page. Server-rendered via Jinja. HTMX polls the fragment endpoints
|
||||
every few seconds so there's no client-side state and no build step.
|
||||
Endpoints:
|
||||
|
||||
GET / full page
|
||||
GET /fragments/opportunities table of recent opportunities
|
||||
GET /fragments/baskets table of open + recent baskets
|
||||
GET /fragments/pnl paper pnl summary + mode indicator
|
||||
POST /kill touch the kill switch file (immediate halt)
|
||||
POST /unkill remove the kill switch file
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from ..config import settings
|
||||
from ..db import db_conn, init_db
|
||||
|
||||
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="arbitrage dashboard", lifespan=_lifespan)
|
||||
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
|
||||
# Python 3.14 + Jinja2 LRUCache regression: disable caching.
|
||||
templates.env.cache = None
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request, "index.html", {"mode": settings.mode.value}
|
||||
)
|
||||
|
||||
@app.get("/fragments/opportunities", response_class=HTMLResponse)
|
||||
async def opps(request: Request) -> HTMLResponse:
|
||||
rows = await _recent_opportunities(limit=25)
|
||||
return templates.TemplateResponse(
|
||||
request, "fragments/opportunities.html", {"rows": rows}
|
||||
)
|
||||
|
||||
@app.get("/fragments/baskets", response_class=HTMLResponse)
|
||||
async def baskets(request: Request) -> HTMLResponse:
|
||||
rows = await _recent_baskets(limit=25)
|
||||
return templates.TemplateResponse(
|
||||
request, "fragments/baskets.html", {"rows": rows}
|
||||
)
|
||||
|
||||
@app.get("/fragments/pnl", response_class=HTMLResponse)
|
||||
async def pnl(request: Request) -> HTMLResponse:
|
||||
summary = await _paper_pnl_summary()
|
||||
kill_active = settings.kill_switch_file.exists()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"fragments/pnl.html",
|
||||
{
|
||||
"mode": settings.mode.value,
|
||||
"kill_active": kill_active,
|
||||
**summary,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/kill", response_class=HTMLResponse)
|
||||
async def kill(request: Request) -> HTMLResponse:
|
||||
settings.kill_switch_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
settings.kill_switch_file.touch(exist_ok=True)
|
||||
return await pnl(request)
|
||||
|
||||
@app.post("/unkill", response_class=HTMLResponse)
|
||||
async def unkill(request: Request) -> HTMLResponse:
|
||||
path = settings.kill_switch_file
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return await pnl(request)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _recent_opportunities(limit: int) -> list[dict]:
|
||||
async with db_conn() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT detected_at, event_title, sum_vwap_asks, net_edge_bps,
|
||||
max_baskets, expected_profit_usd, acted_on
|
||||
FROM opportunities
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"detected_at": r[0],
|
||||
"event_title": r[1],
|
||||
"sum_vwap_asks": r[2],
|
||||
"net_edge_bps": r[3],
|
||||
"max_baskets": r[4],
|
||||
"expected_profit_usd": r[5],
|
||||
"acted_on": bool(r[6]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def _recent_baskets(limit: int) -> list[dict]:
|
||||
async with db_conn() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT b.id, b.created_at, e.title, b.is_paper, b.basket_count,
|
||||
b.total_cost_usd, b.status, b.realized_pnl_usd
|
||||
FROM baskets b
|
||||
LEFT JOIN events e ON e.id = b.event_id
|
||||
ORDER BY b.created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0][:8],
|
||||
"created_at": r[1],
|
||||
"event_title": r[2] or "?",
|
||||
"is_paper": bool(r[3]),
|
||||
"basket_count": r[4],
|
||||
"total_cost_usd": r[5],
|
||||
"status": r[6],
|
||||
"realized_pnl_usd": r[7],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def _paper_pnl_summary() -> dict:
|
||||
async with db_conn() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN status='redeemed' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status='pending_resolution' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status='invalid' THEN 1 ELSE 0 END), 0)
|
||||
FROM baskets WHERE is_paper=1
|
||||
"""
|
||||
)
|
||||
counts = await cur.fetchone()
|
||||
cur = await conn.execute(
|
||||
"SELECT realized_pnl_usd FROM baskets "
|
||||
"WHERE is_paper=1 AND realized_pnl_usd IS NOT NULL"
|
||||
)
|
||||
pnl_rows = await cur.fetchall()
|
||||
total = Decimal(0)
|
||||
for (val,) in pnl_rows:
|
||||
if val is None:
|
||||
continue
|
||||
try:
|
||||
total += Decimal(val)
|
||||
except (ArithmeticError, ValueError):
|
||||
pass
|
||||
return {
|
||||
"redeemed": counts[0],
|
||||
"pending": counts[1],
|
||||
"failed": counts[2],
|
||||
"invalid": counts[3],
|
||||
"realized_pnl_usd": f"{total:.4f}",
|
||||
}
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,29 @@
|
||||
<h2>baskets</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>id</th>
|
||||
<th>event</th>
|
||||
<th>mode</th>
|
||||
<th class="num">count</th>
|
||||
<th class="num">cost</th>
|
||||
<th>status</th>
|
||||
<th class="num">pnl</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in rows %}
|
||||
<tr>
|
||||
<td>{{ b.id }}</td>
|
||||
<td>{{ b.event_title }}</td>
|
||||
<td>{{ "paper" if b.is_paper else "live" }}</td>
|
||||
<td class="num">{{ b.basket_count }}</td>
|
||||
<td class="num">{{ b.total_cost_usd }}</td>
|
||||
<td><span class="badge {{ b.status }}">{{ b.status }}</span></td>
|
||||
<td class="num {% if b.realized_pnl_usd and b.realized_pnl_usd.startswith('-') %}neg{% elif b.realized_pnl_usd %}pos{% endif %}">{{ b.realized_pnl_usd or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" style="color:#8b949e;">no baskets yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,29 @@
|
||||
<h2>recent opportunities</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>detected</th>
|
||||
<th>event</th>
|
||||
<th class="num">Σ asks</th>
|
||||
<th class="num">net bps</th>
|
||||
<th class="num">max baskets</th>
|
||||
<th class="num">expected $</th>
|
||||
<th>acted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td>{{ r.detected_at }}</td>
|
||||
<td>{{ r.event_title }}</td>
|
||||
<td class="num">{{ r.sum_vwap_asks }}</td>
|
||||
<td class="num {% if r.net_edge_bps > 0 %}pos{% else %}neg{% endif %}">{{ r.net_edge_bps }}</td>
|
||||
<td class="num">{{ r.max_baskets }}</td>
|
||||
<td class="num">{{ r.expected_profit_usd }}</td>
|
||||
<td>{% if r.acted_on %}<span class="badge redeemed">yes</span>{% else %}<span class="badge">no</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" style="color:#8b949e;">no opportunities yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,17 @@
|
||||
<h2>paper pnl</h2>
|
||||
<table>
|
||||
<tr><th>realized pnl (usd)</th><td class="num {% if realized_pnl_usd.startswith('-') %}neg{% else %}pos{% endif %}">{{ realized_pnl_usd }}</td></tr>
|
||||
<tr><th>redeemed</th><td class="num">{{ redeemed }}</td></tr>
|
||||
<tr><th>pending resolution</th><td class="num">{{ pending }}</td></tr>
|
||||
<tr><th>failed</th><td class="num">{{ failed }}</td></tr>
|
||||
<tr><th>invalid</th><td class="num">{{ invalid }}</td></tr>
|
||||
</table>
|
||||
<p style="margin-top: 0.75rem;">
|
||||
mode: <strong>{{ mode }}</strong>
|
||||
{% if kill_active %}
|
||||
· <span class="badge failed">KILL SWITCH ACTIVE</span>
|
||||
<button class="unkill" hx-post="/unkill" hx-target="#pnl" hx-swap="innerHTML">arm (remove kill)</button>
|
||||
{% else %}
|
||||
<button class="kill" hx-post="/kill" hx-target="#pnl" hx-swap="innerHTML">kill</button>
|
||||
{% endif %}
|
||||
</p>
|
||||
@@ -0,0 +1,60 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>arbitrage — {{ mode }}</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
margin: 0; padding: 1.5rem; background: #0e1117; color: #e6edf3; }
|
||||
h1 { font-size: 1.1rem; margin: 0 0 1rem; letter-spacing: 0.02em; }
|
||||
h1 .mode { padding: 0.15em 0.5em; border-radius: 4px; font-size: 0.8em;
|
||||
margin-left: 0.6em; background: #1f6feb; color: white; }
|
||||
h1 .mode.live { background: #da3633; }
|
||||
.grid { display: grid; gap: 1.25rem; grid-template-columns: 1fr; }
|
||||
@media (min-width: 960px) { .grid { grid-template-columns: 1fr 1fr; } }
|
||||
section { background: #161b22; border: 1px solid #30363d; border-radius: 6px;
|
||||
padding: 1rem; }
|
||||
section h2 { font-size: 0.85rem; margin: 0 0 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; color: #8b949e; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th, td { text-align: left; padding: 0.4rem 0.55rem; border-bottom: 1px solid #21262d; }
|
||||
th { color: #8b949e; font-weight: 600; }
|
||||
.num { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.pos { color: #3fb950; }
|
||||
.neg { color: #f85149; }
|
||||
.badge { padding: 0.1em 0.45em; border-radius: 4px; font-size: 0.75em; background: #30363d; }
|
||||
.badge.redeemed { background: #1f6f3e; }
|
||||
.badge.failed { background: #8b1a1a; }
|
||||
.badge.pending_resolution { background: #5a4200; }
|
||||
.badge.invalid { background: #3b1f5a; }
|
||||
button { background: #21262d; color: #e6edf3; border: 1px solid #30363d;
|
||||
padding: 0.4em 0.9em; border-radius: 4px; cursor: pointer; }
|
||||
button.kill { background: #8b1a1a; border-color: #da3633; }
|
||||
button.unkill { background: #1f6f3e; border-color: #3fb950; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>arbitrage
|
||||
<span class="mode {% if mode == 'live' %}live{% endif %}">{{ mode }}</span>
|
||||
</h1>
|
||||
|
||||
<div class="grid">
|
||||
<section id="pnl"
|
||||
hx-get="/fragments/pnl" hx-trigger="load, every 3s" hx-swap="innerHTML">
|
||||
loading...
|
||||
</section>
|
||||
|
||||
<section id="baskets"
|
||||
hx-get="/fragments/baskets" hx-trigger="load, every 3s" hx-swap="innerHTML">
|
||||
loading...
|
||||
</section>
|
||||
|
||||
<section id="opps" style="grid-column: 1 / -1;"
|
||||
hx-get="/fragments/opportunities" hx-trigger="load, every 2s" hx-swap="innerHTML">
|
||||
loading...
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user