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).""" """Market-level endpoints (benchmark regime + AI/Tech regime-change monitor)."""
from typing import Literal
from fastapi import APIRouter, Depends, Query 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 sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access, require_admin from app.dependencies import get_db, require_access, require_admin
@@ -55,8 +57,10 @@ class RegimeConfigUpdate(BaseModel):
class RegimeFundamentalsUpdate(BaseModel): class RegimeFundamentalsUpdate(BaseModel):
f1_score: float | None = None model_config = ConfigDict(extra="forbid")
f3_score: float | None = None
capex: dict[str, Literal["raising", "holding", "cutting", "unknown"]] | None = None
good_news_stock_down: Literal["yes", "no", "mixed"] | None = None
locked: bool | None = None locked: bool | None = None
@@ -108,9 +112,12 @@ async def update_regime_fundamentals(
_admin: User = Depends(require_admin), _admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> APIEnvelope: ) -> 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( 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) return APIEnvelope(status="success", data=data)
+75 -20
View File
@@ -90,6 +90,11 @@ DEFAULT_CONFIG: dict = {
"fundamental_staleness_days": 80, "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]] 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: async def get_fundamental_overrides(db: AsyncSession) -> dict:
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
default = { default = {
"methodology": METHODOLOGY,
"f1_score": None, "f1_score": None,
"f3_score": None, "f3_score": None,
"capex": {name: "unknown" for name in names},
"good_news_stock_down": "mixed",
"locked": False, "locked": False,
"reasoning": None, "reasoning": None,
"fetched_at": None, "fetched_at": None,
@@ -572,21 +581,70 @@ async def get_fundamental_overrides(db: AsyncSession) -> dict:
stored = json.loads(raw) stored = json.loads(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
return default 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( async def set_fundamental_overrides(
db: AsyncSession, db: AsyncSession,
f1_score: float | None = None, capex: dict[str, str] | None = None,
f3_score: float | None = None, good_news_stock_down: str | None = None,
locked: bool | None = None, locked: bool | None = None,
) -> dict: ) -> dict:
current = await get_fundamental_overrides(db) current = await get_fundamental_overrides(db)
observation_changed = f1_score is not None or f3_score is not None observation_changed = capex is not None or good_news_stock_down is not None
if f1_score is not None: if capex is not None:
current["f1_score"] = _clamp(float(f1_score)) names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
if f3_score is not None: normalised = _normalise_capex_states(capex, names, strict=True)
current["f3_score"] = _clamp(float(f3_score)) 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: if locked is not None:
current["locked"] = bool(locked) current["locked"] = bool(locked)
elif observation_changed: elif observation_changed:
@@ -594,7 +652,9 @@ async def set_fundamental_overrides(
if observation_changed: if observation_changed:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
current.update({ current.update({
"methodology": METHODOLOGY,
"source": "manual", "source": "manual",
"reasoning": None,
"fetched_at": now.isoformat(), "fetched_at": now.isoformat(),
"effective_date": _next_weekday(now.date()).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)) 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( async def refresh_fundamental_overrides(
db: AsyncSession, config: dict | None = None, force: bool = False db: AsyncSession, config: dict | None = None, force: bool = False
) -> dict: ) -> dict:
@@ -993,18 +1049,17 @@ async def refresh_fundamental_overrides(
parsed = await _call_llm_json( parsed = await _call_llm_json(
llm, _CAPEX_PROMPT.format(names=", ".join(names), example=example) llm, _CAPEX_PROMPT.format(names=", ".join(names), example=example)
) )
capex = parsed.get("capex", {}) if isinstance(parsed, dict) else {} raw_capex = parsed.get("capex", {}) if isinstance(parsed, dict) else {}
scores = [ capex = _normalise_capex_states(raw_capex, names)
_CAPEX_STATE_SCORES[value] f1 = _score_capex_states(capex, names)
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
reaction = str(parsed.get("good_news_stock_down", "")).strip().lower() reaction = str(parsed.get("good_news_stock_down", "")).strip().lower()
if reaction not in GNSD_STATES:
reaction = "mixed"
f3 = _GNSD_SCORES.get(reaction) f3 = _GNSD_SCORES.get(reaction)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
result = { result = {
"f1_score": round(f1, 1) if f1 is not None else None, "methodology": METHODOLOGY,
"f1_score": f1,
"f3_score": f3, "f3_score": f3,
"capex": capex, "capex": capex,
"good_news_stock_down": reaction or None, "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 Zero means ordinary/healthy, and only stress contributes positively. Automated
capex `raising`/`holding` and no good-news-stock-down pattern map to zero; capex `raising`/`holding` and no good-news-stock-down pattern map to zero;
`mixed`, unknown, and stale observations are unavailable rather than neutral 50. `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 Scores renormalize over available fixed weights, but a band is published only at
75% or greater coverage. Trend deltas are suppressed when the participating 75% or greater coverage. Trend deltas are suppressed when the participating
+2 -5
View File
@@ -3,6 +3,7 @@ import type {
RegimeMonitor, RegimeMonitor,
RegimeConfig, RegimeConfig,
RegimeFundamentals, RegimeFundamentals,
RegimeFundamentalsUpdate,
EventStudyReport, EventStudyReport,
RegimeHistoryPoint, RegimeHistoryPoint,
} from '../lib/types'; } from '../lib/types';
@@ -33,11 +34,7 @@ export function getRegimeFundamentals() {
return apiClient.get<RegimeFundamentals>('regime/fundamentals').then((r) => r.data); return apiClient.get<RegimeFundamentals>('regime/fundamentals').then((r) => r.data);
} }
export function updateRegimeFundamentals(body: { export function updateRegimeFundamentals(body: RegimeFundamentalsUpdate) {
f1_score?: number;
f3_score?: number;
locked?: boolean;
}) {
return apiClient.put<RegimeFundamentals>('regime/fundamentals', body).then((r) => r.data); 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 { export interface RegimeFundamentals {
methodology: 'v2';
f1_score: number | null; f1_score: number | null;
f3_score: number | null; f3_score: number | null;
locked: boolean; locked: boolean;
@@ -518,8 +519,17 @@ export interface RegimeFundamentals {
fetched_at: string | null; fetched_at: string | null;
effective_date: string | null; effective_date: string | null;
source: string; source: string;
capex?: Record<string, string>; capex: Record<string, CapexState>;
good_news_stock_down?: string | null; 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 { export interface RegimeConfig {
+63 -19
View File
@@ -16,10 +16,13 @@ import {
updateRegimeFundamentals, updateRegimeFundamentals,
} from '../api/regime'; } from '../api/regime';
import type { import type {
CapexState,
EventStudyReport, EventStudyReport,
GoodNewsReaction,
RegimeBand, RegimeBand,
RegimeConfig, RegimeConfig,
RegimeFundamentals, RegimeFundamentals,
RegimeFundamentalsUpdate,
RegimeReading, RegimeReading,
} from '../lib/types'; } 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({ function FundamentalsEditor({
data, data,
onSave, onSave,
@@ -215,15 +227,19 @@ function FundamentalsEditor({
refreshing, refreshing,
}: { }: {
data: RegimeFundamentals; data: RegimeFundamentals;
onSave: (body: { f1_score?: number; f3_score?: number; locked?: boolean }) => void; onSave: (body: RegimeFundamentalsUpdate) => void;
onRefresh: () => void; onRefresh: () => void;
saving: boolean; saving: boolean;
refreshing: boolean; refreshing: boolean;
}) { }) {
const [f1, setF1] = useState(data.f1_score ?? 0); const [capex, setCapex] = useState<Record<string, CapexState>>(() => ({ ...data.capex }));
const [f3, setF3] = useState(data.f3_score ?? 0); 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 ( 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"> <div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span>Source: {data.source}</span> <span>Source: {data.source}</span>
{data.fetched_at && <span>· fetched {new Date(data.fetched_at).toLocaleDateString()}</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" />} {data.locked && <Badge label="locked" variant="manual" />}
</div> </div>
{data.reasoning && <p className="text-xs leading-relaxed text-gray-400">{data.reasoning}</p>} {data.reasoning && <p className="text-xs leading-relaxed text-gray-400">{data.reasoning}</p>}
{[ <div>
['F1 · Capex cuts', f1, setF1], <div className="mb-2 flex items-center justify-between gap-3 text-xs">
['F3 · Good news, stock down', f3, setF3], <span className="font-medium text-gray-300">F1 · Capex guidance by hyperscaler</span>
].map(([label, value, setter]) => ( <span className="num text-gray-500">score {derivedF1 ?? 'n/a'}</span>
<label key={String(label)} className="flex items-center gap-3 text-xs text-gray-400"> </div>
<span className="w-52 shrink-0">{String(label)}</span> <div className="grid grid-cols-2 gap-2">
<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" /> {Object.entries(capex).map(([symbol, state]) => (
<span className="w-8 text-right num text-gray-300">{Number(value)}</span> <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> </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"> <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> <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>} {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> </div>
@@ -266,7 +304,7 @@ function ConfigEditor({ data, onSave, saving }: { data: RegimeConfig; onSave: (u
<span>days</span> <span>days</span>
</label> </label>
<p className="text-[11px] text-gray-600">Changing the basket resets its freeze date and silently reseeds quadrant alerts.</p> <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> </div>
); );
} }
@@ -280,16 +318,22 @@ function AdminControls() {
const saveFundamentals = useMutation({ mutationFn: updateRegimeFundamentals, onSuccess: invalidate }); const saveFundamentals = useMutation({ mutationFn: updateRegimeFundamentals, onSuccess: invalidate });
const saveConfig = useMutation({ mutationFn: updateRegimeConfig, onSuccess: invalidate }); const saveConfig = useMutation({ mutationFn: updateRegimeConfig, onSuccess: invalidate });
return ( return (
<div className="space-y-3"> <Disclosure summary="Admin · Monitor settings">
<Disclosure summary="Admin · Fundamental observations"> <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} />} {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>} {refresh.isError && <Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>}
</Disclosure> </section>
<Disclosure summary="Admin · Fixed basket & freshness"> <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} />} {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>} {saveConfig.isError && <Callout variant="error">Save failed: {(saveConfig.error as Error).message}</Callout>}
</Disclosure> </section>
</div> </div>
</Disclosure>
); );
} }
+82
View File
@@ -7,6 +7,7 @@ import json
from datetime import date, timedelta from datetime import date, timedelta
import pytest import pytest
from pydantic import ValidationError as PydanticValidationError
from sqlalchemy import select from sqlalchemy import select
from app.models.regime_snapshot import RegimeSnapshot 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) 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 @pytest.mark.asyncio
async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch): async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch):
stored = { stored = {
"methodology": "v2",
"f1_score": 100.0, "f1_score": 100.0,
"f3_score": 0.0, "f3_score": 0.0,
"capex": dict.fromkeys(DEFAULT_CONFIG["tickers"]["hyperscalers"], "cutting"),
"good_news_stock_down": "no",
"locked": True, "locked": True,
"source": "manual", "source": "manual",
"fetched_at": "2026-06-01T10:00:00+00:00", "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 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 @pytest.mark.asyncio
async def test_prior_v2_snapshot_is_immutable_without_explicit_rebuild(db_session): async def test_prior_v2_snapshot_is_immutable_without_explicit_rebuild(db_session):
snapshot_date = date(2026, 6, 26) snapshot_date = date(2026, 6, 26)