feat: add standalone AI/Tech regime-change monitor tab
A new /regime tab scoring how far the AI/Tech bull regime has deteriorated toward a re-rating as a single 0-100 index with per-signal breakdown and a 7/30-day trend. Intentionally decoupled: nothing reads its output to gate or score trades — the daily-pipeline membership is scheduling only. - regime_monitor_service: price sub-scores (P1-P6 via Alpaca, like market_regime), VIX + HY credit spreads via a small FRED helper, weighted aggregation over available signals (missing source -> n/a, dropped from the denominator), one snapshot row/day, and a ~90-day history backfill by replaying the already-fetched series as-of each past day. - F1/F3 fundamentals proposed by the configured grounded LLM (reuses sentiment_provider_service config resolution), with a manual override + lock. - regime_snapshots table (migration 011); endpoints on the existing market router; admin-editable weights/threshold; standalone /regime page. Data needs: prices via Alpaca, VIX/credit via FRED (optional key — signals show n/a without it). No LLM needed for history. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import RegisterPage from './pages/RegisterPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import MarketPage from './pages/MarketPage';
|
||||
import SignalsPage from './pages/SignalsPage';
|
||||
import RegimePage from './pages/RegimePage';
|
||||
import TickerDetailPage from './pages/TickerDetailPage';
|
||||
import AdminPage from './pages/AdminPage';
|
||||
|
||||
@@ -19,6 +20,7 @@ export default function App() {
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/market" element={<MarketPage />} />
|
||||
<Route path="/signals" element={<SignalsPage />} />
|
||||
<Route path="/regime" element={<RegimePage />} />
|
||||
<Route path="/ticker/:symbol" element={<TickerDetailPage />} />
|
||||
{/* Legacy routes from the old 6-page layout */}
|
||||
<Route path="/watchlist" element={<Navigate to="/market" replace />} />
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import apiClient from './client';
|
||||
import type { RegimeMonitor, RegimeConfig, RegimeFundamentals } from '../lib/types';
|
||||
|
||||
export function getRegimeMonitor() {
|
||||
return apiClient.get<RegimeMonitor>('regime/monitor').then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getRegimeConfig() {
|
||||
return apiClient.get<RegimeConfig>('regime/config').then((r) => r.data);
|
||||
}
|
||||
|
||||
export function updateRegimeConfig(updates: Partial<RegimeConfig>) {
|
||||
return apiClient.put<RegimeConfig>('regime/config', updates).then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getRegimeFundamentals() {
|
||||
return apiClient.get<RegimeFundamentals>('regime/fundamentals').then((r) => r.data);
|
||||
}
|
||||
|
||||
export function updateRegimeFundamentals(body: {
|
||||
f1_score?: number;
|
||||
f3_score?: number;
|
||||
locked?: boolean;
|
||||
}) {
|
||||
return apiClient.put<RegimeFundamentals>('regime/fundamentals', body).then((r) => r.data);
|
||||
}
|
||||
|
||||
export function refreshRegimeFundamentals() {
|
||||
return apiClient
|
||||
.post<RegimeFundamentals>('regime/fundamentals/refresh')
|
||||
.then((r) => r.data);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const navItems = [
|
||||
{ to: '/', label: 'Overview', end: true },
|
||||
{ to: '/market', label: 'Market', end: false },
|
||||
{ to: '/signals', label: 'Signals', end: false },
|
||||
{ to: '/regime', label: 'Regime', end: false },
|
||||
];
|
||||
|
||||
export default function MobileNav() {
|
||||
|
||||
@@ -8,6 +8,7 @@ const navItems = [
|
||||
{ to: '/', label: 'Overview', index: '01', end: true },
|
||||
{ to: '/market', label: 'Market', index: '02', end: false },
|
||||
{ to: '/signals', label: 'Signals', index: '03', end: false },
|
||||
{ to: '/regime', label: 'Regime', index: '04', end: false },
|
||||
];
|
||||
|
||||
const linkClasses = (isActive: boolean) =>
|
||||
@@ -62,7 +63,7 @@ export default function Sidebar() {
|
||||
))}
|
||||
{role === 'admin' && (
|
||||
<NavLink to="/admin" className={({ isActive }) => linkClasses(isActive)}>
|
||||
<span className="font-mono text-[10px] tracking-widest opacity-50">04</span>
|
||||
<span className="font-mono text-[10px] tracking-widest opacity-50">05</span>
|
||||
Admin
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
@@ -263,6 +263,54 @@ export interface MarketRegime {
|
||||
computed_at?: string;
|
||||
}
|
||||
|
||||
// AI/Tech Regime-Change Monitor (standalone, observational)
|
||||
export type RegimeBand = 'stable' | 'watch' | 'elevated' | 'breaking';
|
||||
|
||||
export interface RegimeSignal {
|
||||
id: string;
|
||||
label: string;
|
||||
sub_score: number | null;
|
||||
weight: number;
|
||||
available: boolean;
|
||||
contribution: number;
|
||||
}
|
||||
|
||||
export interface RegimeMonitor {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
date?: string;
|
||||
total_score?: number;
|
||||
band?: RegimeBand;
|
||||
alert_threshold?: number;
|
||||
breakdown?: RegimeSignal[];
|
||||
inputs?: {
|
||||
vix: number | null;
|
||||
hy_oas: number | null;
|
||||
fundamentals_fetched_at: string | null;
|
||||
};
|
||||
trend?: { delta_7: number | null; delta_30: number | null };
|
||||
}
|
||||
|
||||
export interface RegimeFundamentals {
|
||||
f1_score: number;
|
||||
f3_score: number;
|
||||
locked: boolean;
|
||||
reasoning: string | null;
|
||||
fetched_at: string | null;
|
||||
source: string;
|
||||
capex?: Record<string, string>;
|
||||
good_news_stock_down?: string | null;
|
||||
}
|
||||
|
||||
export interface RegimeConfig {
|
||||
weights: Record<string, number>;
|
||||
alert_threshold: number;
|
||||
tickers: Record<string, unknown>;
|
||||
leader_weight: number;
|
||||
rs_lookback: number;
|
||||
fundamental_staleness_days: number;
|
||||
}
|
||||
|
||||
export interface AlertConfig {
|
||||
enabled: boolean;
|
||||
telegram_chat_id: string;
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
import { Disclosure } from '../components/ui/Disclosure';
|
||||
import { Badge } from '../components/ui/Badge';
|
||||
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import {
|
||||
getRegimeMonitor,
|
||||
getRegimeConfig,
|
||||
updateRegimeConfig,
|
||||
getRegimeFundamentals,
|
||||
updateRegimeFundamentals,
|
||||
refreshRegimeFundamentals,
|
||||
} from '../api/regime';
|
||||
import type {
|
||||
RegimeBand,
|
||||
RegimeMonitor,
|
||||
RegimeSignal,
|
||||
RegimeConfig,
|
||||
RegimeFundamentals,
|
||||
} from '../lib/types';
|
||||
|
||||
const BAND_STYLES: Record<RegimeBand, { text: string; bar: string; ring: string; label: string }> = {
|
||||
stable: { text: 'text-emerald-400', bar: 'bg-emerald-400', ring: 'border-emerald-400/30', label: 'Stable' },
|
||||
watch: { text: 'text-amber-400', bar: 'bg-amber-400', ring: 'border-amber-400/30', label: 'Watch' },
|
||||
elevated: { text: 'text-orange-400', bar: 'bg-orange-400', ring: 'border-orange-400/30', label: 'Elevated' },
|
||||
breaking: { text: 'text-red-400', bar: 'bg-red-400', ring: 'border-red-400/30', label: 'Breaking' },
|
||||
};
|
||||
|
||||
function TrendChip({ label, delta }: { label: string; delta: number | null | undefined }) {
|
||||
if (delta == null) {
|
||||
return <span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-500">{label}: n/a</span>;
|
||||
}
|
||||
const rising = delta > 0;
|
||||
const flat = delta === 0;
|
||||
// Higher index = worse, so a rising score is the warning direction.
|
||||
const color = flat ? 'text-gray-400' : rising ? 'text-red-400' : 'text-emerald-400';
|
||||
const arrow = flat ? '→' : rising ? '↑' : '↓';
|
||||
return (
|
||||
<span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-400">
|
||||
{label}: <span className={`font-medium ${color}`}>{arrow} {delta > 0 ? '+' : ''}{delta}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Gauge({ data }: { data: RegimeMonitor }) {
|
||||
const band = (data.band ?? 'stable') as RegimeBand;
|
||||
const style = BAND_STYLES[band];
|
||||
const score = data.total_score ?? 0;
|
||||
const threshold = data.alert_threshold ?? 65;
|
||||
const clamp = (v: number) => Math.min(100, Math.max(0, v));
|
||||
return (
|
||||
<div className={`glass border ${style.ring} p-6`}>
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className={`font-display text-6xl font-bold ${style.text}`}>{Math.round(score)}</span>
|
||||
<span className="text-sm text-gray-500">/ 100</span>
|
||||
</div>
|
||||
<p className={`mt-1 text-sm font-medium ${style.text}`}>{style.label}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TrendChip label="7d" delta={data.trend?.delta_7} />
|
||||
<TrendChip label="30d" delta={data.trend?.delta_30} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Band track with score + threshold markers */}
|
||||
<div className="relative mt-6 h-2 w-full rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
|
||||
<div
|
||||
className="absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded bg-gray-300/80"
|
||||
style={{ left: `${clamp(threshold)}%` }}
|
||||
title={`Alert threshold ${threshold}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style.bar}`}
|
||||
style={{ left: `${clamp(score)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600">
|
||||
<span>0</span><span>30</span><span>60</span><span>80</span><span>100</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs leading-relaxed text-gray-500">
|
||||
An <span className="text-gray-400">index</span> (not a calibrated probability) of how far the AI/Tech bull regime
|
||||
has deteriorated. Mostly coincident signals — it shortens reaction time, it doesn't predict the exact turn.
|
||||
{data.date && <> As of {data.date}.</>}
|
||||
{data.inputs && (data.inputs.vix != null || data.inputs.hy_oas != null) && (
|
||||
<span className="ml-1 text-gray-600">
|
||||
VIX {data.inputs.vix ?? '—'} · HY OAS {data.inputs.hy_oas ?? '—'}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Breakdown({ breakdown }: { breakdown: RegimeSignal[] }) {
|
||||
return (
|
||||
<div className="glass overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-3 font-medium">Signal</th>
|
||||
<th className="px-4 py-3 font-medium">Sub-score</th>
|
||||
<th className="px-4 py-3 text-right font-medium">Weight</th>
|
||||
<th className="px-4 py-3 text-right font-medium">Contribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{breakdown.map((s) => (
|
||||
<tr key={s.id} className="border-b border-white/[0.03] last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-[10px] text-gray-600">{s.id}</span>{' '}
|
||||
<span className="text-gray-300">{s.label}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{s.available && s.sub_score != null ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div className="h-full rounded-full bg-blue-400/70" style={{ width: `${s.sub_score}%` }} />
|
||||
</div>
|
||||
<span className="num text-gray-300">{s.sub_score}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-600">n/a</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right num text-gray-400">{s.weight}</td>
|
||||
<td className="px-4 py-3 text-right num text-gray-300">
|
||||
{s.available ? s.contribution.toFixed(1) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SliderRow({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
|
||||
return (
|
||||
<label className="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span className="w-52 shrink-0">{label}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
className="h-2 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-700 accent-blue-500"
|
||||
/>
|
||||
<span className="w-8 text-right num text-gray-300">{value}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FundamentalsEditor({
|
||||
data,
|
||||
onSave,
|
||||
onRefresh,
|
||||
saving,
|
||||
refreshing,
|
||||
}: {
|
||||
data: RegimeFundamentals;
|
||||
onSave: (body: { f1_score?: number; f3_score?: number; locked?: boolean }) => void;
|
||||
onRefresh: () => void;
|
||||
saving: boolean;
|
||||
refreshing: boolean;
|
||||
}) {
|
||||
const [f1, setF1] = useState(Math.round(data.f1_score));
|
||||
const [f3, setF3] = useState(Math.round(data.f3_score));
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
<span>Source: {data.source}</span>
|
||||
{data.fetched_at && <span>· {new Date(data.fetched_at).toLocaleDateString()}</span>}
|
||||
{data.locked && <Badge label="locked" variant="manual" />}
|
||||
</div>
|
||||
{data.reasoning && <p className="text-xs leading-relaxed text-gray-400">{data.reasoning}</p>}
|
||||
<SliderRow label="F1 · Hyperscaler capex guidance" value={f1} onChange={setF1} />
|
||||
<SliderRow label="F3 · Good news, stock down" value={f3} onChange={setF3} />
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<button
|
||||
className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50"
|
||||
disabled={saving}
|
||||
onClick={() => onSave({ f1_score: f1, f3_score: f3, locked: true })}
|
||||
>
|
||||
Save override
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 disabled:opacity-50"
|
||||
disabled={refreshing}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
{refreshing ? 'Refreshing…' : 'Refresh via LLM'}
|
||||
</button>
|
||||
{data.locked && (
|
||||
<button
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 disabled:opacity-50"
|
||||
disabled={saving}
|
||||
onClick={() => onSave({ locked: false })}
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeightsEditor({
|
||||
data,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
data: RegimeConfig;
|
||||
onSave: (updates: Partial<RegimeConfig>) => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const [weights, setWeights] = useState<Record<string, number>>(() => ({ ...data.weights }));
|
||||
const [threshold, setThreshold] = useState<number>(data.alert_threshold);
|
||||
|
||||
const setWeight = (key: string, value: string) => {
|
||||
const num = parseFloat(value);
|
||||
setWeights((prev) => ({ ...prev, [key]: isNaN(num) ? 0 : num }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{Object.keys(weights).map((key) => (
|
||||
<label key={key} className="flex items-center justify-between gap-2 text-xs text-gray-400">
|
||||
<span className="font-mono text-gray-500">{key}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={weights[key]}
|
||||
onChange={(e) => setWeight(key, e.target.value)}
|
||||
className="w-16 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>Alert threshold</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(parseInt(e.target.value, 10) || 0)}
|
||||
className="w-20 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50"
|
||||
disabled={saving}
|
||||
onClick={() => onSave({ weights, alert_threshold: threshold })}
|
||||
>
|
||||
Save weights
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminControls() {
|
||||
const qc = useQueryClient();
|
||||
const fundamentals = useQuery({ queryKey: ['regime', 'fundamentals'], queryFn: getRegimeFundamentals });
|
||||
const config = useQuery({ queryKey: ['regime', 'config'], queryFn: getRegimeConfig });
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['regime'] });
|
||||
const refresh = useMutation({ mutationFn: refreshRegimeFundamentals, onSuccess: invalidate });
|
||||
const saveFund = useMutation({ mutationFn: updateRegimeFundamentals, onSuccess: invalidate });
|
||||
const saveConfig = useMutation({ mutationFn: updateRegimeConfig, onSuccess: invalidate });
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Disclosure summary="Admin · Fundamentals (F1 / F3)">
|
||||
{fundamentals.isLoading && <SkeletonCard className="h-24" />}
|
||||
{fundamentals.data && (
|
||||
<FundamentalsEditor
|
||||
key={fundamentals.dataUpdatedAt}
|
||||
data={fundamentals.data}
|
||||
onSave={(body) => saveFund.mutate(body)}
|
||||
onRefresh={() => refresh.mutate()}
|
||||
saving={saveFund.isPending}
|
||||
refreshing={refresh.isPending}
|
||||
/>
|
||||
)}
|
||||
{refresh.isError && (
|
||||
<Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>
|
||||
)}
|
||||
</Disclosure>
|
||||
|
||||
<Disclosure summary="Admin · Weights & threshold">
|
||||
{config.isLoading && <SkeletonCard className="h-24" />}
|
||||
{config.data && (
|
||||
<WeightsEditor
|
||||
key={config.dataUpdatedAt}
|
||||
data={config.data}
|
||||
onSave={(updates) => saveConfig.mutate(updates)}
|
||||
saving={saveConfig.isPending}
|
||||
/>
|
||||
)}
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegimePage() {
|
||||
const role = useAuthStore((s) => s.role);
|
||||
const isAdmin = role === 'admin';
|
||||
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
<PageHeader
|
||||
title="Regime Monitor"
|
||||
subtitle="AI/Tech regime-change index — observational, feeds no trades"
|
||||
/>
|
||||
|
||||
{monitor.isLoading && (
|
||||
<>
|
||||
<SkeletonCard className="h-44" />
|
||||
<SkeletonTable rows={6} cols={4} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{monitor.isError && (
|
||||
<Callout variant="error" onRetry={() => monitor.refetch()}>
|
||||
Failed to load: {(monitor.error as Error).message}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{monitor.data && !monitor.data.available && (
|
||||
<Callout variant="empty">
|
||||
Not computed yet — run the “Regime Monitor” job from Admin → Jobs, or wait for the daily pipeline.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{monitor.data && monitor.data.available && (
|
||||
<>
|
||||
<Gauge data={monitor.data} />
|
||||
{monitor.data.breakdown && <Breakdown breakdown={monitor.data.breakdown} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && <AdminControls />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user