feat: add standalone AI/Tech regime-change monitor tab
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 46s
Deploy / deploy (push) Successful in 27s

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:
2026-06-26 11:51:45 +02:00
parent 5605915d45
commit ebff19940b
18 changed files with 1600 additions and 3 deletions
+354
View File
@@ -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>
);
}