first commit
deploy GitHub Pages / deploy (push) Has been cancelled
tests / test (push) Has been cancelled

This commit is contained in:
2026-07-22 18:53:44 +08:00
commit 9259325d8a
60 changed files with 11081 additions and 0 deletions
View File
+184
View File
@@ -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 %}
&middot; <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>
+60
View File
@@ -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>