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,
|
||||
|
||||
Reference in New Issue
Block a user