|
|
@@ -1,25 +1,22 @@
|
|
|
from fastapi import APIRouter
|
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
|
|
-from .store import list_concerns, latest_cycle, latest_regime_samples
|
|
|
+from .store import latest_cycle, latest_regime_samples
|
|
|
|
|
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
|
|
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
|
def overview():
|
|
|
- concerns = list_concerns()
|
|
|
cycle = latest_cycle() or {}
|
|
|
+ concerns = []
|
|
|
regimes = latest_regime_samples(10)
|
|
|
+ concern_rows = "<tr><td colspan='5' class='muted'>Loading live data…</td></tr>"
|
|
|
regime_rows = "".join(
|
|
|
f"<tr><td>{r.get('concern_id','')}</td><td>{r.get('timeframe','')}</td><td><pre style='white-space:pre-wrap;margin:0'>{r.get('regime_json','')}</pre></td><td>{r.get('captured_at','')}</td></tr>"
|
|
|
for r in regimes
|
|
|
) or "<tr><td colspan='4' class='muted'>No regime samples yet.</td></tr>"
|
|
|
- concern_rows = "".join(
|
|
|
- f"<tr><td>{c.get('id','')}</td><td>{c.get('account_id','')}</td><td>{c.get('market_symbol','')}</td><td>{c.get('base_currency','')}</td><td>{c.get('quote_currency','')}</td><td>{c.get('strategy_id','')}</td><td>{c.get('source','')}</td><td>{c.get('status','')}</td></tr>"
|
|
|
- for c in concerns
|
|
|
- ) or "<tr><td colspan='8' class='muted'>No concerns yet.</td></tr>"
|
|
|
- return """
|
|
|
+ template = """
|
|
|
<html>
|
|
|
<head>
|
|
|
<title>Hermes MCP Dashboard</title>
|
|
|
@@ -36,7 +33,89 @@ def overview():
|
|
|
.nav {{ display:flex; gap:10px; flex-wrap:wrap; margin: 10px 0 18px; }}
|
|
|
.nav a {{ text-decoration:none; border:1px solid #d1d5db; padding:8px 10px; border-radius:8px; color:#111827; background:#fff; }}
|
|
|
pre {{ white-space: pre-wrap; margin: 0; }}
|
|
|
+ .grid {{ display:grid; grid-template-columns: 1fr; gap: 16px; }}
|
|
|
+ .small {{ font-size: 0.92rem; color:#4b5563; }}
|
|
|
+ .regime-grid {{ display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }}
|
|
|
+ .regime-card {{ border:1px solid #e5e7eb; border-radius: 14px; padding: 10px; background: linear-gradient(180deg, #fff, #fafafa); min-width: 0; }}
|
|
|
+ .chips {{ display:flex; gap:6px; flex-wrap:wrap; margin: 8px 0; }}
|
|
|
+ .chip {{ display:inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }}
|
|
|
+ .good {{ background:#dcfce7; color:#166534; }}
|
|
|
+ .warn {{ background:#fef3c7; color:#92400e; }}
|
|
|
+ .bad {{ background:#fee2e2; color:#991b1b; }}
|
|
|
+ .neutral {{ background:#e5e7eb; color:#374151; }}
|
|
|
+ .spark {{ width:100%; height:40px; display:block; margin-top:8px; }}
|
|
|
+ .spark-block {{ margin-top: 8px; }}
|
|
|
+ .spark-label {{ display:flex; justify-content:space-between; gap:8px; font-size:12px; color:#6b7280; }}
|
|
|
</style>
|
|
|
+ <script>
|
|
|
+ function regimeColor(state) {{
|
|
|
+ const s = String(state || '').toLowerCase();
|
|
|
+ if (['bull', 'bullish', 'strong', 'up', 'positive'].includes(s)) return 'good';
|
|
|
+ if (['bear', 'bearish', 'down', 'negative'].includes(s)) return 'bad';
|
|
|
+ if (['neutral', 'range', 'chop', 'sideways'].includes(s)) return 'neutral';
|
|
|
+ return 'warn';
|
|
|
+ }}
|
|
|
+ function sparkline(values, stroke='#2563eb') {{
|
|
|
+ if (!values.length) return '';
|
|
|
+ const min = Math.min(...values), max = Math.max(...values);
|
|
|
+ const span = (max - min) || 1;
|
|
|
+ const points = values.map((v, i) => `${{(i/(values.length-1||1))*100}},${{40 - ((v-min)/span)*40}}`).join(' ');
|
|
|
+ return `<svg class='spark' viewBox='0 0 100 40' preserveAspectRatio='none'><polyline fill='none' stroke='${{stroke}}' stroke-width='2' points='${{points}}' /></svg>`;
|
|
|
+ }}
|
|
|
+ async function refreshData() {{
|
|
|
+ const res = await fetch('/dashboard/data', {{ cache: 'no-store' }});
|
|
|
+ const data = await res.json();
|
|
|
+ document.getElementById('cycle-status').textContent = data.latest_cycle?.status || 'none';
|
|
|
+ document.getElementById('cycle-started').textContent = data.latest_cycle?.started_at || '-';
|
|
|
+ document.getElementById('cycle-finished').textContent = data.latest_cycle?.finished_at || '-';
|
|
|
+ document.getElementById('cycle-notes').textContent = data.latest_cycle?.notes || '-';
|
|
|
+ document.getElementById('concern-count').textContent = String(data.concerns.length);
|
|
|
+ document.getElementById('concerns-body').innerHTML = data.concerns.map(c => `
|
|
|
+ <tr>
|
|
|
+ <td><strong>${{c.account_display || ''}}</strong><div class='small'>${{c.id || ''}}</div></td>
|
|
|
+ <td>${{c.market_display || c.market_symbol || ''}}<div class='small'>${{c.market_description || ''}}</div></td>
|
|
|
+ <td>${{c.balance_summary || '-'}}<div class='small'>Total value: ${{typeof c.total_value_usd === 'number' ? c.total_value_usd.toFixed(2) : '-'}}</div></td>
|
|
|
+ <td>${{c.source || ''}}</td>
|
|
|
+ <td>${{c.status || ''}}</td>
|
|
|
+ </tr>`).join('') || "<tr><td colspan='5' class='muted'>No concerns yet.</td></tr>";
|
|
|
+ const histories = data.regime_histories || {};
|
|
|
+ const desiredOrder = ['1d', '4h', '1h', '15m', '5m', '1m'];
|
|
|
+ const samples = data.regime_samples || [];
|
|
|
+ const cards = desiredOrder.map(tf => samples.find(r => String(r.timeframe || '').toLowerCase() === tf)).filter(Boolean).map(r => {{
|
|
|
+ const parsed = (() => {{ try {{ return JSON.parse(r.regime_json); }} catch {{ return {{}}; }} }})();
|
|
|
+ const trend = parsed.trend?.state || 'neutral';
|
|
|
+ const momentum = parsed.momentum?.state || 'neutral';
|
|
|
+ const reversal = parsed.reversal?.direction || 'none';
|
|
|
+ const strength = parsed.reversal?.score ?? 0;
|
|
|
+ const rawMarket = r.market_display || r.market_symbol || 'Market';
|
|
|
+ const tf = String(r.timeframe || '').trim();
|
|
|
+ const market = tf && rawMarket.toLowerCase().endsWith(tf.toLowerCase())
|
|
|
+ ? rawMarket.slice(0, -tf.length).trim().replace(/[·\\-\\s]+$/, '').trim()
|
|
|
+ : rawMarket;
|
|
|
+ const title = tf ? `${{market}} · ${{tf}}` : market;
|
|
|
+ const key = `${{r.concern_id}}::${{r.timeframe}}`;
|
|
|
+ const hist = (histories[key] || []).map(x => {{ try {{ return JSON.parse(x.regime_json); }} catch {{ return null; }} }}).filter(Boolean);
|
|
|
+ const prices = hist.map(x => Number(x.price)).filter(Number.isFinite).slice(-24);
|
|
|
+ const atrs = hist.map(x => Number(x.volatility?.atr_percent)).filter(Number.isFinite).slice(-24);
|
|
|
+ const rsis = hist.map(x => Number(x.momentum?.rsi)).filter(Number.isFinite).slice(-24);
|
|
|
+ return `
|
|
|
+ <div class='regime-card'>
|
|
|
+ <div><strong>${{title}}</strong></div>
|
|
|
+ <div class='chips'>
|
|
|
+ <span class='chip ${{regimeColor(trend)}}'>trend: ${{trend}}</span>
|
|
|
+ <span class='chip ${{regimeColor(momentum)}}'>momentum: ${{momentum}}</span>
|
|
|
+ <span class='chip ${{regimeColor(reversal)}}'>reversal: ${{reversal}}</span>
|
|
|
+ <span class='chip neutral'>strength: ${{strength}}</span>
|
|
|
+ </div>
|
|
|
+ <div class='spark-block'><div class='spark-label'><span>Price: ${{parsed.price ?? '-'}}</span><span>${{prices.length}} / 24</span></div>${{sparkline(prices, '#2563eb')}}</div>
|
|
|
+ <div class='spark-block'><div class='spark-label'><span>ATR %: ${{parsed.volatility?.atr_percent ?? '-'}}</span><span>${{atrs.length}} / 24</span></div>${{sparkline(atrs, '#d97706')}}</div>
|
|
|
+ <div class='spark-block'><div class='spark-label'><span>Momentum RSI: ${{parsed.momentum?.rsi ?? '-'}}</span><span>${{rsis.length}} / 24</span></div>${{sparkline(rsis, '#16a34a')}}</div>
|
|
|
+ </div>`;
|
|
|
+ }}).join('') || "<div class='muted'>No regime samples yet.</div>";
|
|
|
+ document.getElementById('regimes-body').innerHTML = `<div class='regime-grid'>${{cards}}</div>`;
|
|
|
+ }}
|
|
|
+ window.addEventListener('load', () => {{ refreshData(); setInterval(refreshData, 15000); }});
|
|
|
+ </script>
|
|
|
</head>
|
|
|
<body>
|
|
|
<div class="page"><div class="card">
|
|
|
@@ -47,29 +126,31 @@ def overview():
|
|
|
<a href="/dashboard/tech">Tech monitor</a>
|
|
|
</div>
|
|
|
<h2>Last poll</h2>
|
|
|
- <p><span class="pill">{cycle_status}</span></p>
|
|
|
- <p><strong>started:</strong> {cycle_started}</p>
|
|
|
- <p><strong>finished:</strong> {cycle_finished}</p>
|
|
|
- <p><strong>notes:</strong> {cycle_notes}</p>
|
|
|
+ <p><span class="pill" id="cycle-status">__CYCLE_STATUS__</span></p>
|
|
|
+ <p><strong>started:</strong> <span id="cycle-started">__CYCLE_STARTED__</span></p>
|
|
|
+ <p><strong>finished:</strong> <span id="cycle-finished">__CYCLE_FINISHED__</span></p>
|
|
|
+ <p><strong>notes:</strong> <span id="cycle-notes">__CYCLE_NOTES__</span></p>
|
|
|
+ <p class="small"><span id="concern-count">__CONCERN_COUNT__</span> concerns</p>
|
|
|
<h2>Concerns</h2>
|
|
|
<table>
|
|
|
- <tr><th>id</th><th>account</th><th>market</th><th>base</th><th>quote</th><th>strategy</th><th>source</th><th>status</th></tr>
|
|
|
- {concern_rows}
|
|
|
+ <tr><th>account</th><th>market</th><th>balances</th><th>source</th><th>status</th></tr>
|
|
|
+ <tbody id="concerns-body">__CONCERN_ROWS__</tbody>
|
|
|
</table>
|
|
|
<h2>Latest regime samples</h2>
|
|
|
- <table>
|
|
|
- <tr><th>concern</th><th>timeframe</th><th>regime</th><th>captured</th></tr>
|
|
|
- {regime_rows}
|
|
|
- </table>
|
|
|
+ <div id="regimes-body">__REGIME_ROWS__</div>
|
|
|
</div></div>
|
|
|
</body></html>
|
|
|
- """.format(
|
|
|
- cycle_status=cycle.get("status", "none"),
|
|
|
- cycle_started=cycle.get("started_at", "-"),
|
|
|
- cycle_finished=cycle.get("finished_at", "-"),
|
|
|
- cycle_notes=cycle.get("notes", "-"),
|
|
|
- concern_rows=concern_rows,
|
|
|
- regime_rows=regime_rows,
|
|
|
+ """
|
|
|
+ template = template.replace("{{", "{").replace("}}", "}")
|
|
|
+ return HTMLResponse(
|
|
|
+ template
|
|
|
+ .replace("__CYCLE_STATUS__", cycle.get("status", "none"))
|
|
|
+ .replace("__CYCLE_STARTED__", cycle.get("started_at", "-"))
|
|
|
+ .replace("__CYCLE_FINISHED__", cycle.get("finished_at", "-"))
|
|
|
+ .replace("__CYCLE_NOTES__", cycle.get("notes", "-"))
|
|
|
+ .replace("__CONCERN_COUNT__", str(len(concerns)))
|
|
|
+ .replace("__CONCERN_ROWS__", concern_rows)
|
|
|
+ .replace("__REGIME_ROWS__", regime_rows)
|
|
|
)
|
|
|
|
|
|
|