feat: cockpit
This commit is contained in:
@@ -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 }) =>
|
||||
|
||||
@@ -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<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: 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() {
|
||||
) : <div className="text-slate-500 text-xs">Backend required</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 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">
|
||||
{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">
|
||||
@@ -425,20 +421,58 @@ export default function Dashboard() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{watchlistRadarData.length > 0 ? (
|
||||
{((watchlistQuotesData as any)?.items ?? []).length > 0 ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={130}>
|
||||
<RadarChart data={watchlistRadarData}>
|
||||
<PolarGrid stroke="#1e2d4d" />
|
||||
<PolarAngleAxis dataKey="subject" tick={{ fill: '#94a3b8', fontSize: 9 }} />
|
||||
<Radar dataKey="ref" stroke="#334155" strokeDasharray="3 3" fill="transparent" isAnimationActive={false} />
|
||||
<Radar dataKey="value" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.25} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-mono font-bold text-white">{activeWatchlistTicker}</span>
|
||||
<div className="flex gap-0.5">
|
||||
{['1w', '1m', '3m', '6m', '1y', '5y', 'max'].map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setWatchlistChartPeriod(p)}
|
||||
className={clsx('px-1 py-0.5 rounded text-[8px] uppercase font-semibold transition-colors',
|
||||
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) => (
|
||||
<div key={it.ticker} className="flex items-center justify-between gap-1.5 text-[10px] whitespace-nowrap">
|
||||
<span className="text-slate-300 font-mono shrink-0 flex items-center gap-1">
|
||||
<button
|
||||
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.quote_source === 'saxo' && <span className="text-emerald-500" title="Priced from Saxo, not yfinance">●</span>}
|
||||
</span>
|
||||
@@ -456,7 +490,7 @@ export default function Dashboard() {
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</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 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 (
|
||||
<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'}
|
||||
{conflictCount > 0 && <span className="text-[10px] text-red-400 font-normal">{conflictCount} conflict{conflictCount > 1 ? 's' : ''}</span>}
|
||||
</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 ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={100}>
|
||||
<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">
|
||||
<div className={clsx('space-y-0.5', radarAxes.length > 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 (
|
||||
|
||||
Reference in New Issue
Block a user