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)."""
|
"""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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">
|
||||||
</label>
|
<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">
|
<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 & 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">
|
||||||
{fundamentals.data && <FundamentalsEditor key={fundamentals.dataUpdatedAt} data={fundamentals.data} onSave={(body) => saveFundamentals.mutate(body)} onRefresh={() => refresh.mutate()} saving={saveFundamentals.isPending} refreshing={refresh.isPending} />}
|
<section className="border-b border-white/[0.06] pb-5 xl:border-b-0 xl:border-r xl:pb-0 xl:pr-6">
|
||||||
{refresh.isError && <Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>}
|
<div className="mb-3 text-[11px] uppercase tracking-wider text-gray-500">Fundamental observations</div>
|
||||||
</Disclosure>
|
{fundamentals.isLoading && <SkeletonCard className="h-36" />}
|
||||||
<Disclosure summary="Admin · Fixed basket & freshness">
|
{fundamentals.data && <FundamentalsEditor key={fundamentals.dataUpdatedAt} data={fundamentals.data} onSave={(body) => saveFundamentals.mutate(body)} onRefresh={() => refresh.mutate()} saving={saveFundamentals.isPending} refreshing={refresh.isPending} />}
|
||||||
{config.data && <ConfigEditor key={config.dataUpdatedAt} data={config.data} onSave={(updates) => saveConfig.mutate(updates)} saving={saveConfig.isPending} />}
|
{refresh.isError && <Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>}
|
||||||
{saveConfig.isError && <Callout variant="error">Save failed: {(saveConfig.error as Error).message}</Callout>}
|
</section>
|
||||||
</Disclosure>
|
<section>
|
||||||
</div>
|
<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>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Disclosure>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user