diff --git a/app/routers/market.py b/app/routers/market.py index aa73563..02f9068 100644 --- a/app/routers/market.py +++ b/app/routers/market.py @@ -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) diff --git a/app/services/regime_monitor_service.py b/app/services/regime_monitor_service.py index 1f4f5f4..4e22c69 100644 --- a/app/services/regime_monitor_service.py +++ b/app/services/regime_monitor_service.py @@ -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, diff --git a/docs/research/regime-monitor-v2.md b/docs/research/regime-monitor-v2.md index e99edcb..cfafbad 100644 --- a/docs/research/regime-monitor-v2.md +++ b/docs/research/regime-monitor-v2.md @@ -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 diff --git a/frontend/src/api/regime.ts b/frontend/src/api/regime.ts index d90ff09..728806c 100644 --- a/frontend/src/api/regime.ts +++ b/frontend/src/api/regime.ts @@ -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('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('regime/fundamentals', body).then((r) => r.data); } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 1886164..04b4283 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; - good_news_stock_down?: string | null; + capex: Record; + good_news_stock_down: GoodNewsReaction; +} + +export type CapexState = 'raising' | 'holding' | 'cutting' | 'unknown'; +export type GoodNewsReaction = 'yes' | 'no' | 'mixed'; + +export interface RegimeFundamentalsUpdate { + capex?: Record; + good_news_stock_down?: GoodNewsReaction; + locked?: boolean; } export interface RegimeConfig { diff --git a/frontend/src/pages/RegimePage.tsx b/frontend/src/pages/RegimePage.tsx index 5a50c48..fa4fb70 100644 --- a/frontend/src/pages/RegimePage.tsx +++ b/frontend/src/pages/RegimePage.tsx @@ -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>(() => ({ ...data.capex })); + const [reaction, setReaction] = useState(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 ( -
+
Source: {data.source} {data.fetched_at && · fetched {new Date(data.fetched_at).toLocaleDateString()}} @@ -231,18 +247,40 @@ function FundamentalsEditor({ {data.locked && }
{data.reasoning &&

{data.reasoning}

} - {[ - ['F1 · Capex cuts', f1, setF1], - ['F3 · Good news, stock down', f3, setF3], - ].map(([label, value, setter]) => ( - - ))} +
+
+ F1 · Capex guidance by hyperscaler + score {derivedF1 ?? 'n/a'} +
+
+ {Object.entries(capex).map(([symbol, state]) => ( + + ))} +
+

Raising/holding = 0 stress; cutting = 100; at least three known names required.

+
+
- + {data.locked && }
@@ -266,7 +304,7 @@ function ConfigEditor({ data, onSave, saving }: { data: RegimeConfig; onSave: (u days

Changing the basket resets its freeze date and silently reseeds quadrant alerts.

- +
); } @@ -280,16 +318,22 @@ function AdminControls() { const saveFundamentals = useMutation({ mutationFn: updateRegimeFundamentals, onSuccess: invalidate }); const saveConfig = useMutation({ mutationFn: updateRegimeConfig, onSuccess: invalidate }); return ( -
- - {fundamentals.data && saveFundamentals.mutate(body)} onRefresh={() => refresh.mutate()} saving={saveFundamentals.isPending} refreshing={refresh.isPending} />} - {refresh.isError && Refresh failed: {(refresh.error as Error).message}} - - - {config.data && saveConfig.mutate(updates)} saving={saveConfig.isPending} />} - {saveConfig.isError && Save failed: {(saveConfig.error as Error).message}} - -
+ +
+
+
Fundamental observations
+ {fundamentals.isLoading && } + {fundamentals.data && saveFundamentals.mutate(body)} onRefresh={() => refresh.mutate()} saving={saveFundamentals.isPending} refreshing={refresh.isPending} />} + {refresh.isError && Refresh failed: {(refresh.error as Error).message}} +
+
+
Fixed basket & freshness
+ {config.isLoading && } + {config.data && saveConfig.mutate(updates)} saving={saveConfig.isPending} />} + {saveConfig.isError && Save failed: {(saveConfig.error as Error).message}} +
+
+
); } diff --git a/tests/unit/test_regime_monitor.py b/tests/unit/test_regime_monitor.py index b78b8d2..c05715b 100644 --- a/tests/unit/test_regime_monitor.py +++ b/tests/unit/test_regime_monitor.py @@ -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)