From 6eba6ce5f8f60c56f8f8d70703538724e6cd5148 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Thu, 23 Jul 2026 19:29:32 +0200 Subject: [PATCH] feat: cockpit --- backend/routers/instruments_watchlist.py | 47 ++++++++- backend/routers/journal.py | 10 ++ backend/services/portfolio_risk.py | 117 ++++++++++++++++++++++ frontend/src/hooks/useApi.ts | 18 ++++ frontend/src/pages/Dashboard.tsx | 118 +++++++++++++++-------- 5 files changed, 268 insertions(+), 42 deletions(-) diff --git a/backend/routers/instruments_watchlist.py b/backend/routers/instruments_watchlist.py index 5bdea6f..5399a78 100644 --- a/backend/routers/instruments_watchlist.py +++ b/backend/routers/instruments_watchlist.py @@ -1,6 +1,6 @@ import logging -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel from typing import List, Optional @@ -66,6 +66,51 @@ def watchlist_quotes(): return {"items": items} +_HISTORY_PERIODS = { + "1w": {"yf": "5d", "days": 7}, + "1m": {"yf": "1mo", "days": 30}, + "3m": {"yf": "3mo", "days": 90}, + "6m": {"yf": "6mo", "days": 180}, + "1y": {"yf": "1y", "days": 365}, + "5y": {"yf": "5y", "days": 1825}, + "max": {"yf": "max", "days": 3650}, +} + + +@router.get("/history/{ticker}") +def watchlist_history(ticker: str, period: str = Query("3m")): + """Daily close series for the Watchlist card's chart — Saxo-sourced if this + instrument has a saxo_quote_symbol link (see saxo-quote-link below), yfinance + otherwise. Same source-of-truth split as /quotes above, just returning a series + instead of a single latest point.""" + from services.database import get_instruments_watchlist, get_saxo_catalog_by_symbol + from services.saxo_client import get_price_history + import yfinance as yf + + ticker = ticker.strip().upper() + spec = _HISTORY_PERIODS.get(period.lower(), _HISTORY_PERIODS["3m"]) + + row = next((r for r in get_instruments_watchlist() if r["ticker"] == ticker), None) + saxo_quote_symbol = row.get("saxo_quote_symbol") if row else None + + if saxo_quote_symbol: + try: + entry = get_saxo_catalog_by_symbol(saxo_quote_symbol) + asset_type = entry["asset_type"] if entry else "FxSpot" + bars = get_price_history(saxo_quote_symbol, asset_type, days=spec["days"]) + return {"ticker": ticker, "source": "saxo", "bars": [{"date": b["date"], "close": b["close"]} for b in bars]} + except Exception as e: + logger.info(f"[watchlist/history] Saxo history failed for '{saxo_quote_symbol}', falling back to yfinance: {e}") + + try: + hist = yf.Ticker(ticker).history(period=spec["yf"], interval="1d", auto_adjust=True) + hist = hist.dropna(subset=["Close"]) + bars = [{"date": idx.strftime("%Y-%m-%d"), "close": round(float(c), 6)} for idx, c in hist["Close"].items()] + return {"ticker": ticker, "source": "yfinance", "bars": bars} + except Exception as e: + return {"ticker": ticker, "source": "none", "bars": [], "error": str(e)} + + @router.post("/{ticker}") def add_ticker(ticker: str): """Adds a tracked instrument. yfinance validation is best-effort, not a gate — an diff --git a/backend/routers/journal.py b/backend/routers/journal.py index d4c3bb1..b59d2c0 100644 --- a/backend/routers/journal.py +++ b/backend/routers/journal.py @@ -256,6 +256,16 @@ def portfolio_risk(): return _sanitize(result) +@router.get("/portfolio-risk-radar") +def portfolio_risk_radar(): + """5-axis risk radar (Concentration/Volatility/Correlation/Exposure/Drawdown) for the + Cockpit's Risk card. Separate from /portfolio-risk above — this one makes live + yfinance calls (per-position volatility + a correlation matrix), heavier and slower, + so it's not bundled into the lighter endpoint other pages may poll more often.""" + from services.portfolio_risk import compute_portfolio_risk_radar + return _sanitize(compute_portfolio_risk_radar()) + + class TradeCheckRequest(BaseModel): underlying: str strategy: str diff --git a/backend/services/portfolio_risk.py b/backend/services/portfolio_risk.py index 97ab74a..5e1695f 100644 --- a/backend/services/portfolio_risk.py +++ b/backend/services/portfolio_risk.py @@ -184,6 +184,123 @@ def analyze_simulation_portfolio() -> Dict[str, Any]: } +def _compute_avg_pairwise_correlation(underlyings: List[str], days: int = 90) -> Optional[float]: + """Average pairwise correlation of daily returns across the given tickers, using + whichever of them yfinance actually resolves (Saxo-only underlyings without a + yfinance equivalent are silently dropped, not treated as an error).""" + import numpy as np + import pandas as pd + import yfinance as yf + + if len(underlyings) < 2: + return None + try: + raw = yf.download(underlyings, period=f"{days}d", interval="1d", progress=False, auto_adjust=True) + closes = raw["Close"] if isinstance(raw.columns, pd.MultiIndex) else raw[["Close"]] + except Exception: + return None + closes = closes.dropna(axis=1, how="all") + if closes.shape[1] < 2: + return None + returns = closes.pct_change().dropna(how="all") + corr = returns.corr().to_numpy() + n = corr.shape[0] + if n < 2: + return None + off_diag = [corr[i, j] for i in range(n) for j in range(n) if i != j and not np.isnan(corr[i, j])] + if not off_diag: + return None + return float(np.mean(off_diag)) + + +def _compute_max_drawdown_pct(snapshots: List[Dict[str, Any]]) -> Optional[float]: + """Max peak-to-trough drop in total_pnl_pct across the P&L snapshot history + (services.var_service.get_pnl_snapshots — DESC order, so reverse to oldest-first).""" + if not snapshots: + return None + ordered = list(reversed(snapshots)) # oldest -> newest + peak = ordered[0].get("total_pnl_pct") + if peak is None: + return None + max_dd = 0.0 + for snap in ordered: + v = snap.get("total_pnl_pct") + if v is None: + continue + peak = max(peak, v) + max_dd = max(max_dd, peak - v) + return round(max_dd, 2) + + +def compute_portfolio_risk_radar() -> Dict[str, Any]: + """5-axis risk radar for the Cockpit's Risk card (replaces the old asset-class donut, + which now lives separately as the allocation breakdown). Axes, each scaled 0-100: + - Concentration: capital-weighted share of the single largest underlying. + - Volatility: capital-weighted average 20d realized vol of open positions. + - Correlation: average pairwise return correlation across open positions' + underlyings (only positive correlation counts as risk — negative correlation is + diversification, not danger). + - Exposure: open position count against a soft target of 10 concurrent trades — + a proxy, NOT true margin leverage: trade_entry_prices has no notional/contract-size + column to compute real leverage from, so this measures "how spread thin" instead. + - Drawdown: max peak-to-trough drop in the simulated portfolio's total P&L %, + from services.var_service's snapshot history. + """ + from services.data_fetcher import get_quote_with_volatility + from services.var_service import get_pnl_snapshots + + trades = get_open_simulation_trades() + open_count = len(trades) + if not trades: + return {"axes": [], "open_count": 0} + + weights = [max(t.get("capital_invested") or t.get("entry_price") or 0, 0) for t in trades] + total_w = sum(weights) or 1.0 + + by_underlying_w: Dict[str, float] = {} + for t, w in zip(trades, weights): + u = (t.get("underlying") or "").upper() + if u: + by_underlying_w[u] = by_underlying_w.get(u, 0) + w + concentration_pct = (max(by_underlying_w.values()) / total_w * 100) if by_underlying_w else 0.0 + + vol_cache: Dict[str, Optional[float]] = {} + weighted_vol_sum, vol_weight_total = 0.0, 0.0 + for t, w in zip(trades, weights): + u = (t.get("underlying") or "").upper() + if not u: + continue + if u not in vol_cache: + try: + q = get_quote_with_volatility(u) + vol_cache[u] = q.get("volatility_pct") if q else None + except Exception: + vol_cache[u] = None + v = vol_cache[u] + if v is not None: + weighted_vol_sum += v * w + vol_weight_total += w + avg_vol_pct = (weighted_vol_sum / vol_weight_total) if vol_weight_total else None + + avg_corr = _compute_avg_pairwise_correlation(sorted(by_underlying_w.keys())) + + exposure_score = min(100.0, open_count / 10 * 100) + + drawdown_pct = _compute_max_drawdown_pct(get_pnl_snapshots(200)) + + def _scale(v: Optional[float], cap: float) -> Optional[float]: + return round(min(100.0, max(0.0, v / cap * 100)), 1) if v is not None else None + + axes = [ + {"axis": "Concentration", "value": round(concentration_pct, 1), "detail": f"{concentration_pct:.0f}% in top position"}, + {"axis": "Volatility", "value": _scale(avg_vol_pct, 60), "detail": f"{avg_vol_pct:.0f}% avg 20d vol" if avg_vol_pct is not None else "n/a"}, + {"axis": "Correlation", "value": round(max(0.0, avg_corr) * 100, 1) if avg_corr is not None else None, "detail": f"{avg_corr:+.2f} avg correlation" if avg_corr is not None else "n/a"}, + {"axis": "Exposure", "value": round(exposure_score, 1), "detail": f"{open_count} open position{'s' if open_count != 1 else ''}"}, + {"axis": "Drawdown", "value": _scale(drawdown_pct, 20) if drawdown_pct is not None else None, "detail": f"{drawdown_pct:.1f}pt from peak" if drawdown_pct is not None else "n/a"}, + ] + return {"axes": axes, "open_count": open_count} + + def check_new_trade(underlying: str, strategy: str, asset_class: str) -> Dict[str, Any]: """Pre-entry check: would this new trade create conflicts or concentration issues?""" open_trades = get_open_simulation_trades() diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index a1efe72..0a5f619 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -140,6 +140,14 @@ export const useAddWatchlistInstrument = () => { }) } +export const useWatchlistHistory = (ticker: string, period: string) => + useQuery({ + queryKey: ['instruments-watchlist-history', ticker, period], + queryFn: () => api.get(`/watchlist/history/${encodeURIComponent(ticker)}`, { params: { period } }).then(r => r.data), + enabled: !!ticker, + staleTime: 5 * 60_000, + }) + export const useRemoveWatchlistInstrument = () => { const qc = useQueryClient() return useMutation({ @@ -745,6 +753,16 @@ export const useSimPortfolioRisk = () => staleTime: 30_000, }) +// 5-axis risk radar (Concentration/Volatility/Correlation/Exposure/Drawdown) — heavier +// than useSimPortfolioRisk above (live per-position vol + a correlation matrix), kept +// as its own endpoint/query so it isn't refetched as eagerly. +export const usePortfolioRiskRadar = () => + useQuery({ + queryKey: ['journal-portfolio-risk-radar'], + queryFn: () => api.get('/journal/portfolio-risk-radar').then(r => r.data), + staleTime: 5 * 60_000, + }) + export const useTradeCheck = () => useMutation({ mutationFn: (body: { underlying: string; strategy: string; asset_class: string }) => diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 7bf09d2..6bb4964 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -4,8 +4,8 @@ import { useGeoRiskScore, useAllQuotes, useEcoCalendar, usePortfolioSummary, useLastScores, useAllPatterns, useMacroRegime, useTradeMtm, useRiskDashboard, useGeoNews, - useSimPortfolioRisk, useCycleStatus, useClosedTrades, - useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useSaxoIvWatchlist, useLatestCycleReport, + useSimPortfolioRisk, usePortfolioRiskRadar, useCycleStatus, useClosedTrades, + useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useWatchlistHistory, useSaxoIvWatchlist, useLatestCycleReport, useWaveletWatchlistSignals, } from '../hooks/useApi' import { Clock, Globe, ShieldAlert, ArrowUpRight, Newspaper, Waves, Link2 } from 'lucide-react' @@ -150,10 +150,15 @@ export default function Dashboard() { const { data: closedTradesData } = useClosedTrades(90) const { data: riskDashboard } = useRiskDashboard() const { data: simRisk } = useSimPortfolioRisk() + const { data: riskRadarData } = usePortfolioRiskRadar() const { data: cycleStatusData } = useCycleStatus() const { data: geoNews } = useGeoNews() const { data: watchlistItems } = useInstrumentsWatchlist() const { data: watchlistQuotesData } = useInstrumentsWatchlistQuotes() + const [watchlistChartTicker, setWatchlistChartTicker] = useState(null) + const [watchlistChartPeriod, setWatchlistChartPeriod] = useState('3m') + const activeWatchlistTicker = watchlistChartTicker ?? (watchlistItems as any)?.[0]?.ticker ?? '' + const { data: watchlistHistoryData, isLoading: watchlistHistoryLoading } = useWatchlistHistory(activeWatchlistTicker, watchlistChartPeriod) const { data: latestCycleReportData } = useLatestCycleReport() const { data: waveletSignalsData } = useWaveletWatchlistSignals() const { data: saxoIvWatchlistData } = useSaxoIvWatchlist() @@ -264,15 +269,6 @@ export default function Dashboard() { .slice(0, 30) , [geoNews]) - // Watchlist radar: change_pct normalized to a 0-100 scale (50 = flat) - const watchlistRadarData = useMemo(() => { - const items: any[] = (watchlistQuotesData as any)?.items ?? [] - return items.slice(0, 8).map((it: any) => { - const chg = Math.max(-5, Math.min(5, it.change_pct ?? 0)) - return { subject: it.ticker, value: 50 + chg * 10, ref: 50 } - }) - }, [watchlistQuotesData]) - const watchlistAsOf = useMemo(() => { const items: any[] = (watchlistQuotesData as any)?.items ?? [] return fmtAsOf(items.map((it: any) => it.timestamp).filter(Boolean).sort().pop()) @@ -414,10 +410,10 @@ export default function Dashboard() { ) :
Backend required
} - {/* Watchlist Radar — natural height (config-driven), measured and used to cap the other row-1 cards */} + {/* Watchlist — natural height (config-driven), measured and used to cap the other row-1 cards */}
-
📡 Watchlist Radar
+
📡 Watchlist
{watchlistAsOf && MAJ {watchlistAsOf}} @@ -425,20 +421,58 @@ export default function Dashboard() {
- {watchlistRadarData.length > 0 ? ( + {((watchlistQuotesData as any)?.items ?? []).length > 0 ? ( <> - - - - - - - - -
+
+ {activeWatchlistTicker} +
+ {['1w', '1m', '3m', '6m', '1y', '5y', 'max'].map(p => ( + + ))} +
+
+ {watchlistHistoryLoading ? ( +
Loading…
+ ) : ((watchlistHistoryData as any)?.bars?.length ?? 0) > 1 ? ( + + + + + + + + + + + [fmtPrice(v), activeWatchlistTicker]} + /> + + + + ) : ( +
No chart data for {activeWatchlistTicker}
+ )} +
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => ( -
- +
-
+ ))}
@@ -821,7 +855,8 @@ export default function Dashboard() { ) })()} - {/* Risk — donut chart of asset allocation */} + {/* Risk — radar of 5 risk factors (Concentration/Volatility/Correlation/Exposure/ + Drawdown), asset-class allocation breakdown unchanged below it */} {(() => { const risk = simRisk as any const alertCount: number = risk?.alerts?.length ?? 0 @@ -832,6 +867,7 @@ export default function Dashboard() { .map(([cls, data]: [string, any]) => ({ name: cls, value: data.pct, bullish: data.bullish, bearish: data.bearish })) .filter(d => d.value > 0) .sort((a, b) => b.value - a.value) + const radarAxes = ((riskRadarData as any)?.axes ?? []).map((a: any) => ({ ...a, value: a.value ?? 0 })) return ( 0 ? `${alertCount} alert${alertCount > 1 ? 's' : ''}` : 'OK'} {conflictCount > 0 && {conflictCount} conflict{conflictCount > 1 ? 's' : ''}}
+ {radarAxes.length > 0 && ( + + + + + + [props.payload.detail, props.payload.axis]} + /> + + + )} {pieData.length > 0 ? ( <> - - - - {pieData.map((d, i) => ( - - ))} - - [`${value}% (${props.payload.bullish}↑ ${props.payload.bearish}↓)`, name]} - /> - - -
+
0 && 'mt-1 pt-1 border-t border-slate-700/30')}> {pieData.map(d => { const bias = d.bullish > d.bearish ? 'bullish' : d.bearish > d.bullish ? 'bearish' : 'neutral' return (