|
|
@@ -104,6 +104,39 @@ def overview():
|
|
|
if (['suspend_grid', 'replace_with_exposure_protector', 'replace_with_trend_follower'].includes(v)) return 'warn';
|
|
|
return 'neutral';
|
|
|
}}
|
|
|
+ function formatLocalTime(value) {{
|
|
|
+ try {{
|
|
|
+ return new Intl.DateTimeFormat('de-AT', {{
|
|
|
+ timeZone: 'Europe/Vienna',
|
|
|
+ dateStyle: 'short',
|
|
|
+ timeStyle: 'medium',
|
|
|
+ }}).format(new Date(value));
|
|
|
+ }} catch {{
|
|
|
+ return String(value || '');
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+ function decisionChanges(rows) {{
|
|
|
+ const grouped = new Map();
|
|
|
+ for (const row of rows || []) {{
|
|
|
+ const key = String(row.concern_id || '');
|
|
|
+ if (!key) continue;
|
|
|
+ if (!grouped.has(key)) grouped.set(key, []);
|
|
|
+ grouped.get(key).push(row);
|
|
|
+ }}
|
|
|
+ const out = [];
|
|
|
+ for (const list of grouped.values()) {{
|
|
|
+ const sorted = list.slice().sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')));
|
|
|
+ for (let i = 1; i < sorted.length; i++) {{
|
|
|
+ const prev = sorted[i - 1];
|
|
|
+ const cur = sorted[i];
|
|
|
+ const fields = ['mode', 'action', 'target_strategy', 'reason_summary'];
|
|
|
+ const diffs = fields.filter(f => String(cur?.[f] ?? '') !== String(prev?.[f] ?? ''));
|
|
|
+ if (!diffs.length) continue;
|
|
|
+ out.push({{ cur, prev, diffs }});
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+ return out.sort((a, b) => String(b.cur.created_at || '').localeCompare(String(a.cur.created_at || '')));
|
|
|
+ }}
|
|
|
async function refreshData() {{
|
|
|
const res = await fetch('/dashboard/data', {{ cache: 'no-store' }});
|
|
|
const data = await res.json();
|
|
|
@@ -244,6 +277,7 @@ def overview():
|
|
|
<p class="muted">Overview, signals, features, narrative, decision, explanation.</p>
|
|
|
<div class="nav">
|
|
|
<a href="/dashboard/">Overview</a>
|
|
|
+ <a href="/dashboard/changes">Decision changes</a>
|
|
|
<a href="/dashboard/tech">Tech monitor</a>
|
|
|
</div>
|
|
|
<h2>Last poll</h2>
|
|
|
@@ -293,6 +327,155 @@ def overview():
|
|
|
)
|
|
|
|
|
|
|
|
|
+@router.get("/changes", response_class=HTMLResponse)
|
|
|
+def changes():
|
|
|
+ return """
|
|
|
+ <html>
|
|
|
+ <head>
|
|
|
+ <title>Hermes Decision Changes</title>
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
+ <style>
|
|
|
+ body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 0; color: #111827; background: #fff; }
|
|
|
+ .page { width: 100%; display: flex; justify-content: center; }
|
|
|
+ .card { width: min(1600px, calc(100vw - 2rem)); margin: 1rem auto; padding: 1.25rem; border: 1px solid #e5e7eb; border-radius: 12px; }
|
|
|
+ .muted { color: #6b7280; }
|
|
|
+ table { width: 100%; border-collapse: collapse; margin-top: 14px; }
|
|
|
+ th, td { border-bottom: 1px solid #e5e7eb; padding: 10px 8px; text-align: left; vertical-align: top; }
|
|
|
+ th { background: #f9fafb; }
|
|
|
+ .pill { display:inline-block; padding:2px 10px; border-radius:999px; background:#f3f4f6; font-size: 0.9em; }
|
|
|
+ .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; }
|
|
|
+ .good { background:#dcfce7; color:#166534; }
|
|
|
+ .warn { background:#fef3c7; color:#92400e; }
|
|
|
+ .bad { background:#fee2e2; color:#991b1b; }
|
|
|
+ .info { background:#dbeafe; color:#1d4ed8; }
|
|
|
+ .neutral { background:#e5e7eb; color:#374151; }
|
|
|
+ .small { font-size: 0.92rem; color:#4b5563; }
|
|
|
+ .recent-change td { background:#eff6ff; transition: background 0.3s ease; }
|
|
|
+ .focus-cell { border-left: 4px solid #3b82f6; }
|
|
|
+ </style>
|
|
|
+ <script>
|
|
|
+ function modeChip(value) {
|
|
|
+ const v = String(value || '').toLowerCase();
|
|
|
+ if (['act', 'active', 'running', 'keep_grid', 'keep_trend', 'keep_rebalancer'].includes(v)) return 'good';
|
|
|
+ if (['observe', 'wait', 'warn'].includes(v)) return 'info';
|
|
|
+ if (['suspend_grid', 'replace_with_exposure_protector', 'replace_with_trend_follower'].includes(v)) return 'warn';
|
|
|
+ return 'neutral';
|
|
|
+ }
|
|
|
+ function formatLocalTime(value) {
|
|
|
+ try {
|
|
|
+ return new Intl.DateTimeFormat('de-AT', {
|
|
|
+ timeZone: 'Europe/Vienna',
|
|
|
+ dateStyle: 'short',
|
|
|
+ timeStyle: 'medium',
|
|
|
+ }).format(new Date(value));
|
|
|
+ } catch {
|
|
|
+ return String(value || '');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ function decisionChanges(rows) {
|
|
|
+ const grouped = new Map();
|
|
|
+ for (const row of rows || []) {
|
|
|
+ const key = String(row.concern_id || '');
|
|
|
+ if (!key) continue;
|
|
|
+ if (!grouped.has(key)) grouped.set(key, []);
|
|
|
+ grouped.get(key).push(row);
|
|
|
+ }
|
|
|
+ const out = [];
|
|
|
+ for (const list of grouped.values()) {
|
|
|
+ const sorted = list.slice().sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')));
|
|
|
+ for (let i = 1; i < sorted.length; i++) {
|
|
|
+ const prev = sorted[i - 1];
|
|
|
+ const cur = sorted[i];
|
|
|
+ const fields = ['mode', 'action', 'target_strategy', 'reason_summary'];
|
|
|
+ const diffs = fields.filter(f => String(cur?.[f] ?? '') !== String(prev?.[f] ?? ''));
|
|
|
+ if (!diffs.length) continue;
|
|
|
+ out.push({ cur, prev, diffs });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out.sort((a, b) => String(b.cur.created_at || '').localeCompare(String(a.cur.created_at || '')));
|
|
|
+ }
|
|
|
+ function parsePayload(row) {
|
|
|
+ try { return JSON.parse(row.target_policy_json || '{}'); } catch { return {}; }
|
|
|
+ }
|
|
|
+ function parseState(row) {
|
|
|
+ try { return JSON.parse(row.payload_json || '{}'); } catch { return {}; }
|
|
|
+ }
|
|
|
+ function strategyLabel(payload, strategyId) {
|
|
|
+ const ranking = payload.strategy_fit_ranking || [];
|
|
|
+ const match = ranking.find(x => String(x.strategy_id || '') === String(strategyId || ''));
|
|
|
+ return match?.strategy_type || (strategyId ? String(strategyId).slice(0, 8) : '-');
|
|
|
+ }
|
|
|
+ function priceFromState(statePayload) {
|
|
|
+ const raw = statePayload?.features_by_timeframe?.['1m']?.raw || {};
|
|
|
+ return Number.isFinite(Number(raw.price)) ? Number(raw.price) : null;
|
|
|
+ }
|
|
|
+ async function refreshData() {
|
|
|
+ const res = await fetch('/dashboard/data', { cache: 'no-store' });
|
|
|
+ const data = await res.json();
|
|
|
+ const concernsById = new Map((data.concerns || []).map(c => [String(c.id || ''), c]));
|
|
|
+ const stateByCycle = new Map((data.state_history || []).map(s => [String(s.cycle_id || ''), s]));
|
|
|
+ const rows = decisionChanges(data.decision_history || []);
|
|
|
+ document.getElementById('count').textContent = String(rows.length);
|
|
|
+ document.getElementById('changes-body').innerHTML = rows.map(({ cur, prev, diffs }) => {
|
|
|
+ const payload = parsePayload(cur);
|
|
|
+ const wallet = payload.wallet_state || {};
|
|
|
+ const ranking = payload.strategy_fit_ranking || [];
|
|
|
+ const top = ranking[0] || {};
|
|
|
+ const concern = concernsById.get(String(cur.concern_id || '')) || {};
|
|
|
+ const statePayload = parseState(stateByCycle.get(String(cur.cycle_id || '')) || {});
|
|
|
+ const price = priceFromState(statePayload);
|
|
|
+ const targetLabel = strategyLabel(payload, cur.target_strategy || '');
|
|
|
+ const breakout = payload.grid_breakout_pressure || {};
|
|
|
+ const changed = diffs.map(f => `${f}`).join(', ');
|
|
|
+ const baseAvailable = Number(wallet.base_available);
|
|
|
+ const baseReserved = Number(wallet.base_reserved);
|
|
|
+ const quoteAvailable = Number(wallet.quote_available);
|
|
|
+ const quoteReserved = Number(wallet.quote_reserved);
|
|
|
+ const baseTotal = (Number.isFinite(baseAvailable) ? baseAvailable : 0) + (Number.isFinite(baseReserved) ? baseReserved : 0);
|
|
|
+ const quoteTotal = (Number.isFinite(quoteAvailable) ? quoteAvailable : 0) + (Number.isFinite(quoteReserved) ? quoteReserved : 0);
|
|
|
+ const baseValue = Number.isFinite(baseTotal) && Number.isFinite(price) ? baseTotal * price : Number(wallet.base_value);
|
|
|
+ const quoteValue = Number.isFinite(quoteTotal) ? quoteTotal : Number(wallet.quote_value);
|
|
|
+ const totalValue = Number.isFinite(baseValue) && Number.isFinite(quoteValue) ? baseValue + quoteValue : Number(wallet.total_value);
|
|
|
+ return `
|
|
|
+ <tr class='recent-change'>
|
|
|
+ <td class='focus-cell'>${formatLocalTime(cur.created_at || cur.applied_at || '')}</td>
|
|
|
+ <td><strong>${concern.account_display || ''}</strong><div class='small'>${concern.id || cur.concern_id || ''}</div></td>
|
|
|
+ <td>${concern.market_display || concern.market_symbol || ''}<div class='small'>${concern.market_description || ''}</div></td>
|
|
|
+ <td>${wallet.base_currency || concern.base_currency || 'base'} total ${Number.isFinite(baseTotal) ? baseTotal.toFixed(4) : '-'} / ${wallet.quote_currency || concern.quote_currency || 'quote'} total ${Number.isFinite(quoteTotal) ? quoteTotal.toFixed(4) : '-'}<div class='small'>Base value: ${Number.isFinite(baseValue) ? baseValue.toFixed(4) : '-'} · Quote value: ${Number.isFinite(quoteValue) ? quoteValue.toFixed(4) : '-'} · Total: ${Number.isFinite(totalValue) ? totalValue.toFixed(4) : '-'}</div></td>
|
|
|
+ <td>${typeof price === 'number' ? price.toFixed(4) : '-'}</td>
|
|
|
+ <td>
|
|
|
+ <div><span class='pill ${modeChip(cur.action)}'>${cur.action || ''}</span></div>
|
|
|
+ <div class='small'>to ${targetLabel || cur.target_strategy || '-'}</div>
|
|
|
+ </td>
|
|
|
+ <td>${changed}</td>
|
|
|
+ <td>${cur.reason_summary || ''}</td>
|
|
|
+ <td>${typeof cur.confidence === 'number' ? cur.confidence.toFixed(2) : ''}</td>
|
|
|
+ </tr>`;
|
|
|
+ }).join('') || "<tr><td colspan='9' class='muted'>No decision changes yet.</td></tr>";
|
|
|
+ }
|
|
|
+ window.addEventListener('load', () => { refreshData(); setInterval(refreshData, 15000); });
|
|
|
+ </script>
|
|
|
+ </head>
|
|
|
+ <body>
|
|
|
+ <div class="page"><div class="card">
|
|
|
+ <h1>Hermes Decision Changes</h1>
|
|
|
+ <p class="muted">Only rows where the decision changed from the previous state for that concern.</p>
|
|
|
+ <div class="nav">
|
|
|
+ <a href="/dashboard/">Overview</a>
|
|
|
+ <a href="/dashboard/changes">Decision changes</a>
|
|
|
+ <a href="/dashboard/tech">Tech monitor</a>
|
|
|
+ </div>
|
|
|
+ <p class="small"><span id="count">0</span> changes</p>
|
|
|
+ <table>
|
|
|
+ <tr><th>time</th><th>account</th><th>market</th><th>balances</th><th>price</th><th>action</th><th>changed fields</th><th>reason</th><th>confidence</th></tr>
|
|
|
+ <tbody id="changes-body"><tr><td colspan='9' class='muted'>Loading live data…</td></tr></tbody>
|
|
|
+ </table>
|
|
|
+ </div></div>
|
|
|
+ </body></html>
|
|
|
+ """
|
|
|
+
|
|
|
+
|
|
|
@router.get("/tech", response_class=HTMLResponse)
|
|
|
def tech():
|
|
|
return """
|
|
|
@@ -311,6 +494,11 @@ def tech():
|
|
|
<div class="page"><div class="card">
|
|
|
<h1>Tech Monitor</h1>
|
|
|
<p class="muted">Signals, features, state, narrative, decision, explanation.</p>
|
|
|
+ <div class="nav">
|
|
|
+ <a href="/dashboard/">Overview</a>
|
|
|
+ <a href="/dashboard/changes">Decision changes</a>
|
|
|
+ <a href="/dashboard/tech">Tech monitor</a>
|
|
|
+ </div>
|
|
|
</div></div>
|
|
|
</body></html>
|
|
|
"""
|