fix: use categorical regime fundamentals
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m13s
Deploy / deploy (push) Successful in 37s

This commit is contained in:
2026-07-15 10:13:22 +02:00
parent ced066dc01
commit f714782fa4
7 changed files with 257 additions and 58 deletions
+12 -5
View File
@@ -1,7 +1,9 @@
"""Market-level endpoints (benchmark regime + AI/Tech regime-change monitor)."""
from typing import Literal
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access, require_admin
@@ -55,8 +57,10 @@ class RegimeConfigUpdate(BaseModel):
class RegimeFundamentalsUpdate(BaseModel):
f1_score: float | None = None
f3_score: float | None = None
model_config = ConfigDict(extra="forbid")
capex: dict[str, Literal["raising", "holding", "cutting", "unknown"]] | None = None
good_news_stock_down: Literal["yes", "no", "mixed"] | None = None
locked: bool | None = None
@@ -108,9 +112,12 @@ async def update_regime_fundamentals(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Manually override F1/F3 (locks out the LLM refresh until unlocked)."""
"""Manually override categorical F1/F3 observations."""
data = await regime_monitor_service.set_fundamental_overrides(
db, f1_score=body.f1_score, f3_score=body.f3_score, locked=body.locked
db,
capex=body.capex,
good_news_stock_down=body.good_news_stock_down,
locked=body.locked,
)
return APIEnvelope(status="success", data=data)
+75 -20
View File
@@ -90,6 +90,11 @@ DEFAULT_CONFIG: dict = {
"fundamental_staleness_days": 80,
}
CAPEX_STATES = ("raising", "holding", "cutting", "unknown")
GNSD_STATES = ("yes", "no", "mixed")
_CAPEX_STATE_SCORES = {"raising": 0.0, "holding": 0.0, "cutting": 100.0}
_GNSD_SCORES = {"yes": 100.0, "no": 0.0}
Series = list[tuple[date, float]]
@@ -556,9 +561,13 @@ async def update_regime_config(db: AsyncSession, updates: dict) -> dict:
async def get_fundamental_overrides(db: AsyncSession) -> dict:
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
default = {
"methodology": METHODOLOGY,
"f1_score": None,
"f3_score": None,
"capex": {name: "unknown" for name in names},
"good_news_stock_down": "mixed",
"locked": False,
"reasoning": None,
"fetched_at": None,
@@ -572,21 +581,70 @@ async def get_fundamental_overrides(db: AsyncSession) -> dict:
stored = json.loads(raw)
except (TypeError, ValueError):
return default
return {**default, **stored}
if stored.get("methodology") != METHODOLOGY:
return default
capex = _normalise_capex_states(stored.get("capex"), names)
reaction = str(stored.get("good_news_stock_down", "mixed")).strip().lower()
if reaction not in GNSD_STATES:
reaction = "mixed"
return {
**default,
**stored,
"methodology": METHODOLOGY,
"f1_score": _score_capex_states(capex, names),
"f3_score": _GNSD_SCORES.get(reaction),
"capex": capex,
"good_news_stock_down": reaction,
}
def _normalise_capex_states(
raw: object,
names: list[str],
*,
strict: bool = False,
) -> dict[str, str]:
values = raw if isinstance(raw, dict) else {}
if strict and set(values) != set(names):
raise ValidationError(
f"Capex override must contain exactly: {', '.join(names)}"
)
out: dict[str, str] = {}
for name in names:
state = str(values.get(name, "unknown")).strip().lower()
if state not in CAPEX_STATES:
if strict:
raise ValidationError(f"Invalid capex state for {name}: {state}")
state = "unknown"
out[name] = state
return out
def _score_capex_states(capex: dict[str, str], names: list[str]) -> float | None:
scores = [_CAPEX_STATE_SCORES[capex[name]] for name in names if capex[name] in _CAPEX_STATE_SCORES]
score = _mean(scores) if len(scores) >= 3 else None
return round(score, 1) if score is not None else None
async def set_fundamental_overrides(
db: AsyncSession,
f1_score: float | None = None,
f3_score: float | None = None,
capex: dict[str, str] | None = None,
good_news_stock_down: str | None = None,
locked: bool | None = None,
) -> dict:
current = await get_fundamental_overrides(db)
observation_changed = f1_score is not None or f3_score is not None
if f1_score is not None:
current["f1_score"] = _clamp(float(f1_score))
if f3_score is not None:
current["f3_score"] = _clamp(float(f3_score))
observation_changed = capex is not None or good_news_stock_down is not None
if capex is not None:
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
normalised = _normalise_capex_states(capex, names, strict=True)
current["capex"] = normalised
current["f1_score"] = _score_capex_states(normalised, names)
if good_news_stock_down is not None:
reaction = good_news_stock_down.strip().lower()
if reaction not in GNSD_STATES:
raise ValidationError(f"Invalid good-news-stock-down state: {reaction}")
current["good_news_stock_down"] = reaction
current["f3_score"] = _GNSD_SCORES.get(reaction)
if locked is not None:
current["locked"] = bool(locked)
elif observation_changed:
@@ -594,7 +652,9 @@ async def set_fundamental_overrides(
if observation_changed:
now = datetime.now(timezone.utc)
current.update({
"methodology": METHODOLOGY,
"source": "manual",
"reasoning": None,
"fetched_at": now.isoformat(),
"effective_date": _next_weekday(now.date()).isoformat(),
})
@@ -972,10 +1032,6 @@ async def _call_llm_json(cfg: dict, prompt: str) -> dict:
return json.loads(_strip_fences(response.choices[0].message.content))
_CAPEX_STATE_SCORES = {"raising": 0.0, "holding": 0.0, "cutting": 100.0}
_GNSD_SCORES = {"yes": 100.0, "no": 0.0}
async def refresh_fundamental_overrides(
db: AsyncSession, config: dict | None = None, force: bool = False
) -> dict:
@@ -993,18 +1049,17 @@ async def refresh_fundamental_overrides(
parsed = await _call_llm_json(
llm, _CAPEX_PROMPT.format(names=", ".join(names), example=example)
)
capex = parsed.get("capex", {}) if isinstance(parsed, dict) else {}
scores = [
_CAPEX_STATE_SCORES[value]
for name in names
if (value := str(capex.get(name, "")).strip().lower()) in _CAPEX_STATE_SCORES
]
f1 = _mean(scores) if len(scores) >= 3 else None
raw_capex = parsed.get("capex", {}) if isinstance(parsed, dict) else {}
capex = _normalise_capex_states(raw_capex, names)
f1 = _score_capex_states(capex, names)
reaction = str(parsed.get("good_news_stock_down", "")).strip().lower()
if reaction not in GNSD_STATES:
reaction = "mixed"
f3 = _GNSD_SCORES.get(reaction)
now = datetime.now(timezone.utc)
result = {
"f1_score": round(f1, 1) if f1 is not None else None,
"methodology": METHODOLOGY,
"f1_score": f1,
"f3_score": f3,
"capex": capex,
"good_news_stock_down": reaction or None,
+4
View File
@@ -27,6 +27,10 @@ Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v2.
Zero means ordinary/healthy, and only stress contributes positively. Automated
capex `raising`/`holding` and no good-news-stock-down pattern map to zero;
`mixed`, unknown, and stale observations are unavailable rather than neutral 50.
Manual observations use the same categories: each hyperscaler is marked
`raising`, `holding`, `cutting`, or `unknown`, while the earnings reaction is
`yes`, `no`, or `mixed`. F1 is derived from the share of at least three known
hyperscalers marked `cutting`; arbitrary numeric overrides are not accepted.
Scores renormalize over available fixed weights, but a band is published only at
75% or greater coverage. Trend deltas are suppressed when the participating
+2 -5
View File
@@ -3,6 +3,7 @@ import type {
RegimeMonitor,
RegimeConfig,
RegimeFundamentals,
RegimeFundamentalsUpdate,
EventStudyReport,
RegimeHistoryPoint,
} from '../lib/types';
@@ -33,11 +34,7 @@ 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;
}) {
export function updateRegimeFundamentals(body: RegimeFundamentalsUpdate) {
return apiClient.put<RegimeFundamentals>('regime/fundamentals', body).then((r) => r.data);
}
+12 -2
View File
@@ -511,6 +511,7 @@ export interface RegimeMonitor {
}
export interface RegimeFundamentals {
methodology: 'v2';
f1_score: number | null;
f3_score: number | null;
locked: boolean;
@@ -518,8 +519,17 @@ export interface RegimeFundamentals {
fetched_at: string | null;
effective_date: string | null;
source: string;
capex?: Record<string, string>;
good_news_stock_down?: string | null;
capex: Record<string, CapexState>;
good_news_stock_down: GoodNewsReaction;
}
export type CapexState = 'raising' | 'holding' | 'cutting' | 'unknown';
export type GoodNewsReaction = 'yes' | 'no' | 'mixed';
export interface RegimeFundamentalsUpdate {
capex?: Record<string, CapexState>;
good_news_stock_down?: GoodNewsReaction;
locked?: boolean;
}
export interface RegimeConfig {
+63 -19
View File
@@ -16,10 +16,13 @@ import {
updateRegimeFundamentals,
} from '../api/regime';
import type {
CapexState,
EventStudyReport,
GoodNewsReaction,
RegimeBand,
RegimeConfig,
RegimeFundamentals,
RegimeFundamentalsUpdate,
RegimeReading,
} from '../lib/types';
@@ -207,6 +210,15 @@ function EventStudyPanel() {
);
}
const SELECT_CLASS = 'rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1.5 text-xs text-gray-200';
const CAPEX_OPTIONS: { value: CapexState; label: string }[] = [
{ value: 'raising', label: 'Raising' },
{ value: 'holding', label: 'Holding' },
{ value: 'cutting', label: 'Cutting' },
{ value: 'unknown', label: 'Unknown' },
];
function FundamentalsEditor({
data,
onSave,
@@ -215,15 +227,19 @@ function FundamentalsEditor({
refreshing,
}: {
data: RegimeFundamentals;
onSave: (body: { f1_score?: number; f3_score?: number; locked?: boolean }) => void;
onSave: (body: RegimeFundamentalsUpdate) => void;
onRefresh: () => void;
saving: boolean;
refreshing: boolean;
}) {
const [f1, setF1] = useState(data.f1_score ?? 0);
const [f3, setF3] = useState(data.f3_score ?? 0);
const [capex, setCapex] = useState<Record<string, CapexState>>(() => ({ ...data.capex }));
const [reaction, setReaction] = useState<GoodNewsReaction>(data.good_news_stock_down);
const knownCapex = Object.values(capex).filter((state) => state !== 'unknown');
const cutting = knownCapex.filter((state) => state === 'cutting').length;
const derivedF1 = knownCapex.length >= 3 ? Math.round((cutting / knownCapex.length) * 1000) / 10 : null;
const derivedF3 = reaction === 'yes' ? 100 : reaction === 'no' ? 0 : null;
return (
<div className="space-y-3">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span>Source: {data.source}</span>
{data.fetched_at && <span>· fetched {new Date(data.fetched_at).toLocaleDateString()}</span>}
@@ -231,18 +247,40 @@ function FundamentalsEditor({
{data.locked && <Badge label="locked" variant="manual" />}
</div>
{data.reasoning && <p className="text-xs leading-relaxed text-gray-400">{data.reasoning}</p>}
{[
['F1 · Capex cuts', f1, setF1],
['F3 · Good news, stock down', f3, setF3],
].map(([label, value, setter]) => (
<label key={String(label)} className="flex items-center gap-3 text-xs text-gray-400">
<span className="w-52 shrink-0">{String(label)}</span>
<input type="range" min={0} max={100} value={Number(value)} onChange={(event) => (setter as (v: number) => void)(Number(event.target.value))} className="h-2 flex-1 accent-blue-500" />
<span className="w-8 text-right num text-gray-300">{Number(value)}</span>
<div>
<div className="mb-2 flex items-center justify-between gap-3 text-xs">
<span className="font-medium text-gray-300">F1 · Capex guidance by hyperscaler</span>
<span className="num text-gray-500">score {derivedF1 ?? 'n/a'}</span>
</div>
<div className="grid grid-cols-2 gap-2">
{Object.entries(capex).map(([symbol, state]) => (
<label key={symbol} className="flex items-center justify-between gap-2 rounded-md bg-white/[0.02] px-2.5 py-2 text-xs text-gray-400">
<span className="font-mono text-gray-300">{symbol}</span>
<select
className={SELECT_CLASS}
value={state}
onChange={(event) => setCapex((current) => ({ ...current, [symbol]: event.target.value as CapexState }))}
>
{CAPEX_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
))}
</div>
<p className="mt-1.5 text-[11px] text-gray-600">Raising/holding = 0 stress; cutting = 100; at least three known names required.</p>
</div>
<label className="flex items-center justify-between gap-3 text-xs text-gray-400">
<span>
<span className="font-medium text-gray-300">F3 · Good news, stock down</span>
<span className="ml-2 num text-gray-600">score {derivedF3 ?? 'n/a'}</span>
</span>
<select className={SELECT_CLASS} value={reaction} onChange={(event) => setReaction(event.target.value as GoodNewsReaction)}>
<option value="yes">Yes · stress</option>
<option value="no">No · ordinary</option>
<option value="mixed">Mixed · unavailable</option>
</select>
</label>
<div className="flex flex-wrap gap-2">
<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="btn-primary px-3 py-1.5 text-sm disabled:opacity-50" disabled={saving} onClick={() => onSave({ capex, good_news_stock_down: reaction, locked: true })}>Save observations</button>
<button className="rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-white/[0.04] 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]" onClick={() => onSave({ locked: false })}>Unlock</button>}
</div>
@@ -266,7 +304,7 @@ function ConfigEditor({ data, onSave, saving }: { data: RegimeConfig; onSave: (u
<span>days</span>
</label>
<p className="text-[11px] text-gray-600">Changing the basket resets its freeze date and silently reseeds quadrant alerts.</p>
<button className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50" disabled={saving || symbols.length < 20} onClick={() => onSave({ breadth_basket: symbols, fundamental_staleness_days: staleness })}>Save monitor settings</button>
<button className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50" disabled={saving || symbols.length < 20} onClick={() => onSave({ breadth_basket: symbols, fundamental_staleness_days: staleness })}>Save basket &amp; freshness</button>
</div>
);
}
@@ -280,16 +318,22 @@ function AdminControls() {
const saveFundamentals = useMutation({ mutationFn: updateRegimeFundamentals, onSuccess: invalidate });
const saveConfig = useMutation({ mutationFn: updateRegimeConfig, onSuccess: invalidate });
return (
<div className="space-y-3">
<Disclosure summary="Admin · Fundamental observations">
<Disclosure summary="Admin · Monitor settings">
<div className="grid gap-5 xl:grid-cols-2 xl:gap-6">
<section className="border-b border-white/[0.06] pb-5 xl:border-b-0 xl:border-r xl:pb-0 xl:pr-6">
<div className="mb-3 text-[11px] uppercase tracking-wider text-gray-500">Fundamental observations</div>
{fundamentals.isLoading && <SkeletonCard className="h-36" />}
{fundamentals.data && <FundamentalsEditor key={fundamentals.dataUpdatedAt} data={fundamentals.data} onSave={(body) => saveFundamentals.mutate(body)} onRefresh={() => refresh.mutate()} saving={saveFundamentals.isPending} refreshing={refresh.isPending} />}
{refresh.isError && <Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>}
</Disclosure>
<Disclosure summary="Admin · Fixed basket & freshness">
</section>
<section>
<div className="mb-3 text-[11px] uppercase tracking-wider text-gray-500">Fixed basket &amp; freshness</div>
{config.isLoading && <SkeletonCard className="h-36" />}
{config.data && <ConfigEditor key={config.dataUpdatedAt} data={config.data} onSave={(updates) => saveConfig.mutate(updates)} saving={saveConfig.isPending} />}
{saveConfig.isError && <Callout variant="error">Save failed: {(saveConfig.error as Error).message}</Callout>}
</Disclosure>
</section>
</div>
</Disclosure>
);
}
+82
View File
@@ -7,6 +7,7 @@ import json
from datetime import date, timedelta
import pytest
from pydantic import ValidationError as PydanticValidationError
from sqlalchemy import select
from app.models.regime_snapshot import RegimeSnapshot
@@ -110,11 +111,52 @@ def test_fundamentals_never_replay_before_effective_date_and_expire():
assert _fundamental_scores_asof(overrides, config, date(2026, 8, 22))[:2] == (None, None)
def test_capex_score_is_derived_from_company_categories():
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
assert rms._score_capex_states(
dict.fromkeys(names, "holding"), names
) == 0.0
assert rms._score_capex_states(
{names[0]: "cutting", **dict.fromkeys(names[1:], "holding")}, names
) == 25.0
assert rms._score_capex_states(
{names[0]: "cutting", names[1]: "holding", names[2]: "holding", names[3]: "unknown"},
names,
) == 33.3
assert rms._score_capex_states(
{names[0]: "cutting", names[1]: "holding", names[2]: "unknown", names[3]: "unknown"},
names,
) is None
def test_fundamental_api_rejects_numeric_ordinal_overrides():
with pytest.raises(PydanticValidationError):
market_router.RegimeFundamentalsUpdate(f3_score=75)
@pytest.mark.asyncio
async def test_legacy_numeric_fundamentals_do_not_leak_into_v2(monkeypatch):
async def fake_value(_db, _key):
return json.dumps({"f1_score": 75.0, "f3_score": 75.0, "source": "manual"})
monkeypatch.setattr(rms.settings_store, "get_value", fake_value)
result = await rms.get_fundamental_overrides(object())
assert result["methodology"] == "v2"
assert result["f1_score"] is None
assert result["f3_score"] is None
assert result["good_news_stock_down"] == "mixed"
@pytest.mark.asyncio
async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch):
stored = {
"methodology": "v2",
"f1_score": 100.0,
"f3_score": 0.0,
"capex": dict.fromkeys(DEFAULT_CONFIG["tickers"]["hyperscalers"], "cutting"),
"good_news_stock_down": "no",
"locked": True,
"source": "manual",
"fetched_at": "2026-06-01T10:00:00+00:00",
@@ -139,6 +181,46 @@ async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch):
assert saved == result
@pytest.mark.asyncio
async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch):
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
current = {
"methodology": "v2",
"f1_score": None,
"f3_score": None,
"capex": dict.fromkeys(names, "unknown"),
"good_news_stock_down": "mixed",
"locked": False,
"reasoning": "old reasoning",
"fetched_at": None,
"effective_date": None,
"source": "default",
}
saved: dict = {}
async def fake_get(_db):
return dict(current)
async def fake_update(_db, _key, value):
saved.update(json.loads(value))
monkeypatch.setattr(rms, "get_fundamental_overrides", fake_get)
monkeypatch.setattr(rms, "update_setting", fake_update)
capex = {names[0]: "cutting", **dict.fromkeys(names[1:], "holding")}
result = await rms.set_fundamental_overrides(
object(), capex=capex, good_news_stock_down="mixed"
)
assert result["f1_score"] == 25.0
assert result["f3_score"] is None
assert result["good_news_stock_down"] == "mixed"
assert result["source"] == "manual"
assert result["locked"] is True
assert result["reasoning"] is None
assert saved == result
@pytest.mark.asyncio
async def test_prior_v2_snapshot_is_immutable_without_explicit_rebuild(db_session):
snapshot_date = date(2026, 6, 26)