fix: use categorical regime fundamentals
This commit is contained in:
+12
-5
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user