feat: cockpit
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user