"""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 = '
No coin data yet.
' 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 = """

Price / NAV per Token

""" header = """
""" 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' {esc(ticker)}' rows += f"""""" rows += f"""""" table = header + rows + '
Coin Price NAV / Token Price / NAV Treasury Market Cap
{label} {_fmt_price(price)} {_fmt_price(nav)} {_fmt_ratio(ratio)} {_fmt_usd(treasury)} {_fmt_usd(mcap)}
Total ({len(coins)}) {_fmt_usd(total_treasury)} {_fmt_usd(total_mcap)}
' scripts = """""" 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)