Some checks are pending
CI / lint-and-test (push) Waiting to run
dashboard_portfolio.py: - datetime.utcnow() → datetime.now(timezone.utc) (deprecation fix) - days parameter validation with try/except + min(..., 365) on 2 endpoints fetch_coins.py: - isinstance(chain, str) guard prevents AttributeError on string chain values - Log when adjusted market cap differs from DexScreener value Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
408 lines
15 KiB
Python
408 lines
15 KiB
Python
"""Portfolio dashboard — fixes empty chart by:
|
|
1. Computing NAV server-side in the history API (not client-side from nulls)
|
|
2. Only returning dates with valid NAV data
|
|
3. Showing data points when sparse
|
|
"""
|
|
|
|
import json
|
|
import sqlite3
|
|
import logging
|
|
from html import escape as esc
|
|
from datetime import datetime, timezone
|
|
|
|
from aiohttp import web
|
|
from shared_ui import render_page
|
|
|
|
logger = logging.getLogger("argus.portfolio")
|
|
|
|
CSS = """
|
|
.hero-chart { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
|
.hero-chart h2 { color: #c9d1d9; font-size: 18px; margin-bottom: 12px; }
|
|
.range-btns { display: flex; gap: 4px; margin-bottom: 12px; }
|
|
.range-btn { background: #21262d; border: 1px solid #30363d; color: #8b949e; padding: 5px 14px;
|
|
border-radius: 4px; cursor: pointer; font-size: 12px; }
|
|
.range-btn.active { background: #1f6feb33; border-color: #58a6ff; color: #58a6ff; }
|
|
.ptable-wrap { overflow-x: auto; margin-top: 20px; }
|
|
.ptable { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.ptable th { background: #161b22; color: #8b949e; font-size: 11px; text-transform: uppercase;
|
|
letter-spacing: 0.5px; padding: 10px 12px; text-align: right; border-bottom: 1px solid #30363d;
|
|
cursor: pointer; user-select: none; white-space: nowrap; }
|
|
.ptable th:first-child { text-align: left; position: sticky; left: 0; background: #161b22; z-index: 1; }
|
|
.ptable th:hover { color: #c9d1d9; }
|
|
.ptable th.sorted-asc::after { content: ' \\25B2'; font-size: 9px; }
|
|
.ptable th.sorted-desc::after { content: ' \\25BC'; font-size: 9px; }
|
|
.ptable td { padding: 10px 12px; text-align: right; border-bottom: 1px solid #21262d; color: #c9d1d9; }
|
|
.ptable td:first-child { text-align: left; position: sticky; left: 0; background: #0d1117; z-index: 1; font-weight: 600; }
|
|
.ptable tr:hover td { background: #161b22; }
|
|
.ptable tr:hover td:first-child { background: #161b22; }
|
|
.summary-row td { font-weight: 700; border-top: 2px solid #30363d; background: #161b22 !important; }
|
|
.premium { color: #f85149; }
|
|
.discount { color: #3fb950; }
|
|
.near-nav { color: #d29922; }
|
|
"""
|
|
|
|
|
|
def _fmt_usd(v):
|
|
if v is None:
|
|
return '\u2014'
|
|
if abs(v) >= 1_000_000:
|
|
return f'${v / 1_000_000:.1f}M'
|
|
if abs(v) >= 1_000:
|
|
return f'${v / 1_000:.0f}K'
|
|
return f'${v:,.0f}'
|
|
|
|
|
|
def _fmt_price(v):
|
|
if v is None:
|
|
return '\u2014'
|
|
if v >= 100:
|
|
return f'${v:,.0f}'
|
|
if v >= 1:
|
|
return f'${v:.2f}'
|
|
if v >= 0.01:
|
|
return f'${v:.4f}'
|
|
return f'${v:.6f}'
|
|
|
|
|
|
def _fmt_ratio(v):
|
|
if v is None or v == 0:
|
|
return '\u2014'
|
|
return f'{v:.2f}x'
|
|
|
|
|
|
def _ratio_class(v):
|
|
if v is None or v == 0:
|
|
return ''
|
|
if v > 1.5:
|
|
return 'premium'
|
|
if v < 0.9:
|
|
return 'discount'
|
|
if v <= 1.1:
|
|
return 'near-nav'
|
|
return ''
|
|
|
|
|
|
def render_portfolio_page(coins: list[dict], now: datetime) -> str:
|
|
if not coins:
|
|
body = '<div style="padding:40px;text-align:center;color:#8b949e;">No coin data yet.</div>'
|
|
return render_page("Portfolio", "Ownership coin portfolio", "/portfolio", body,
|
|
extra_css=CSS, timestamp=now.strftime("%Y-%m-%d %H:%M UTC"))
|
|
|
|
total_mcap = sum(c.get('market_cap_usd') or 0 for c in coins)
|
|
total_treasury = sum(c.get('treasury_usd') or 0 for c in coins)
|
|
|
|
hero_chart = """
|
|
<div class="hero-chart">
|
|
<h2>Price / NAV per Token</h2>
|
|
<div class="range-btns">
|
|
<button class="range-btn" onclick="setRange(this, 30)">30d</button>
|
|
<button class="range-btn active" onclick="setRange(this, 90)">90d</button>
|
|
<button class="range-btn" onclick="setRange(this, 180)">180d</button>
|
|
<button class="range-btn" onclick="setRange(this, 365)">All</button>
|
|
</div>
|
|
<canvas id="ratio-chart" height="320" style="max-height:320px"></canvas>
|
|
</div>
|
|
"""
|
|
|
|
header = """<div class="ptable-wrap"><table class="ptable" id="coin-table">
|
|
<thead><tr>
|
|
<th data-col="name">Coin</th>
|
|
<th data-col="price">Price</th>
|
|
<th data-col="nav">NAV / Token</th>
|
|
<th data-col="ratio">Price / NAV</th>
|
|
<th data-col="treasury">Treasury</th>
|
|
<th data-col="mcap">Market Cap</th>
|
|
</tr></thead><tbody>"""
|
|
|
|
rows = ''
|
|
for c in coins:
|
|
name = c.get('name', '?')
|
|
ticker = c.get('ticker', '')
|
|
price = c.get('price_usd')
|
|
nav = c.get('nav_per_token')
|
|
ratio = c.get('price_nav_ratio')
|
|
treasury = c.get('treasury_usd')
|
|
mcap = c.get('market_cap_usd')
|
|
|
|
label = esc(name)
|
|
if ticker:
|
|
label += f' <span style="color:#8b949e;font-size:11px;">{esc(ticker)}</span>'
|
|
|
|
rows += f"""<tr>
|
|
<td>{label}</td>
|
|
<td>{_fmt_price(price)}</td>
|
|
<td>{_fmt_price(nav)}</td>
|
|
<td class="{_ratio_class(ratio)}">{_fmt_ratio(ratio)}</td>
|
|
<td>{_fmt_usd(treasury)}</td>
|
|
<td>{_fmt_usd(mcap)}</td>
|
|
</tr>"""
|
|
|
|
rows += f"""<tr class="summary-row">
|
|
<td>Total ({len(coins)})</td>
|
|
<td></td><td></td><td></td>
|
|
<td>{_fmt_usd(total_treasury)}</td>
|
|
<td>{_fmt_usd(total_mcap)}</td>
|
|
</tr>"""
|
|
|
|
table = header + rows + '</tbody></table></div>'
|
|
|
|
scripts = """<script>
|
|
const COLORS = ['#58a6ff','#3fb950','#f0883e','#d29922','#f85149','#bc8cff','#39d353','#79c0ff','#ff7b72','#a5d6ff'];
|
|
let chart = null;
|
|
|
|
function setRange(btn, days) {
|
|
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
loadChart(days);
|
|
}
|
|
|
|
function loadChart(days) {
|
|
fetch('/api/portfolio/nav-ratios?days=' + days)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const dates = data.dates || [];
|
|
const series = data.series || {};
|
|
|
|
if (dates.length === 0) {
|
|
if (chart) chart.destroy();
|
|
chart = null;
|
|
const ctx = document.getElementById('ratio-chart').getContext('2d');
|
|
ctx.fillStyle = '#8b949e';
|
|
ctx.font = '14px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('No NAV data yet — accumulating daily snapshots', ctx.canvas.width / 2, 160);
|
|
return;
|
|
}
|
|
|
|
const sparse = dates.length <= 10;
|
|
const datasets = [];
|
|
let i = 0;
|
|
for (const [name, ratios] of Object.entries(series)) {
|
|
const hasData = ratios.some(v => v !== null);
|
|
if (!hasData) { i++; continue; }
|
|
datasets.push({
|
|
label: name,
|
|
data: ratios,
|
|
borderColor: COLORS[i % COLORS.length],
|
|
backgroundColor: COLORS[i % COLORS.length] + '33',
|
|
borderWidth: 2,
|
|
tension: 0.3,
|
|
spanGaps: true,
|
|
pointRadius: sparse ? 4 : 0,
|
|
pointHoverRadius: 6,
|
|
fill: false,
|
|
});
|
|
i++;
|
|
}
|
|
|
|
if (chart) chart.destroy();
|
|
const ctx = document.getElementById('ratio-chart').getContext('2d');
|
|
chart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: { labels: dates, datasets },
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: { mode: 'index', intersect: false },
|
|
plugins: {
|
|
legend: { labels: { color: '#8b949e', font: { size: 11 }, usePointStyle: true, boxWidth: 8 }, position: 'top' },
|
|
tooltip: { mode: 'index', intersect: false,
|
|
callbacks: { label: ctx => ctx.dataset.label + ': ' + (ctx.parsed.y != null ? ctx.parsed.y.toFixed(2) + 'x' : 'n/a') }
|
|
},
|
|
annotation: {
|
|
annotations: {
|
|
navLine: {
|
|
type: 'line',
|
|
yMin: 1, yMax: 1,
|
|
borderColor: '#3fb95088',
|
|
borderWidth: 2,
|
|
borderDash: [6, 4],
|
|
label: {
|
|
display: true,
|
|
content: '1.0x = NAV',
|
|
position: 'end',
|
|
backgroundColor: '#3fb95033',
|
|
color: '#3fb950',
|
|
font: { size: 10 },
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: { ticks: { color: '#8b949e', maxTicksLimit: 12 }, grid: { display: false } },
|
|
y: { ticks: { color: '#8b949e', callback: v => v.toFixed(1) + 'x' }, grid: { color: '#21262d' },
|
|
suggestedMin: 0 }
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Table sorting
|
|
function sortTable(col) {
|
|
const table = document.getElementById('coin-table');
|
|
const tbody = table.querySelector('tbody');
|
|
const rows = Array.from(tbody.querySelectorAll('tr:not(.summary-row)'));
|
|
const summaryRow = tbody.querySelector('.summary-row');
|
|
const th = table.querySelectorAll('th')[col];
|
|
const asc = th.classList.contains('sorted-asc');
|
|
table.querySelectorAll('th').forEach(h => h.classList.remove('sorted-asc','sorted-desc'));
|
|
th.classList.add(asc ? 'sorted-desc' : 'sorted-asc');
|
|
rows.sort((a, b) => {
|
|
let va = a.cells[col].textContent.replace(/[$,+%x\\u2014]/g,'').trim();
|
|
let vb = b.cells[col].textContent.replace(/[$,+%x\\u2014]/g,'').trim();
|
|
const na = parseFloat(va) || 0, nb = parseFloat(vb) || 0;
|
|
if (col === 0) return asc ? vb.localeCompare(va) : va.localeCompare(vb);
|
|
return asc ? na - nb : nb - na;
|
|
});
|
|
rows.forEach(r => tbody.appendChild(r));
|
|
if (summaryRow) tbody.appendChild(summaryRow);
|
|
}
|
|
document.querySelectorAll('#coin-table th').forEach((th, i) => {
|
|
th.addEventListener('click', () => sortTable(i));
|
|
});
|
|
|
|
loadChart(90);
|
|
</script>"""
|
|
|
|
body = hero_chart + table
|
|
return render_page("Portfolio", "Ownership coin portfolio", "/portfolio", body,
|
|
scripts=scripts, extra_css=CSS,
|
|
timestamp=now.strftime("%Y-%m-%d %H:%M UTC"))
|
|
|
|
|
|
# ── API handlers ────────────────────────────────────────────────────────────
|
|
|
|
def _get_db(request):
|
|
return request.app["_portfolio_conn"]()
|
|
|
|
|
|
def _compute_nav(row):
|
|
"""Compute NAV per token and Price/NAV ratio from a snapshot row dict."""
|
|
treas = (row.get('treasury_multisig_usd') or 0) + (row.get('lp_usdc_total') or 0)
|
|
adj = row.get('adjusted_circulating_supply') or 0
|
|
price = row.get('price_usd') or 0
|
|
nav = treas / adj if adj > 0 else 0
|
|
ratio = price / nav if nav > 0 else 0
|
|
return treas, nav, ratio
|
|
|
|
|
|
async def handle_portfolio_page(request):
|
|
conn = _get_db(request)
|
|
try:
|
|
rows = conn.execute("""
|
|
SELECT * FROM coin_snapshots
|
|
WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM coin_snapshots)
|
|
ORDER BY market_cap_usd DESC
|
|
""").fetchall()
|
|
coins = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
treas, nav, ratio = _compute_nav(d)
|
|
d['treasury_usd'] = treas
|
|
d['nav_per_token'] = nav
|
|
d['price_nav_ratio'] = ratio
|
|
coins.append(d)
|
|
now = datetime.now(timezone.utc)
|
|
html = render_portfolio_page(coins, now)
|
|
return web.Response(text=html, content_type='text/html')
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
async def handle_nav_ratios(request):
|
|
"""Server-side computed NAV ratios — only returns dates with valid data."""
|
|
conn = _get_db(request)
|
|
try:
|
|
try:
|
|
days = min(int(request.query.get('days', '90')), 365)
|
|
except (ValueError, TypeError):
|
|
days = 90
|
|
rows = conn.execute("""
|
|
SELECT name, snapshot_date, price_usd, treasury_multisig_usd,
|
|
lp_usdc_total, adjusted_circulating_supply
|
|
FROM coin_snapshots
|
|
WHERE snapshot_date >= date('now', ? || ' days')
|
|
AND adjusted_circulating_supply IS NOT NULL
|
|
AND adjusted_circulating_supply > 0
|
|
ORDER BY name, snapshot_date
|
|
""", (f'-{days}',)).fetchall()
|
|
|
|
coin_ratios = {}
|
|
all_dates = set()
|
|
for r in rows:
|
|
d = dict(r)
|
|
name = d['name']
|
|
date = d['snapshot_date']
|
|
_, nav, ratio = _compute_nav(d)
|
|
if nav > 0 and ratio > 0:
|
|
if name not in coin_ratios:
|
|
coin_ratios[name] = {}
|
|
coin_ratios[name][date] = round(ratio, 3)
|
|
all_dates.add(date)
|
|
|
|
sorted_dates = sorted(all_dates)
|
|
series = {}
|
|
for name, date_map in coin_ratios.items():
|
|
series[name] = [date_map.get(d) for d in sorted_dates]
|
|
|
|
return web.json_response({
|
|
'dates': sorted_dates,
|
|
'series': series,
|
|
})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
async def handle_portfolio_history(request):
|
|
conn = _get_db(request)
|
|
try:
|
|
try:
|
|
days = min(int(request.query.get('days', '90')), 365)
|
|
except (ValueError, TypeError):
|
|
days = 90
|
|
rows = conn.execute("""
|
|
SELECT * FROM coin_snapshots
|
|
WHERE snapshot_date >= date('now', ? || ' days')
|
|
ORDER BY name, snapshot_date
|
|
""", (f'-{days}',)).fetchall()
|
|
history = {}
|
|
for r in rows:
|
|
d = dict(r)
|
|
key = d['name']
|
|
if key not in history:
|
|
history[key] = []
|
|
history[key].append(d)
|
|
return web.json_response({'history': history})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
async def handle_portfolio_latest(request):
|
|
conn = _get_db(request)
|
|
try:
|
|
rows = conn.execute("""
|
|
SELECT * FROM coin_snapshots
|
|
WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM coin_snapshots)
|
|
ORDER BY market_cap_usd DESC
|
|
""").fetchall()
|
|
coins = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
treas, nav, ratio = _compute_nav(d)
|
|
d['treasury_usd'] = treas
|
|
d['nav_per_token'] = nav
|
|
d['price_nav_ratio'] = ratio
|
|
coins.append(d)
|
|
return web.json_response({'coins': coins, 'date': coins[0]['snapshot_date'] if coins else None})
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def register_portfolio_routes(app, get_conn):
|
|
app["_portfolio_conn"] = get_conn
|
|
app.router.add_get("/portfolio", handle_portfolio_page)
|
|
app.router.add_get("/api/portfolio/nav-ratios", handle_nav_ratios)
|
|
app.router.add_get("/api/portfolio/history", handle_portfolio_history)
|
|
app.router.add_get("/api/portfolio/latest", handle_portfolio_latest)
|