feat: cockpit

This commit is contained in:
OpenSquared
2026-07-23 19:29:32 +02:00
parent d3dc85fee9
commit 6eba6ce5f8
5 changed files with 268 additions and 42 deletions

View File

@@ -1,6 +1,6 @@
import logging import logging
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
@@ -66,6 +66,51 @@ def watchlist_quotes():
return {"items": items} 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}") @router.post("/{ticker}")
def add_ticker(ticker: str): def add_ticker(ticker: str):
"""Adds a tracked instrument. yfinance validation is best-effort, not a gate — an """Adds a tracked instrument. yfinance validation is best-effort, not a gate — an

View File

@@ -256,6 +256,16 @@ def portfolio_risk():
return _sanitize(result) 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): class TradeCheckRequest(BaseModel):
underlying: str underlying: str
strategy: str strategy: str

View File

@@ -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]: 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?""" """Pre-entry check: would this new trade create conflicts or concentration issues?"""
open_trades = get_open_simulation_trades() open_trades = get_open_simulation_trades()

View File

@@ -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 = () => { export const useRemoveWatchlistInstrument = () => {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
@@ -745,6 +753,16 @@ export const useSimPortfolioRisk = () =>
staleTime: 30_000, 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 = () => export const useTradeCheck = () =>
useMutation({ useMutation({
mutationFn: (body: { underlying: string; strategy: string; asset_class: string }) => mutationFn: (body: { underlying: string; strategy: string; asset_class: string }) =>

View File

@@ -4,8 +4,8 @@ import {
useGeoRiskScore, useAllQuotes, useGeoRiskScore, useAllQuotes,
useEcoCalendar, usePortfolioSummary, useLastScores, useAllPatterns, useMacroRegime, useEcoCalendar, usePortfolioSummary, useLastScores, useAllPatterns, useMacroRegime,
useTradeMtm, useRiskDashboard, useGeoNews, useTradeMtm, useRiskDashboard, useGeoNews,
useSimPortfolioRisk, useCycleStatus, useClosedTrades, useSimPortfolioRisk, usePortfolioRiskRadar, useCycleStatus, useClosedTrades,
useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useSaxoIvWatchlist, useLatestCycleReport, useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useWatchlistHistory, useSaxoIvWatchlist, useLatestCycleReport,
useWaveletWatchlistSignals, useWaveletWatchlistSignals,
} from '../hooks/useApi' } from '../hooks/useApi'
import { Clock, Globe, ShieldAlert, ArrowUpRight, Newspaper, Waves, Link2 } from 'lucide-react' 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: closedTradesData } = useClosedTrades(90)
const { data: riskDashboard } = useRiskDashboard() const { data: riskDashboard } = useRiskDashboard()
const { data: simRisk } = useSimPortfolioRisk() const { data: simRisk } = useSimPortfolioRisk()
const { data: riskRadarData } = usePortfolioRiskRadar()
const { data: cycleStatusData } = useCycleStatus() const { data: cycleStatusData } = useCycleStatus()
const { data: geoNews } = useGeoNews() const { data: geoNews } = useGeoNews()
const { data: watchlistItems } = useInstrumentsWatchlist() const { data: watchlistItems } = useInstrumentsWatchlist()
const { data: watchlistQuotesData } = useInstrumentsWatchlistQuotes() const { data: watchlistQuotesData } = useInstrumentsWatchlistQuotes()
const [watchlistChartTicker, setWatchlistChartTicker] = useState<string | null>(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: latestCycleReportData } = useLatestCycleReport()
const { data: waveletSignalsData } = useWaveletWatchlistSignals() const { data: waveletSignalsData } = useWaveletWatchlistSignals()
const { data: saxoIvWatchlistData } = useSaxoIvWatchlist() const { data: saxoIvWatchlistData } = useSaxoIvWatchlist()
@@ -264,15 +269,6 @@ export default function Dashboard() {
.slice(0, 30) .slice(0, 30)
, [geoNews]) , [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 watchlistAsOf = useMemo(() => {
const items: any[] = (watchlistQuotesData as any)?.items ?? [] const items: any[] = (watchlistQuotesData as any)?.items ?? []
return fmtAsOf(items.map((it: any) => it.timestamp).filter(Boolean).sort().pop()) return fmtAsOf(items.map((it: any) => it.timestamp).filter(Boolean).sort().pop())
@@ -414,10 +410,10 @@ export default function Dashboard() {
) : <div className="text-slate-500 text-xs">Backend required</div>} ) : <div className="text-slate-500 text-xs">Backend required</div>}
</div> </div>
{/* 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 */}
<div ref={watchlistCardRef} className="card col-span-1"> <div ref={watchlistCardRef} className="card col-span-1">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="section-title mb-0">📡 Watchlist Radar</div> <div className="section-title mb-0">📡 Watchlist</div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{watchlistAsOf && <span className="text-[9px] text-slate-500" title="Last quote refresh">MAJ {watchlistAsOf}</span>} {watchlistAsOf && <span className="text-[9px] text-slate-500" title="Last quote refresh">MAJ {watchlistAsOf}</span>}
<Link to="/config" className="flex items-center gap-0.5 text-[10px] text-slate-400 hover:text-slate-300 transition-colors"> <Link to="/config" className="flex items-center gap-0.5 text-[10px] text-slate-400 hover:text-slate-300 transition-colors">
@@ -425,20 +421,58 @@ export default function Dashboard() {
</Link> </Link>
</div> </div>
</div> </div>
{watchlistRadarData.length > 0 ? ( {((watchlistQuotesData as any)?.items ?? []).length > 0 ? (
<> <>
<ResponsiveContainer width="100%" height={130}> <div className="flex items-center justify-between mb-1">
<RadarChart data={watchlistRadarData}> <span className="text-[10px] font-mono font-bold text-white">{activeWatchlistTicker}</span>
<PolarGrid stroke="#1e2d4d" /> <div className="flex gap-0.5">
<PolarAngleAxis dataKey="subject" tick={{ fill: '#94a3b8', fontSize: 9 }} /> {['1w', '1m', '3m', '6m', '1y', '5y', 'max'].map(p => (
<Radar dataKey="ref" stroke="#334155" strokeDasharray="3 3" fill="transparent" isAnimationActive={false} /> <button
<Radar dataKey="value" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.25} /> key={p}
</RadarChart> onClick={() => setWatchlistChartPeriod(p)}
</ResponsiveContainer> className={clsx('px-1 py-0.5 rounded text-[8px] uppercase font-semibold transition-colors',
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-1"> watchlistChartPeriod === p ? 'bg-blue-600 text-white' : 'text-slate-600 hover:text-slate-300')}
>
{p}
</button>
))}
</div>
</div>
{watchlistHistoryLoading ? (
<div className="h-[110px] flex items-center justify-center text-slate-600 text-[10px]">Loading</div>
) : ((watchlistHistoryData as any)?.bars?.length ?? 0) > 1 ? (
<ResponsiveContainer width="100%" height={110}>
<AreaChart data={(watchlistHistoryData as any).bars} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="wlChartGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.35} />
<stop offset="100%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" hide />
<YAxis domain={['auto', 'auto']} hide />
<Tooltip
contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10, padding: '4px 8px' }}
formatter={(v: any) => [fmtPrice(v), activeWatchlistTicker]}
/>
<Area type="monotone" dataKey="close" stroke="#3b82f6" strokeWidth={1.5} fill="url(#wlChartGrad)" isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-[110px] flex items-center justify-center text-slate-600 text-[10px]">No chart data for {activeWatchlistTicker}</div>
)}
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-0.5">
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => ( {((watchlistQuotesData as any)?.items ?? []).map((it: any) => (
<div key={it.ticker} className="flex items-center justify-between gap-1.5 text-[10px] whitespace-nowrap"> <button
<span className="text-slate-300 font-mono shrink-0 flex items-center gap-1"> key={it.ticker}
type="button"
onClick={() => setWatchlistChartTicker(it.ticker)}
className={clsx(
'w-full flex items-center justify-between gap-1.5 text-[10px] whitespace-nowrap rounded px-1 -mx-1 py-0.5 text-left transition-colors',
it.ticker === activeWatchlistTicker ? 'bg-blue-900/20' : 'hover:bg-dark-700/40'
)}
>
<span className={clsx('font-mono shrink-0 flex items-center gap-1', it.ticker === activeWatchlistTicker ? 'text-white font-bold' : 'text-slate-300')}>
{it.ticker} {it.ticker}
{it.quote_source === 'saxo' && <span className="text-emerald-500" title="Priced from Saxo, not yfinance"></span>} {it.quote_source === 'saxo' && <span className="text-emerald-500" title="Priced from Saxo, not yfinance"></span>}
</span> </span>
@@ -456,7 +490,7 @@ export default function Dashboard() {
)} )}
</span> </span>
</div> </div>
</div> </button>
))} ))}
</div> </div>
</> </>
@@ -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 risk = simRisk as any
const alertCount: number = risk?.alerts?.length ?? 0 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 })) .map(([cls, data]: [string, any]) => ({ name: cls, value: data.pct, bullish: data.bullish, bearish: data.bearish }))
.filter(d => d.value > 0) .filter(d => d.value > 0)
.sort((a, b) => b.value - a.value) .sort((a, b) => b.value - a.value)
const radarAxes = ((riskRadarData as any)?.axes ?? []).map((a: any) => ({ ...a, value: a.value ?? 0 }))
return ( return (
<Link to="/risk" className="card flex flex-col overflow-y-auto hover:border-slate-600/60 transition-all cursor-pointer" <Link to="/risk" className="card flex flex-col overflow-y-auto hover:border-slate-600/60 transition-all cursor-pointer"
@@ -844,22 +880,22 @@ export default function Dashboard() {
{alertCount > 0 ? `${alertCount} alert${alertCount > 1 ? 's' : ''}` : 'OK'} {alertCount > 0 ? `${alertCount} alert${alertCount > 1 ? 's' : ''}` : 'OK'}
{conflictCount > 0 && <span className="text-[10px] text-red-400 font-normal">{conflictCount} conflict{conflictCount > 1 ? 's' : ''}</span>} {conflictCount > 0 && <span className="text-[10px] text-red-400 font-normal">{conflictCount} conflict{conflictCount > 1 ? 's' : ''}</span>}
</div> </div>
{radarAxes.length > 0 && (
<ResponsiveContainer width="100%" height={110}>
<RadarChart data={radarAxes}>
<PolarGrid stroke="#1e2d4d" />
<PolarAngleAxis dataKey="axis" tick={{ fill: '#94a3b8', fontSize: 9 }} />
<Radar dataKey="value" stroke="#f87171" fill="#f87171" fillOpacity={0.25} isAnimationActive={false} />
<Tooltip
contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10, padding: '4px 8px' }}
formatter={(_value: any, _name: any, props: any) => [props.payload.detail, props.payload.axis]}
/>
</RadarChart>
</ResponsiveContainer>
)}
{pieData.length > 0 ? ( {pieData.length > 0 ? (
<> <>
<ResponsiveContainer width="100%" height={100}> <div className={clsx('space-y-0.5', radarAxes.length > 0 && 'mt-1 pt-1 border-t border-slate-700/30')}>
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={30} outerRadius={48} paddingAngle={2}>
{pieData.map((d, i) => (
<Cell key={i} fill={ASSET_CLASS_COLORS[d.name] ?? ASSET_CLASS_COLORS.unknown} />
))}
</Pie>
<Tooltip
contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10, padding: '4px 8px' }}
formatter={(value: any, name: any, props: any) => [`${value}% (${props.payload.bullish}${props.payload.bearish}↓)`, name]}
/>
</PieChart>
</ResponsiveContainer>
<div className="mt-1 space-y-0.5">
{pieData.map(d => { {pieData.map(d => {
const bias = d.bullish > d.bearish ? 'bullish' : d.bearish > d.bullish ? 'bearish' : 'neutral' const bias = d.bullish > d.bearish ? 'bullish' : d.bearish > d.bullish ? 'bearish' : 'neutral'
return ( return (