feat(risk-monitor): measure the rule that fires, and give fundamentals their own channel
Deploy / lint (push) Failing after 11s
Deploy / test (push) Skipped
Deploy / deploy (push) Skipped

The Warning study measured a fitted percentile crossing that nothing consumes.
What reaches Telegram is a quadrant change: fixed 50/40 dividers, hysteresis,
two-session confirmation, 3-day cooldown. Those thresholds are constants, not
fits, so there is no training set to protect and all 11 detected corrections are
evaluable instead of the 4 that fell in a holdout.

Replaying it: 1/10 corrections, 0.9 false alarms/year. Random alarms at the same
firing rate match or beat that in 65% of draws. The panel now carries ablations
(does the quadrant machinery earn its place?), external baselines (does the score
earn its complexity?), and that null, because a bare "2 of 4" was unreadable in
either direction. Nothing in the alert path was retuned on the strength of it.

Fundamentals become a third channel rather than a term in either score. v3 cut
them arguing 12+8 of 100 points "could not change any published conclusion" --
true only when every technical sensor reads zero; weighted they moved the bar for
the 40 divider from 40 to 25. But no fusion weight is measurable either: with ~10
events and no fundamental history, any weight is a policy preference presented as
a measurement. So the read is a categorical state (supportive/neutral/adverse/
unknown) with an evidence grade, derived by fixed rules from stored facts, read
by confluence. The LLM extracts and explains; it does not score.

Absence stays absence throughout. `unknown` is unreachable by averaging, a stale
or empty observation may display but never confirm, extraction failures map to
`unknown` rather than `mixed`, and the study rows are coverage-matched and marked
not-measurable until enough corrections are covered -- otherwise a fortnight of
observations renders as 0/10 and reads as a failed test.

Observations become a real time series (migration 033); they lived in a single
overwritten settings slot, so no history existed to replay. Pre-rename snapshots
are adapted rather than discarded. METHODOLOGY stays v4 -- no score changed --
so no reseed; STUDY_SCHEMA moves to 3 and discards the cached report.

Post-deploy: re-run Event Study from Admin -> Jobs. The panel reads "not run yet"
until then.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 11:15:09 +02:00
co-authored by Claude Opus 5
parent 3033ad83fd
commit 333989eeab
18 changed files with 3494 additions and 403 deletions
@@ -0,0 +1,67 @@
"""Point-in-time history for the sourced fundamental observation
Revision ID: 033
Revises: 032
Create Date: 2026-08-12 00:00:00.000000
The hyperscaler capex / "good news, stock down" read lived in a single
``SystemSetting`` slot, so each refresh overwrote the last and no history
existed. The read is now a categorical channel reported alongside State and
Warning (never a term in either), and a channel with no history cannot be
replayed: a snapshot rebuild would record every historical session as if nothing
had ever been observed, and the event study could not measure the channel at all.
Keyed on ``effective_date`` (the session the observation becomes usable on,
normally the next weekday) rather than ``fetched_at``, because that is the gate
that stops a rebuild stamping today's reading onto historical rows.
The table starts empty. ``update_regime_monitor`` records the currently stored
observation on its next run, so a deployment does not lose the live reading —
but genuine history does not exist and cannot be invented here. Backfilling it
from the SEC capex line and earnings-date reactions is separate work; until then
every historical session reads ``unknown``, which is the honest value rather than
a guessed one.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "033"
down_revision: Union[str, None] = "032"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"regime_fundamental_observations",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("effective_date", sa.Date(), nullable=False),
sa.Column("f1_score", sa.Float(), nullable=True),
sa.Column("f3_score", sa.Float(), nullable=True),
sa.Column("capex_json", sa.Text(), nullable=False),
sa.Column("good_news_stock_down", sa.String(length=10), nullable=False),
sa.Column("reasoning", sa.Text(), nullable=True),
sa.Column("source", sa.String(length=30), nullable=False),
sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"effective_date", name="uq_regime_fundamental_observations_effective_date"
),
)
op.create_index(
"ix_regime_fundamental_observations_effective_date",
"regime_fundamental_observations",
["effective_date"],
)
def downgrade() -> None:
op.drop_index(
"ix_regime_fundamental_observations_effective_date",
table_name="regime_fundamental_observations",
)
op.drop_table("regime_fundamental_observations")
+2
View File
@@ -14,6 +14,7 @@ from app.models.settings import SystemSetting, IngestionProgress
from app.models.alert import AlertLog
from app.models.paper_trade import PaperTrade
from app.models.regime_snapshot import RegimeSnapshot
from app.models.regime_fundamental_observation import RegimeFundamentalObservation
from app.models.benchmark_price import BenchmarkPrice
from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.system_event import SystemEvent
@@ -39,6 +40,7 @@ __all__ = [
"AlertLog",
"PaperTrade",
"RegimeSnapshot",
"RegimeFundamentalObservation",
"BenchmarkPrice",
"SignalContextSnapshot",
"SystemEvent",
@@ -0,0 +1,44 @@
from datetime import date as date_type
from datetime import datetime
from sqlalchemy import Date, DateTime, Float, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class RegimeFundamentalObservation(Base):
"""Point-in-time record of the sourced hyperscaler capex / earnings read.
One row per ``effective_date`` (unique, upserted). Before this table the
observation lived in a single ``SystemSetting`` slot, so every refresh
overwrote the previous one and no history existed at all — which made the
read impossible to replay, impossible to backtest, and meant a snapshot
rebuild could only ever score historical sessions as if nothing had been
observed.
The read is a categorical channel reported beside State and Warning, never a
term in either, so this series is not a scoring input. It is the record that
makes the channel replayable at all -- and the only route to eventually
testing whether it improves prediction conditional on Warning, which is the
one thing that could justify combining the channels later.
``effective_date`` rather than ``fetched_at`` is the key: it is the session
the observation becomes usable on (normally the next weekday), and the gate
that stops a rebuild stamping today's reading onto historical rows.
"""
__tablename__ = "regime_fundamental_observations"
id: Mapped[int] = mapped_column(primary_key=True)
effective_date: Mapped[date_type] = mapped_column(
Date, nullable=False, unique=True, index=True
)
f1_score: Mapped[float | None] = mapped_column(Float, nullable=True)
f3_score: Mapped[float | None] = mapped_column(Float, nullable=True)
capex_json: Mapped[str] = mapped_column(Text, nullable=False)
good_news_stock_down: Mapped[str] = mapped_column(String(10), nullable=False)
reasoning: Mapped[str | None] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String(30), nullable=False)
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+8 -2
View File
@@ -1351,8 +1351,11 @@ async def run_event_study_job() -> None:
report = await run_event_study_and_store(db)
_runtime_progress(job_name, processed=1, total=1)
shipped = report.get("shipped") or {}
if report.get("available"):
metrics = report.get("metrics") or {}
# The shipped quadrant rule is the headline; the fitted-threshold
# variant lives under report["fitted"] and is not what fires.
metrics = shipped.get("metrics") or {}
msg = (
f"{metrics.get('events_warned', 0)}/{metrics.get('events', 0)} warned, "
f"{metrics.get('false_alarms_per_year', 0)} false alarms/year"
@@ -1360,7 +1363,10 @@ async def run_event_study_job() -> None:
else:
msg = report.get("reason", "no data")
_runtime_finish(job_name, "completed", processed=1, total=1, message=msg)
_log_event(logging.INFO, "job_complete", job=job_name, events=len(report.get("events", [])))
_log_event(
logging.INFO, "job_complete", job=job_name,
events=len(shipped.get("events") or []),
)
except Exception as exc:
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
+108
View File
@@ -97,6 +97,14 @@ SIGNAL_BUNDLE_MAX_CHARS = 3900 # Telegram limit is 4096; keep room for HTML par
# Hysteresis (a deadband around each divider) stops a point sitting on a boundary
# from flip-flopping; the cooldown caps how often a genuine change can re-alert.
QUAD_TYPE = "regime_quadrant"
# The fundamental channel gets its own alerts rather than shifting a score:
# "the context changed" and "both channels are elevated" are different facts from
# "the market axes moved", and fusing them into one number would destroy exactly
# the information an operator uses to decide how much the alert is worth.
FUND_TYPE = "regime_fundamental"
CONFLUENCE_TYPE = "regime_confluence"
# States that count as fundamental risk for the confluence test.
FUND_ADVERSE = "adverse"
QUAD_X_DIV = 50.0 # v3 State divider (backend response is authoritative)
QUAD_Y_DIV = 40.0 # v3 Warning divider; the axes have different ranges
QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider
@@ -859,16 +867,111 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
)
else:
metrics = f"State {x:.0f} · Warning {y:.0f}"
# The fundamental channel is reported, never added in: this alert is about
# the two market axes, and the context is stated beside them so a reader can
# judge confluence themselves rather than being handed a fused number.
context = data.get("fundamental_context") or {}
context_line = (
f"fundamentals: {context.get('state', 'unknown')} "
f"({context.get('evidence_quality', 'unavailable')})\n"
)
text = (
f"🧭 <b>AI/Tech risk quadrant change</b>\n"
f"{QUAD_LABELS.get(prev, prev)}{QUAD_LABELS.get(new_q, new_q)}\n"
f"{metrics}\n"
f"{context_line}"
f"coverage: state {state.get('coverage'):.0f}% / warning {warning.get('coverage'):.0f}%\n"
f"<i>Risk thermometer - not a trade signal.</i>"
)
return [(_quadrant_log_key(new_q, x, y, basket_hash), text)]
async def _last_logged_key(db: AsyncSession, alert_type: str) -> str | None:
"""Most recent logged key for a type, our baseline for change detection."""
result = await db.execute(
select(AlertLog.dedup_key)
.where(AlertLog.alert_type == alert_type)
.order_by(AlertLog.created_at.desc())
.limit(1)
)
row = result.first()
return row[0] if row else None
async def _collect_regime_fundamental(db: AsyncSession) -> list[tuple[str, str, str]]:
"""Fundamental-context changes and market/fundamental confluence.
Two triggers, deliberately separate from the quadrant alert and from each
other, because they answer different questions: *what the evidence says* and
*whether both channels agree*. Neither is derived by moving a score.
``unknown`` never alerts. An absence of evidence is not a change in the
evidence, and alerting on it would train the reader to ignore the channel.
Both seed silently on first run, exactly as the quadrant alert does.
"""
from app.services.regime_monitor_service import get_regime_monitor
data = await get_regime_monitor(db)
if not data.get("available"):
return []
warning = data.get("warning") or {}
context = data.get("fundamental_context") or {}
state = str(context.get("state") or "unknown")
# `usable`, not `available`: the state is deliberately preserved past its
# staleness horizon so the card can keep showing the last thing observed, and
# an observation whose extraction failed is fresh but knows nothing. Neither
# may confirm anything — without this gate a months-old adverse read silently
# corroborates every new Warning crossing forever, which is the strongest
# claim this channel makes and the one it has least right to make.
usable = bool(context.get("usable"))
score = warning.get("score")
quality = data.get("data_quality") or {}
if not quality.get("is_fresh") or float(warning.get("coverage") or 0) < 75:
return []
quadrant_cfg = data.get("quadrant_config") or {}
y_div = float(quadrant_cfg.get("warning_divider", QUAD_Y_DIV))
warning_elevated = score is not None and float(score) >= y_div
out: list[tuple[str, str, str]] = []
previous_state = await _last_logged_key(db, FUND_TYPE)
if previous_state is None:
_log_alert(db, FUND_TYPE, state) # seed
elif previous_state != state and state != "unknown" and usable:
effective = context.get("effective_date")
out.append((
FUND_TYPE,
state,
f"📋 <b>Fundamental context changed</b>\n"
f"{previous_state}{state}\n"
f"evidence: {context.get('evidence_quality', 'unavailable')}"
+ (f" · effective {effective}" if effective else "")
+ "\n<i>Context channel — not a score, not a trade signal.</i>",
))
confluence = "yes" if (warning_elevated and state == FUND_ADVERSE and usable) else "no"
previous_confluence = await _last_logged_key(db, CONFLUENCE_TYPE)
if previous_confluence is None:
_log_alert(db, CONFLUENCE_TYPE, confluence) # seed
elif previous_confluence != confluence and confluence == "yes":
out.append((
CONFLUENCE_TYPE,
confluence,
f"⚠️ <b>Confluence: market and fundamental risk both elevated</b>\n"
f"Warning {float(score):.0f} (≥ {y_div:.0f}) with fundamentals {state}\n"
f"evidence: {context.get('evidence_quality', 'unavailable')}\n"
f"<i>Highest attention. Still a thermometer — not a trade signal.</i>",
))
elif previous_confluence != confluence:
# Falling out of confluence is a state change worth recording as the new
# baseline, but not worth a message.
_log_alert(db, CONFLUENCE_TYPE, confluence)
return out
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
@@ -961,6 +1064,11 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
# cooldown/hysteresis handled in the collector (like score drops)
for key, text in await _collect_regime_quadrant(db):
outgoing.append((QUAD_TYPE, key, text))
# Deliberately three separate messages off one toggle, not one fused
# signal: the market axes and the fundamental channel are different kinds
# of evidence, and an operator needs to know which one moved.
for alert_type, key, text in await _collect_regime_fundamental(db):
outgoing.append((alert_type, key, text))
if cfg["trade_closed"]:
for key, text, pnl_usd in await _collect_closed_trades(db):
+770 -71
View File
@@ -1,15 +1,48 @@
"""Compact chronological validation for the AI/Tech Risk Monitor warning score.
"""Chronological validation for the AI/Tech Risk Monitor warning score.
The study calls its outcome a 10% correction, uses the first 70% of sessions to
freeze an 80th-percentile warning threshold, and reports alarm episodes only on
the final 30%. It is still labelled exploratory while the fixed breadth basket
is reconstructed before its freeze date.
The outcome is a 10% correction in the leader, never a regime break. Two rules
are measured against it, and they answer different questions:
* **shipped** -- the quadrant-change rule that actually reaches Telegram
(``alert_service._collect_regime_quadrant``). Its thresholds are fixed
constants chosen by scenario arithmetic, so nothing is fitted, so there is no
training set to protect and the whole sample is evaluable. This is the
headline.
* **fitted** -- the original study: an 80th-percentile Warning threshold frozen
on the first 70% of sessions and measured on the last 30%. Kept because it is
what the methodology document reports, and because a fitted threshold is a
genuinely different question -- but it is measured on the four corrections that
happen to fall in the holdout, which is too few to read as a property of the
score.
Both are scored by the same ``evaluate_alarms`` harness, alongside ablations
(does the quadrant machinery earn its place?), external baselines (does the
score earn its complexity?), and a random-alarm null (is any of this better than
chance?). Without those rows a bare "2 of 4" is unreadable in either direction.
The fundamental channel is compared, never fused. It appears as its own rule
(transitions into an adverse state), as a confluence gate (a market crossing kept
only when the state agrees), and as a market-only comparator over the identical
window -- because with ~10 correction events and almost no fundamental history,
any weight that combined it with the market axes would be a policy preference
presented as a measurement.
Those three rows are **coverage-matched**: scored only on the sessions where the
channel had usable context and on the corrections whose warning horizon fell
inside it, and marked ``measurable: false`` until enough corrections are covered.
A fundamental rule scores zero whether it is wrong or merely absent, so scoring
it over the market rows' full sample would turn a fortnight of observations into
a 0/10 that reads as a failed test.
Still labelled exploratory while the fixed breadth basket is reconstructed
before its freeze date.
"""
from __future__ import annotations
import json
import logging
import random
from datetime import date, datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,11 +50,26 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.services import breadth_service, settings_store
from app.services import regime_monitor_service as rms
from app.services.admin_service import update_setting
from app.services.alert_service import (
QUAD_COOLDOWN_DAYS,
QUAD_MARGIN,
QUAD_X_DIV,
QUAD_Y_DIV,
_classify_quadrant,
)
logger = logging.getLogger(__name__)
KEY_REPORT = "regime_event_study"
# Report shape, independent of METHODOLOGY. A cached report from an older shape
# parses fine and reports the current methodology, so without this check the
# panel would render a report missing half its blocks. Bumping discards the cache
# the way a methodology change does -- and it is the *only* thing that does so
# here, because the fundamental-channel rework left METHODOLOGY on v4 (the scores
# did not change), so the methodology check cannot catch a stale report.
STUDY_SCHEMA = 3
EVENT_THRESHOLD_PCT = 10.0
EVENT_COOLDOWN_DAYS = 40
DRAWDOWN_LOOKBACK = 252
@@ -33,6 +81,21 @@ TRAIN_FRACTION = 0.70
MIN_EVENTS_FOR_CONFIDENCE = 8
SENSOR_MISMATCH_TOLERANCE = 0.10
# _collect_regime_quadrant confirms against get_regime_history(db, days=14), so a
# prior session older than that window is not available to confirm with.
QUAD_HISTORY_DAYS = 14
# Quadrants with Warning above its divider: "1" early warning, "2" active stress.
WARNING_QUADRANTS = ("1", "2")
STRESS_QUADRANT = ("2",)
# Draws for the random-alarm null. Seeded, because a cached report that moves
# on re-run for RNG reasons is worse than no report.
NULL_DRAWS = 2000
NULL_SEED = 20260812
BASELINE_SMA_WINDOW = 50
BASELINE_VIX_LEVEL = 20.0
def _median(values: list[float]) -> float | None:
if not values:
@@ -148,41 +211,355 @@ def evaluate_alarms(
}
def _warning_series(
def _score_rule(
alarm_indices: list[int],
event_indices: list[int],
dates: list[date],
horizon: int,
sessions: int,
) -> dict:
"""``evaluate_alarms`` plus the annualised false-alarm rate for one rule.
The rate is ``None`` when the rule had no eligible sessions. Dividing by a
tiny floor instead produced 5e9 alarms/year for a coverage-matched rule with
an empty window -- a number that means "undefined" while looking like a
measurement, which is the failure mode this whole panel is built to avoid.
"""
metrics = evaluate_alarms(alarm_indices, event_indices, dates, horizon)
metrics["false_alarms_per_year"] = (
round(metrics["false_alarms"] / (sessions / 252.0), 2) if sessions > 0 else None
)
return metrics
# ---------------------------------------------------------------------------
# The shipped rule
# ---------------------------------------------------------------------------
def _axis_rows(
prices: dict[str, rms.Series],
breadth_divergence: dict[date, float],
vix_series: rms.Series | None,
oas_series: rms.Series | None,
breadth_series: rms.Series | None,
divergence_series: rms.Series | None,
dates: list[date],
config: dict,
oas_series: rms.Series | None = None,
) -> tuple[dict[date, float], dict[date, int]]:
"""Warning score per session plus how many sensors backed it.
observations: list[dict] | None = None,
) -> dict[date, dict]:
"""State and Warning per session, from the function that writes snapshots.
v2 re-derived this by hand from ``WARNING_WEIGHTS`` and so would have kept
measuring the old construct after a scoring change. Since v3 dropped
fundamentals from the score, this is now exactly the live Warning score
rather than a technical-only approximation of it.
Calling ``_compute_index`` rather than re-deriving the two axes is the same
anti-drift argument that produced ``warning_sensor_scores``: the v2 study
re-derived Warning by hand and would have kept measuring the old construct
through a scoring change. State has no such shared helper, so the whole
snapshot builder is the shared definition.
The sensor count matters because the score renormalises over whatever is
available: a session backed by two sensors is not drawn from the same
distribution as one backed by three, and the frozen threshold assumes it is.
``observations`` is the point-in-time fundamental series. It does not enter
either score -- the fundamental channel is categorical and read by confluence
-- but the per-session ``fundamental_state`` it produces is what the
confluence rule below is measured on, so it has to be the same series
production reports from. Every variant in this module reads its Warning from
these rows, so there is no second derivation to fall out of step.
"""
tickers = config["tickers"]
smh_full = prices.get(tickers["leaders"][0], [])
spy_full = prices.get(tickers["market"], [])
out: dict[date, float] = {}
backing: dict[date, int] = {}
rows: dict[date, dict] = {}
for session in dates:
sensors = rms.warning_sensor_scores(
breadth_divergence.get(session),
rms._closes_asof(smh_full, session),
rms._closes_asof(spy_full, session),
rms._window_asof(oas_series, session, rms.HY_OAS_WINDOW_DAYS),
snapshot = rms._compute_index(
prices,
vix_series,
oas_series,
{},
config,
session,
breadth_series=breadth_series,
divergence_series=divergence_series,
observations=observations or [],
)
score = rms.score_warning_sensors(sensors)
if score is not None:
out[session] = round(score, 2)
backing[session] = sum(1 for value in sensors.values() if value is not None)
return out, backing
state = snapshot["state"]
warning = snapshot["warning"]
rows[session] = {
"state": state.get("score"),
"warning": warning.get("score"),
"fundamental_state": (snapshot.get("fundamental_context") or {}).get("state"),
# `usable`, not `available`: a stale observation keeps its state for
# display but stops counting as evidence, and an observation whose
# extraction failed on everything is fresh but knows nothing. Either
# one counted here would inflate the covered window with sessions the
# channel could not have contributed to.
"fundamental_usable": bool(
(snapshot.get("fundamental_context") or {}).get("usable")
),
"state_coverage": state.get("coverage") or 0.0,
"warning_coverage": warning.get("coverage") or 0.0,
# The score renormalises over available sensors, so a session backed
# by two is not drawn from the same distribution as one backed by
# three, and a frozen threshold assumes it is.
"warning_sensors": len(warning.get("available_pillars") or []),
"inputs_fresh": bool((snapshot.get("data_quality") or {}).get("inputs_fresh")),
}
return rows
def _publishable(row: dict | None) -> bool:
"""What ``get_regime_history`` leaves for the alert to confirm against.
Deliberately not freshness-gated: ``_collect_regime_quadrant`` checks
``is_fresh`` on today's live reading only, while the prior session comes from
stored history where the only filter is a published band on both axes.
"""
return (
row is not None
and row["state"] is not None
and row["warning"] is not None
and row["state_coverage"] >= rms.MIN_COVERAGE
and row["warning_coverage"] >= rms.MIN_COVERAGE
)
def _prior_publishable(
rows: dict[date, dict], dates: list[date], index: int, history_days: int
) -> dict | None:
"""``valid[-2]``: the previous published session inside the 14-day window.
The monitor writes today's snapshot before the alert step runs
(``job_catalog._DAILY_PIPELINE_STEPS``), so ``valid[-1]`` is today and this
is genuinely the prior session rather than t-2.
"""
cutoff = dates[index] - timedelta(days=history_days)
for position in range(index - 1, -1, -1):
if dates[position] < cutoff:
return None
candidate = rows.get(dates[position])
if _publishable(candidate):
return candidate
return None
def replay_quadrant_changes(
rows: dict[date, dict],
dates: list[date],
state_divider: float = QUAD_X_DIV,
warning_divider: float = QUAD_Y_DIV,
margin: float = QUAD_MARGIN,
cooldown_days: int = QUAD_COOLDOWN_DAYS,
history_days: int = QUAD_HISTORY_DAYS,
) -> list[dict]:
"""Every quadrant change the shipped alert would have sent, in order.
A faithful replay of ``_collect_regime_quadrant``, including three details a
state machine written from first principles gets wrong:
* the prior session is classified against the *current baseline*, not against
its own predecessor, so confirmation asks "did yesterday already look like
this change" rather than "did yesterday change too";
* the baseline advances only when an alert actually fires, so a change that
fails confirmation or cooldown is re-evaluated against the old quadrant on
the next session rather than being forgotten;
* one cooldown is shared by every quadrant change, so a 3->4 alert can
swallow a 4->2 alert three days later.
Returns the fires themselves rather than alarm indices, because which
transitions count as a *warning* is the caller's question: entering
Warning-high territory and entering both-high territory are different rules
over the same replay.
"""
fires: list[dict] = []
baseline: str | None = None
baseline_date: date | None = None
for index, session in enumerate(dates):
row = rows.get(session)
if not _publishable(row) or not row["inputs_fresh"]:
continue
x, y = float(row["state"]), float(row["warning"])
if baseline is None: # seeds silently, exactly as a fresh install does
baseline = _classify_quadrant(x, y, None, margin, state_divider, warning_divider)
baseline_date = session
continue
new_quadrant = _classify_quadrant(x, y, baseline, margin, state_divider, warning_divider)
if new_quadrant == baseline:
continue
prior = _prior_publishable(rows, dates, index, history_days)
if prior is None:
continue
prior_quadrant = _classify_quadrant(
float(prior["state"]), float(prior["warning"]),
baseline, margin, state_divider, warning_divider,
)
if prior_quadrant != new_quadrant:
continue
if baseline_date is not None and (session - baseline_date).days < cooldown_days:
continue
fires.append({
"index": index,
"date": session.isoformat(),
"from": baseline,
"to": new_quadrant,
"state": x,
"warning": y,
})
baseline, baseline_date = new_quadrant, session
return fires
def entry_alarms(fires: list[dict], entry: tuple[str, ...]) -> list[int]:
"""Fires that *enter* the given quadrant set from outside it."""
return [f["index"] for f in fires if f["to"] in entry and f["from"] not in entry]
# ---------------------------------------------------------------------------
# Ablations, baselines, null
# ---------------------------------------------------------------------------
def _usable_adverse(rows: dict[date, dict], session: date) -> bool:
"""Adverse *and* still within its staleness horizon.
Both callers need this pair, and neither may use the state alone: the state
survives going stale so the card can show it, which would otherwise let a
months-old read confirm crossings indefinitely.
"""
row = rows.get(session) or {}
return row.get("fundamental_state") == "adverse" and bool(row.get("fundamental_usable"))
def adverse_episodes(
rows: dict[date, dict], dates: list[date], start_index: int
) -> list[int]:
"""Sessions where the fundamental state *becomes* usably adverse.
The market rules alarm on a rising-edge crossing; a categorical state has no
crossing, so its analogue is the transition into ``adverse``. That keeps the
row comparable with every other row in the table rather than counting every
day the state happens to sit there.
"""
alarms: list[int] = []
was_adverse = start_index > 0 and _usable_adverse(rows, dates[start_index - 1])
for index in range(start_index, len(dates)):
if dates[index] not in rows:
continue
adverse = _usable_adverse(rows, dates[index])
if adverse and not was_adverse:
alarms.append(index)
was_adverse = adverse
return alarms
def confluence_episodes(
warning_alarms: list[int], rows: dict[date, dict], dates: list[date]
) -> list[int]:
"""Warning crossings that happen while the fundamental state is usably adverse.
Deliberately gated on the market crossing rather than on either channel
moving: it preserves the rising-edge semantics every other row uses, so the
column measures "does requiring fundamental agreement help?" instead of a
differently-shaped rule that cannot be compared with the others.
"""
return [index for index in warning_alarms if _usable_adverse(rows, dates[index])]
def covered_events(
event_indices: list[int],
rows: dict[date, dict],
dates: list[date],
horizon: int,
) -> list[int]:
"""Corrections a fundamental rule actually had a chance to warn about.
An alarm counts only if it fires in ``[event - horizon, event - 1]``, so a
correction is *coverable* only if the channel had usable context somewhere in
that window. Scoring these rules against every correction instead would make
one day of observation render as 0/10 -- an untested rule reported as a
failed one, which is the exact mistake the ``measurable`` flag exists to
prevent for the empty-table case.
"""
covered: list[int] = []
for event_index in event_indices:
window = range(max(0, event_index - horizon), event_index)
if any(
bool((rows.get(dates[index]) or {}).get("fundamental_usable"))
for index in window
):
covered.append(event_index)
return covered
def eligible_sessions(
rows: dict[date, dict], dates: list[date], start_index: int
) -> int:
"""Sessions a fundamental rule could have fired on, for the FA/year rate.
Annualising over the whole window instead would divide a rule's false alarms
by years in which it was structurally incapable of firing, reporting a
flattering rate that means nothing.
"""
return sum(
1
for session in dates[start_index:]
if bool((rows.get(session) or {}).get("fundamental_usable"))
)
def below_average_series(
series: rms.Series, window: int = BASELINE_SMA_WINDOW
) -> dict[date, float]:
"""100 while the close sits under its ``window``-session average, else 0."""
out: dict[date, float] = {}
closes = [value for _, value in series]
for index, (session, close) in enumerate(series):
if index + 1 < window:
continue
average = sum(closes[index + 1 - window: index + 1]) / window
out[session] = 100.0 if close < average else 0.0
return out
def _null_model(
alarm_count: int,
event_indices: list[int],
dates: list[date],
horizon: int,
start_index: int,
observed_warned: int,
draws: int = NULL_DRAWS,
seed: int = NULL_SEED,
) -> dict | None:
"""Recall from alarms scattered at random over the same evaluable sessions.
Drawn only from sessions a real rule could have fired on: over the whole
sample the null would be diluted by warm-up sessions and would understate
what chance achieves. That matters here -- with ~11 events and a 20-session
horizon, a sixth of the sample already sits inside a hit window.
Corrections cluster, and uniform placement does not, so this is the floor
rather than the bar: an alarm process that clusters would beat it for
reasons that have nothing to do with foresight.
"""
population = range(start_index, len(dates))
if alarm_count <= 0 or not event_indices or alarm_count > len(population):
return None
rng = random.Random(seed)
recalls: list[int] = []
for _ in range(draws):
picks = sorted(rng.sample(population, alarm_count))
recalls.append(evaluate_alarms(picks, event_indices, dates, horizon)["events_warned"])
mean = sum(recalls) / len(recalls)
variance = sum((value - mean) ** 2 for value in recalls) / len(recalls)
return {
"draws": draws,
"alarms_per_draw": alarm_count,
"events": len(event_indices),
"mean_warned": round(mean, 2),
"sd_warned": round(variance ** 0.5, 2),
"observed_warned": observed_warned,
"p_at_least_observed": round(
sum(1 for value in recalls if value >= observed_warned) / len(recalls), 3
),
}
def _reliability(
@@ -192,9 +569,9 @@ def _reliability(
events_detected: int,
events_in_holdout: int,
) -> dict:
"""How far the headline metrics can actually be trusted.
"""How far the *fitted* variant's headline metrics can be trusted.
Two things repeatedly invite over-reading this report:
Two things repeatedly invite over-reading it:
* The holdout carries only the corrections that fall in the last 30% of the
sample. A "2/4" is one event away from "3/4", and in practice the events
@@ -203,6 +580,9 @@ def _reliability(
* The score renormalises over available sensors, so a training window that
predates a sensor's history freezes a threshold on a different construct
than the holdout is measured against.
Neither applies to the shipped rule, whose thresholds are fixed constants --
but the second one does not vanish, it relocates: see ``_era_split``.
"""
expected = len(rms.WARNING_WEIGHTS)
train = [backing[d] for d in dates[:split] if d in backing]
@@ -221,6 +601,126 @@ def _reliability(
}
def _era_split(
alarms: list[int],
event_indices: list[int],
dates: list[date],
horizon: int,
start_index: int,
credit_from: date | None,
) -> dict | None:
"""Shipped-rule metrics either side of the credit sensor's first session.
Dropping the fitted threshold makes the whole sample evaluable, which is the
point -- but most of the extra events sit before 2023-08, where W3 does not
exist and Warning renormalises to ``(W1*45 + W2*30)/75``. The fixed 40
divider is then applied to a different construct than it was reasoned about,
so the coverage caveat does not disappear with the split; it relocates from
the threshold to the score. Reporting the two eras separately is what keeps
the fuller sample from being a differently misleading headline.
The pre-credit era is close to a "Warning without W3" ablation on real
sessions -- and a clean one, because the fundamental channel is not a term in
Warning at all, so the two eras differ by W3 and nothing else. That stays
true however much fundamental history accumulates.
Alarms and events are assigned to eras by index, so an alarm days before the
boundary that matched an event days after it lands in the earlier era. With
the eras years long and the events sparse, that costs nothing.
"""
if credit_from is None:
return None
boundary = next(
(index for index, session in enumerate(dates) if session >= credit_from), None
)
if boundary is None or boundary <= start_index or boundary >= len(dates):
return None
def slice_metrics(low: int, high: int) -> dict:
sessions = max(0, high - low)
metrics = _score_rule(
[a for a in alarms if low <= a < high],
[e for e in event_indices if low <= e < high],
dates, horizon, sessions,
)
metrics.pop("per_event", None)
metrics["sessions"] = sessions
return metrics
return {
"credit_from": credit_from.isoformat(),
"pre_credit": {
"label": "W1+W2 only",
"start": dates[start_index].isoformat(),
"end": dates[boundary - 1].isoformat(),
**slice_metrics(start_index, boundary),
},
"full_coverage": {
"label": "all three sensors",
"start": dates[boundary].isoformat(),
"end": dates[-1].isoformat(),
**slice_metrics(boundary, len(dates)),
},
}
def _warning_from_rows(
rows: dict[date, dict], dates: list[date]
) -> tuple[dict[date, float], dict[date, int]]:
"""Published Warning per session plus how many sensors backed it.
Read off ``_axis_rows`` rather than recomputed. v2 re-derived Warning by hand
from ``WARNING_WEIGHTS`` and would have kept measuring the old construct
after a scoring change; a second derivation here would have done the same to
any later change to how Warning is assembled -- silently, in the fitted
variant and the ``warning_bare`` ablation, while the shipped replay moved on
without it.
"""
out: dict[date, float] = {}
backing: dict[date, int] = {}
for session in dates:
row = rows.get(session)
if row is None or row["warning"] is None:
continue
out[session] = float(row["warning"])
backing[session] = int(row["warning_sensors"])
return out, backing
def _rule_row(
rule_id: str,
label: str,
kind: str,
note: str,
alarms: list[int],
event_indices: list[int],
dates: list[date],
horizon: int,
sessions: int,
measurable: bool = True,
) -> dict:
"""One comparison row.
``measurable=False`` marks a rule whose *input* is too thin to have been
tested, not one that failed. A fundamental rule scores 0/N whether it is
wrong or merely absent, and a 0/N sitting in this table would read as
tested-and-failed -- the same false precision the whole restructure exists to
remove. It stays false until the channel has covered
``MIN_EVENTS_FOR_CONFIDENCE`` corrections, because a 1/1 or 0/2 over a
two-week exposure is not a result either.
"""
metrics = _score_rule(alarms, event_indices, dates, horizon, sessions)
metrics.pop("per_event", None)
return {
"id": rule_id,
"label": label,
"kind": kind,
"note": note,
"measurable": measurable,
**metrics,
}
async def run_event_study(
db: AsyncSession,
threshold_pct: float = EVENT_THRESHOLD_PCT,
@@ -242,55 +742,202 @@ async def run_event_study(
)
divergence = breadth_service.compute_divergence_series(breadth, benchmark)
oas_series = await rms._fetch_fred_series("BAMLH0A0HYM2", start, end)
warning, backing = _warning_series(prices, divergence, dates, config, oas_series)
# State needs volatility, which the Warning-only study never fetched.
vix_series = await rms._fetch_fred_series("VIXCLS", start, end)
# The point-in-time fundamental series. It is not in either score; it drives
# the categorical channel the confluence rule below is measured on.
observations = await rms.get_fundamental_observations(db)
# The credit sensor cannot reach back as far as the price history does (the
# upstream series is capped at ~3 years), so the earlier part of the sample
# scores on W1+W2 alone via renormalisation. Report where W3 starts rather
# than letting the threshold quietly straddle two sensor sets.
credit_from = oas_series[0][0].isoformat() if oas_series else None
credit_from = oas_series[0][0] if oas_series else None
all_events = detect_events(closes, dates, threshold_pct)
all_event_indices = [event["index"] for event in all_events]
# --- one pass; every rule below reads its Warning from these rows ----
rows = _axis_rows(
prices,
vix_series,
oas_series,
rms._mapping_series(breadth),
rms._mapping_series(divergence),
dates,
config,
observations,
)
warning, backing = _warning_from_rows(rows, dates)
fires = replay_quadrant_changes(rows, dates)
# Nothing can alarm before the baseline seeds, so every rule is measured from
# the same session and the comparison stays like-for-like.
seeded = next(
(
index
for index, session in enumerate(dates)
if _publishable(rows.get(session)) and rows[session]["inputs_fresh"]
),
None,
)
if seeded is None:
return {"available": False, "reason": "no session with publishable coverage"}
evaluable_start = seeded + 1
evaluable_sessions = max(1, len(dates) - evaluable_start)
evaluable_events = [index for index in all_event_indices if index >= evaluable_start]
warning_alarms = entry_alarms(fires, WARNING_QUADRANTS)
shipped_metrics = _score_rule(
warning_alarms, evaluable_events, dates, horizon, evaluable_sessions
)
shipped_events = shipped_metrics.pop("per_event")
# --- the fitted variant, kept for continuity -------------------------
split = max(1, min(len(dates) - 1, int(len(dates) * TRAIN_FRACTION)))
train_values = [warning[d] for d in dates[:split] if d in warning]
warn_threshold = _percentile(train_values, WARN_PERCENTILE)
if warn_threshold is None:
return {"available": False, "reason": "insufficient warning history"}
all_events = detect_events(closes, dates, threshold_pct)
holdout_events = [event["index"] for event in all_events if event["index"] >= split]
alarms = alarm_episodes(warning, dates, warn_threshold, start_index=split)
metrics = evaluate_alarms(alarms, holdout_events, dates, horizon)
holdout_events = [index for index in all_event_indices if index >= split]
fitted_alarms = alarm_episodes(warning, dates, warn_threshold, start_index=split)
holdout_sessions = max(1, len(dates) - split)
metrics["false_alarms_per_year"] = round(
metrics["false_alarms"] / (holdout_sessions / 252.0), 2
fitted_metrics = _score_rule(
fitted_alarms, holdout_events, dates, horizon, holdout_sessions
)
fitted_events = fitted_metrics.pop("per_event")
reliability = _reliability(dates, split, backing, len(all_events), len(holdout_events))
# --- ablations and baselines, all on fixed thresholds ----------------
# Fitted thresholds are deliberately excluded here: a threshold fitted on the
# full sample would have lookahead the shipped rule does not, and one fitted
# on a training split could only be scored on the four holdout events. Fixed
# constants keep every row on the same events over the same sessions.
state_series = {
session: row["state"] for session, row in rows.items() if row["state"] is not None
}
vix_indicator = {
session: value
for session in dates
if (value := rms._value_asof(vix_series, session)) is not None
}
# The fundamental channel is categorical and never enters a score, so it is
# compared as its own rule and as a confluence gate rather than tuned as a
# weight. With an empty observation series both are unmeasurable, and say so.
fundamental_alarms = adverse_episodes(rows, dates, evaluable_start)
confluence_alarms = confluence_episodes(warning_alarms, rows, dates)
# Coverage-matched denominators. These rules only existed on the sessions the
# channel had usable context, so scoring them over the whole window would
# report an exposure they never had -- and one day of coverage would render
# as 0/10.
fundamental_events = covered_events(evaluable_events, rows, dates, horizon)
fundamental_sessions = eligible_sessions(rows, dates, evaluable_start)
fundamental_measurable = len(fundamental_events) >= MIN_EVENTS_FOR_CONFIDENCE
comparison = [
_rule_row(
"fundamental_adverse", "Fundamental context turns adverse", "fundamental",
"The third channel on its own: transitions into an adverse capex / "
"earnings-reaction state, with no market input at all.",
fundamental_alarms, fundamental_events, dates, horizon, fundamental_sessions,
measurable=fundamental_measurable,
),
_rule_row(
"confluence", "Confluence: Warning crossing while adverse", "fundamental",
"The shipped market crossing, kept only when the fundamental channel "
"agrees. Answers whether requiring agreement buys precision, at what "
"cost in recall.",
confluence_alarms, fundamental_events, dates, horizon, fundamental_sessions,
measurable=fundamental_measurable,
),
_rule_row(
"market_over_covered", "Quadrant alert, covered window only", "fundamental",
"The shipped market rule scored on exactly the events, sessions and "
"alarms the two rows above were scored on. Without it, any difference "
"between them and the headline could be the window rather than the "
"channel.",
# Alarms are restricted to the covered window too: counting crossings
# that fired when the channel had no context would compare the market
# rule's full exposure against the channel's partial one.
[
index
for index in warning_alarms
if index >= evaluable_start
and bool((rows.get(dates[index]) or {}).get("fundamental_usable"))
],
fundamental_events, dates, horizon, fundamental_sessions,
measurable=fundamental_measurable,
),
_rule_row(
"quadrant_stress_entry", "Quadrant alert, both axes high", "ablation",
"The same replay, recording only entries into the both-high quadrant. "
"State is coincident by construction, so requiring it should convert "
"leads into confirmations.",
entry_alarms(fires, STRESS_QUADRANT),
evaluable_events, dates, horizon, evaluable_sessions,
),
_rule_row(
"warning_bare", f"Warning >= {QUAD_Y_DIV:.0f} (bare crossing)", "ablation",
"The shipped divider with none of the quadrant machinery: no State "
"condition, no hysteresis, no confirmation, no cooldown.",
alarm_episodes(warning, dates, QUAD_Y_DIV, start_index=evaluable_start),
evaluable_events, dates, horizon, evaluable_sessions,
),
_rule_row(
"state_bare", f"State >= {QUAD_X_DIV:.0f} (bare crossing)", "ablation",
"The coincident axis alone. State measures stress that has already "
"arrived, so a competitive lead here would be surprising.",
alarm_episodes(state_series, dates, QUAD_X_DIV, start_index=evaluable_start),
evaluable_events, dates, horizon, evaluable_sessions,
),
_rule_row(
"smh_below_50dma", f"{leader} below its {BASELINE_SMA_WINDOW}-DMA", "baseline",
"The crudest possible trend rule, and free.",
alarm_episodes(
below_average_series(benchmark, BASELINE_SMA_WINDOW), dates,
50.0, start_index=evaluable_start,
),
evaluable_events, dates, horizon, evaluable_sessions,
),
_rule_row(
"vix_level", f"VIX >= {BASELINE_VIX_LEVEL:.0f}", "baseline",
"The market's own risk gauge, unweighted and unmodelled.",
alarm_episodes(
vix_indicator, dates, BASELINE_VIX_LEVEL, start_index=evaluable_start
),
evaluable_events, dates, horizon, evaluable_sessions,
),
]
null_model = _null_model(
len(warning_alarms), evaluable_events, dates, horizon,
evaluable_start, shipped_metrics["events_warned"],
# Passed rather than defaulted: a default argument binds the constant at
# import, so overriding it (in tests) would silently do nothing.
draws=NULL_DRAWS, seed=NULL_SEED,
)
eras = _era_split(
warning_alarms, evaluable_events, dates, horizon, evaluable_start, credit_from
)
basket_asof = date.fromisoformat(config["basket_asof"])
retrospective = dates[split] < basket_asof
retrospective = dates[evaluable_start] < basket_asof
evaluation = "exploratory" if retrospective else "holdout"
lead_text = (
f"median lead {metrics['median_lead_days']:.0f} sessions"
if metrics["median_lead_days"] is not None
f"median lead {shipped_metrics['median_lead_days']:.0f} sessions"
if shipped_metrics["median_lead_days"] is not None
else "no successful warning lead"
)
summary = (
f"{evaluation.capitalize()} chronological test: warning episodes preceded "
f"{metrics['events_warned']}/{metrics['events']} 10% corrections; "
f"{metrics['events_missed']} missed, {metrics['false_alarms_per_year']:.1f} "
f"false alarms/year, {lead_text}. "
f"{metrics['events']} of {reliability['events_detected']} detected corrections "
f"fall in the test period"
+ (
"; too few to read recall as a property of the score."
if reliability["underpowered"]
else "."
f"{evaluation.capitalize()} replay of the shipped quadrant alert over "
f"{evaluable_sessions} sessions: it entered Warning-high territory ahead of "
f"{shipped_metrics['events_warned']} of {shipped_metrics['events']} 10% "
f"corrections, with {shipped_metrics['false_alarms_per_year']:.1f} false "
f"alarms/year and {lead_text}. Its dividers are fixed constants rather than "
f"fitted, so there is no training split and every detected correction is "
f"evaluable — compare it against the ablations and baselines below before "
f"reading the ratio as good or bad."
)
)
per_event = metrics.pop("per_event")
report = {
"available": True,
"schema": STUDY_SCHEMA,
"methodology": rms.METHODOLOGY,
"generated_at": datetime.now(timezone.utc).isoformat(),
"evaluation": evaluation,
@@ -301,24 +948,69 @@ async def run_event_study(
"event_threshold_pct": threshold_pct,
"event_cooldown_days": EVENT_COOLDOWN_DAYS,
"horizon_days": horizon,
"train_fraction": TRAIN_FRACTION,
"warn_percentile": WARN_PERCENTILE,
"warn_threshold": round(warn_threshold, 1),
"credit_sensor_from": credit_from,
"credit_sensor_from": credit_from.isoformat() if credit_from else None,
"basket_hash": rms._basket_hash(config["breadth_basket"]),
"basket_asof": config["basket_asof"],
},
# The channel's actual exposure, which is what its rows are scored on.
# The series starts empty -- the observation lived in a single
# overwritten settings slot until 2026-08-12 -- and it accumulates one
# observation at a time, so for a long while these rows are unmeasurable
# rather than unsuccessful. Stating the exposure is what stops the table
# inventing a failed result out of a thin one.
"fundamental_coverage": {
"observations": len(observations),
"sessions_eligible": fundamental_sessions,
"evaluable_sessions": evaluable_sessions,
"events_covered": len(fundamental_events),
"events_evaluable": len(evaluable_events),
"minimum_events": MIN_EVENTS_FOR_CONFIDENCE,
"measurable": fundamental_measurable,
},
"sample": {
"start": dates[0].isoformat(),
"end": dates[-1].isoformat(),
"sessions": len(dates),
# Not "test_start": the shipped rule fits nothing, so this is where
# the baseline seeds and every rule becomes measurable, not where a
# holdout begins. The fitted variant's split lives under "fitted".
"evaluable_from": dates[evaluable_start].isoformat(),
"evaluable_sessions": evaluable_sessions,
"events_detected": len(all_events),
"events_evaluable": len(evaluable_events),
},
"shipped": {
"rule": {
"state_divider": QUAD_X_DIV,
"warning_divider": QUAD_Y_DIV,
"margin": QUAD_MARGIN,
"confirm_sessions": 2,
"cooldown_days": QUAD_COOLDOWN_DAYS,
"entry": "Warning-high quadrant (early warning or active stress)",
},
"metrics": shipped_metrics,
"events": shipped_events,
"quadrant_changes": len(fires),
"fires": fires,
"by_era": eras,
},
"comparison": comparison,
"null_model": null_model,
"fitted": {
"params": {
"train_fraction": TRAIN_FRACTION,
"warn_percentile": WARN_PERCENTILE,
"warn_threshold": round(warn_threshold, 1),
},
"sample": {
"train_end": dates[split - 1].isoformat(),
"test_start": dates[split].isoformat(),
"sessions": len(dates),
"holdout_sessions": holdout_sessions,
},
"metrics": metrics,
"metrics": fitted_metrics,
"events": fitted_events,
},
"reliability": reliability,
"events": per_event,
"recent_breadth": [
{"date": d.isoformat(), "breadth": breadth[d], "warning": warning.get(d)}
for d in dates[-90:]
@@ -328,10 +1020,13 @@ async def run_event_study(
logger.info(json.dumps({
"event": "regime_event_study_complete",
"evaluation": evaluation,
"events": metrics["events"],
"events_detected": reliability["events_detected"],
"warned": metrics["events_warned"],
"false_alarms_per_year": metrics["false_alarms_per_year"],
"shipped_events": shipped_metrics["events"],
"shipped_warned": shipped_metrics["events_warned"],
"shipped_false_alarms_per_year": shipped_metrics["false_alarms_per_year"],
"quadrant_changes": len(fires),
"fitted_events": fitted_metrics["events"],
"fitted_warned": fitted_metrics["events_warned"],
"null_p_at_least_observed": (null_model or {}).get("p_at_least_observed"),
"underpowered": reliability["underpowered"],
"sensor_coverage_mismatch": reliability["sensor_coverage_mismatch"],
}))
@@ -352,4 +1047,8 @@ async def get_event_study_report(db: AsyncSession) -> dict | None:
report = json.loads(setting.value)
except (TypeError, ValueError):
return None
return report if report.get("methodology") == rms.METHODOLOGY else None
if report.get("methodology") != rms.METHODOLOGY:
return None
# A pre-replay report parses fine and carries the current methodology, so the
# shape has to be checked separately or the panel renders a headline-less v4.
return report if report.get("schema") == STUDY_SCHEMA else None
+422 -33
View File
@@ -7,11 +7,17 @@ two deliberately separate outputs:
* Warning: deterioration/divergence that may precede State (breadth divergence,
relative strength, credit impulse).
Both scores are quantitative and daily. The sourced hyperscaler capex and
earnings-reaction observations are a qualitative *overlay* since v3 rather than
weighted sensors: at a combined 20 points they could not reach the event
study's alarm threshold even when both pegged, so refreshing them appeared to
do nothing. They are reported next to the scores instead of inside them.
* Fundamental context: a categorical channel (supportive / neutral / adverse /
unknown) with an evidence-quality grade, derived by fixed rules from the
sourced hyperscaler capex and earnings-reaction observations.
Both scores are quantitative and daily. The fundamental channel is deliberately
**not** a term in either: the three are read together by confluence, because
adding a slow categorical judgement to a fast continuous score manufactures
precision by summing unlike things, and any fusion weight would be a policy
preference presented as a measurement until there is enough point-in-time
history to fit one. A missing observation therefore stays ``unknown`` instead of
silently redistributing its weight onto the technical sensors.
Daily snapshots are the point-in-time record. The first run under a new
``METHODOLOGY`` rewrites every session inside ``REBUILD_LOOKBACK_DAYS`` once;
@@ -35,6 +41,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.exceptions import ProviderError, ValidationError
from app.models.regime_fundamental_observation import RegimeFundamentalObservation
from app.models.regime_snapshot import RegimeSnapshot
from app.providers.alpaca import AlpacaOHLCVProvider
from app.services import breadth_service, settings_store
@@ -55,7 +62,11 @@ METHODOLOGY = "v4"
# against the *stored* blob, so omitting the current one discards the observation
# on its first write, which leaves fetched_at null and locked false -- and then
# update_regime_monitor refreshes it via the LLM on every single run, forever.
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3", "v4"})
# "v5" is listed although no v5 scoring exists: a v5 was briefly built (a weighted
# fundamental modifier on Warning) and reverted, so a development box can have
# that string sitting in its settings blob. Keeping it costs nothing; omitting it
# costs the failure above.
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3", "v4", "v5"})
# Bumped when a fix changes what historical rows *should* contain without
# changing the live formula, so stored history needs one reseed. Deliberately
@@ -173,6 +184,34 @@ WARNING_WEIGHTS = {
"credit_impulse": 25.0,
}
# The sourced fundamental read is a **separate channel**, never a term in either
# score. It is reported as a categorical state beside State and Warning, and the
# three are read together by confluence rather than added up.
#
# Two things had to be true at once and only this shape gets both.
#
# **v3's reason for removing it was wrong.** v3 argued that F1+F3, at 12+8 of 100
# Warning points, "could not change any published conclusion" because pegged they
# produced a Warning of exactly 20.0. That holds only when every technical sensor
# reads exactly zero. Weighted, those points added +10 to +20 across the
# realistic range and moved the technical score needed to reach the 40 quadrant
# divider from 40 to 25. So the observation was not inert, and demoting it to
# decoration was not justified by that argument.
#
# **But no weight is measurable either.** A weighted modifier was built (v5,
# reverted) and its size could not be derived from anything: with ~10 correction
# events and essentially no fundamental history, any fusion weight is a policy
# preference presented as a measurement. Adding a slow categorical judgement to a
# fast continuous score also manufactures precision by summing unlike things, and
# it forces a missing observation to silently redistribute its weight onto the
# technical sensors -- the opposite of leaving it unknown.
#
# So the read gets a channel, not a coefficient. Revisit only with enough
# point-in-time history to test whether the state improves prediction
# *conditional on* Warning; a fitted model then has something to fit.
FUNDAMENTAL_STATES = ("supportive", "neutral", "adverse", "unknown")
EVIDENCE_QUALITY = ("complete", "partial", "stale", "manual", "unavailable")
# Fixed at the v2 launch. These are liquid S&P 500/Nasdaq AI, semiconductor,
# infrastructure, cloud, and enterprise-software names that the platform's
# normal universe sync already stores.
@@ -196,7 +235,12 @@ DEFAULT_CONFIG: dict = {
}
CAPEX_STATES = ("raising", "holding", "cutting", "unknown")
GNSD_STATES = ("yes", "no", "mixed")
# "mixed" is a genuinely observed mixed reaction; "unknown" is nobody looked or
# the extraction failed. They were the same value until 2026-08-13, so a failed
# LLM parse silently became neutral *evidence* -- an observation of normality
# manufactured out of a parse error. Same distinction the capex map already made
# with its own "unknown", and the same one the whole channel is built on.
GNSD_STATES = ("yes", "no", "mixed", "unknown")
# v2 scored raising and holding identically at 0, so in a capex boom the reading
# was pinned at 0 and could not express the raising -> holding deceleration that
# is the actual early warning. Display-only in v3, but it should still describe.
@@ -435,6 +479,104 @@ def score_warning_sensors(sensors: dict[str, float | None]) -> float | None:
return sum(s * w for s, w in live) / sum(w for _, w in live)
def _capex_signal(capex: dict[str, str] | None, names: list[str]) -> str:
"""Categorical read of hyperscaler capex direction. Never an average.
Averaging is what this must not do: it would let two ``cutting`` reads and
two ``unknown`` ones land on "neutral", presenting missing evidence as
evidence of normality. Any cut is adverse on partial evidence; only a fully
known, uniformly rising basket is supportive.
"""
states = [str((capex or {}).get(name, "unknown")).strip().lower() for name in names]
known = [state for state in states if state in ("raising", "holding", "cutting")]
if not known:
return "unknown"
if "cutting" in known:
return "adverse"
if "holding" in known:
return "neutral"
return "supportive"
def _reaction_signal(good_news_stock_down: str | None) -> str:
"""Good earnings being sold is a late-cycle tell; not being sold is healthy.
Anything that is not one of the three observed categories -- including the
explicit ``"unknown"`` an extraction failure now writes -- falls through to
``unknown`` rather than to ``mixed``. A parse error is not a reading.
"""
return {
"yes": "adverse",
"no": "supportive",
"mixed": "neutral",
}.get(str(good_news_stock_down or "").strip().lower(), "unknown")
def combine_fundamental_signals(capex_signal: str, reaction_signal: str) -> str:
"""Confluence, not arithmetic: precedence over the two categorical reads.
``unknown`` is deliberately unreachable by combination -- it survives only
when *nothing* was observed. A single adverse read carries, because partial
evidence of deterioration is still evidence of deterioration; supportive
requires every observed signal to agree.
"""
signals = (capex_signal, reaction_signal)
if "adverse" in signals:
return "adverse"
observed = [signal for signal in signals if signal != "unknown"]
if not observed:
return "unknown"
return "supportive" if all(signal == "supportive" for signal in observed) else "neutral"
def _usable_context(observed: bool, pending: bool, stale: bool, state: str) -> bool:
"""Whether a fundamental reading may count as evidence.
One definition, called by both the point-in-time record and the live
reading, because they publish the same field name to the same consumers and
a second copy would drift. Distinct from `available`, which is about timing
alone: an observation whose extraction failed on everything is effective and
fresh, and still knows nothing.
"""
return observed and not pending and not stale and state != "unknown"
def _evidence_quality(
capex: dict[str, str] | None,
good_news_stock_down: str | None,
names: list[str],
*,
observed: bool,
stale: bool,
source: str | None,
) -> str:
"""How much to trust the state above, as one field the reader can act on.
Ordered by what an operator most needs to know: nothing collected beats
everything else, then a reading too old to be current, then a hand override,
then completeness.
"""
if not observed:
return "unavailable"
if stale:
return "stale"
if str(source or "").strip().lower() == "manual":
return "manual"
known = sum(
1
for name in names
if str((capex or {}).get(name, "unknown")).strip().lower() != "unknown"
)
# `bool(names)` matters: with an empty basket `known == len(names)` is
# vacuously true, so nothing observed would grade as complete.
complete = (
bool(names)
and known == len(names)
and _reaction_signal(good_news_stock_down) != "unknown"
)
return "complete" if complete else "partial"
def _sensor(sensor_id: str, label: str, score: float | None, **details: object) -> dict:
return {
"id": sensor_id,
@@ -572,26 +714,66 @@ def _overlay_timing(
return effective, pending, age, stale
def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
"""Point-in-time qualitative overlay. Never feeds State or Warning since v3.
def fundamental_context(overrides: dict, config: dict, as_of: date) -> dict:
"""Point-in-time fundamental channel. Never a term in State or Warning.
Called an "overlay" until 2026-08-12, which undersold it: it is the third
channel of the model, read alongside the two scores by confluence rather than
decorating them. The categorical ``state`` is what a reader and the chart
consume; ``evidence_quality`` is how far to trust it.
Both are derived from the stored categorical facts by fixed rules, not from
an LLM's numeric judgement. The LLM's job is extraction and explanation --
find the capex guidance, classify it, cite it -- and the rules turn those
facts into a state, so the same observation always yields the same category.
The effective-date gate stays even though nothing is scored from this: the
400-session rebuild replays historical dates, and stamping today's LLM read
onto 2024 snapshots would be plain lookahead in the stored record.
rebuild replays historical dates, and stamping today's read onto 2024
snapshots would be plain lookahead in the stored record.
This is the *record*. For "what do we know right now", use
``current_observation`` -- do not add a bypass flag here, because this runs
for every replayed date during a rebuild.
"""
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
names = list(config["tickers"]["hyperscalers"])
capex = None if pending else overrides.get("capex")
reaction = None if pending else overrides.get("good_news_stock_down")
observed = not pending and bool(overrides.get("fetched_at"))
capex_signal = _capex_signal(capex, names) if observed else "unknown"
reaction_signal = _reaction_signal(reaction) if observed else "unknown"
state = combine_fundamental_signals(capex_signal, reaction_signal)
return {
"state": state,
"evidence_quality": _evidence_quality(
capex, reaction, names,
observed=observed, stale=stale, source=overrides.get("source"),
),
"capex_signal": capex_signal,
"reaction_signal": reaction_signal,
# Two different questions, and conflating them is a trap:
#
# `available` is about *timing* -- there is an effective, non-stale record
# to display. `usable` is about *content* -- it also actually says
# something. A collected observation whose extraction failed on every
# hyperscaler is available (show it, with its date) but not usable: it
# knows nothing, so it must never count as evidence.
#
# The distinction is load-bearing for the event study. Coverage is
# measured in sessions with usable context, and if repeated extraction
# failures counted, they would slowly accumulate "exposure" until the
# fundamental rows flipped to measurable 0/8 -- a failed result reported
# for a channel that never knew anything, which is the exact confusion
# coverage-matching exists to prevent.
"available": not pending and not stale,
"usable": _usable_context(observed, pending, stale, state),
"pending": pending,
"stale": stale,
"effective_date": effective.isoformat() if effective else None,
"age_days": age,
"capex": None if pending else overrides.get("capex"),
"good_news_stock_down": None if pending else overrides.get("good_news_stock_down"),
"capex": capex,
"good_news_stock_down": reaction,
"capex_stress": None if pending else overrides.get("f1_score"),
"earnings_stress": None if pending else overrides.get("f3_score"),
"reasoning": None if pending else overrides.get("reasoning"),
@@ -603,7 +785,7 @@ def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
def current_observation(overrides: dict, config: dict, as_of: date) -> dict:
"""The observation as it stands now, for the live reading only.
Same shape as ``fundamental_overlay``, but the effective date is *reported*
Same shape as ``fundamental_context``, but the effective date is *reported*
rather than used to blank the content. A refresh stamps
``_next_weekday(today)``, so gating the live card hid a just-collected read
for one day -- three over a weekend -- and refreshing appeared to do
@@ -611,14 +793,36 @@ def current_observation(overrides: dict, config: dict, as_of: date) -> dict:
published number; the stored snapshot keeps the gate.
"""
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
# The default override carries "unknown"/"mixed" placeholders for every
# The default override carries "unknown" placeholders for every
# hyperscaler. Those are the absence of an observation, not an observation
# of absence, and must never be presented as collected. ``fetched_at`` is
# the collection timestamp and is the only field written on every path that
# produces real content (LLM refresh and manual save both stamp it).
observed = bool(overrides.get("fetched_at"))
names = list(config["tickers"]["hyperscalers"])
capex_signal = _capex_signal(overrides.get("capex"), names) if observed else "unknown"
reaction_signal = (
_reaction_signal(overrides.get("good_news_stock_down")) if observed else "unknown"
)
state = combine_fundamental_signals(capex_signal, reaction_signal)
return {
"observed": observed,
"state": state,
"evidence_quality": _evidence_quality(
overrides.get("capex"), overrides.get("good_news_stock_down"), names,
observed=observed, stale=stale, source=overrides.get("source"),
),
"capex_signal": capex_signal,
"reaction_signal": reaction_signal,
# Same shape as the record means the same *fields*, not just the same
# ones this function happens to need: the frontend types both payloads
# identically, so an omission here is an undefined at runtime that
# TypeScript cannot catch across a trusted server boundary.
#
# Note this is stricter than the `available` directly below: a pending
# observation is the freshest thing we have and worth showing, but it is
# not yet in force, so it is not yet evidence.
"usable": _usable_context(observed, pending, stale, state),
# Live availability is about usefulness, not effectiveness: a pending
# observation is the freshest thing we have -- but nothing collected is
# never available.
@@ -656,8 +860,16 @@ def _compute_index(
breadth_series: Series | None = None,
divergence_series: Series | None = None,
breadth_counts: dict[date, int] | None = None,
observations: list[dict] | None = None,
) -> dict:
"""Compute the complete State/Warning snapshot as of one trading date."""
"""Compute the complete State/Warning snapshot as of one trading date.
``observations`` is the point-in-time fundamental series and is authoritative
when supplied; ``overrides`` is the single-slot fallback for callers that
predate the table (the calibration harness). Either way the reading is scored
into the same ``fundamental_context`` -- only where it is read from differs,
so the live monitor and the event study cannot report different states.
"""
tickers = config["tickers"]
smh = _closes_asof(prices.get(tickers["leaders"][0], []), as_of)
qqq = _closes_asof(prices.get(tickers["confirm"][0], []), as_of)
@@ -682,7 +894,10 @@ def _compute_index(
sensors = warning_sensor_scores(divergence, smh, spy, oas_window)
relative_strength = sensors["relative_strength"]
credit_impulse = sensors["credit_impulse"]
overlay = fundamental_overlay(overrides, config, as_of)
observation = (
observation_asof(observations, as_of) if observations is not None else overrides
) or {}
context = fundamental_context(observation, config, as_of)
state_pillars = [
{
@@ -768,7 +983,7 @@ def _compute_index(
"date": as_of.isoformat(),
"state": state,
"warning": warning,
"fundamental_overlay": overlay,
"fundamental_context": context,
"quadrant_config": {
"state_divider": QUADRANT_STATE_DIVIDER,
"warning_divider": QUADRANT_WARNING_DIVIDER,
@@ -790,8 +1005,8 @@ def _compute_index(
"breadth_pct_above_200": round(breadth_pct, 1) if breadth_pct is not None else None,
"breadth_date": breadth_item[0].isoformat() if breadth_item else None,
"fundamentals_fetched_at": overrides.get("fetched_at"),
"fundamentals_effective_date": overlay.get("effective_date"),
"fundamentals_age_days": overlay.get("age_days"),
"fundamentals_effective_date": context.get("effective_date"),
"fundamentals_age_days": context.get("age_days"),
},
"data_quality": {
"minimum_coverage": MIN_COVERAGE,
@@ -859,7 +1074,7 @@ async def get_fundamental_overrides(db: AsyncSession) -> dict:
"f1_score": None,
"f3_score": None,
"capex": {name: "unknown" for name in names},
"good_news_stock_down": "mixed",
"good_news_stock_down": "unknown",
"locked": False,
"reasoning": None,
"fetched_at": None,
@@ -880,9 +1095,9 @@ async def get_fundamental_overrides(db: AsyncSession) -> dict:
if stored.get("methodology") not in CATEGORICAL_FUNDAMENTAL_METHODOLOGIES:
return default
capex = _normalise_capex_states(stored.get("capex"), names)
reaction = str(stored.get("good_news_stock_down", "mixed")).strip().lower()
reaction = str(stored.get("good_news_stock_down", "unknown")).strip().lower()
if reaction not in GNSD_STATES:
reaction = "mixed"
reaction = "unknown"
return {
**default,
**stored,
@@ -922,6 +1137,100 @@ def _score_capex_states(capex: dict[str, str], names: list[str]) -> float | None
return round(score, 1) if score is not None else None
async def record_fundamental_observation(db: AsyncSession, observation: dict) -> None:
"""Append the observation to the point-in-time series, keyed on effective date.
Upsert rather than insert: re-saving on the same effective date is a
correction to that day's reading, not a second observation of it.
Silently does nothing without an effective date or a ``fetched_at``. Those
are the default placeholder blob -- the absence of an observation, which must
never enter the series as though someone had looked.
Deliberately does **not** commit. ``update_regime_monitor`` calls this inside
a run that owns its transaction and commits once after the snapshot loop;
committing here would take that boundary away from it. The two override
writers commit for themselves.
"""
effective = _parse_date(observation.get("effective_date"))
fetched_raw = observation.get("fetched_at")
if effective is None or not fetched_raw:
return
try:
fetched = datetime.fromisoformat(str(fetched_raw))
except ValueError:
fetched = datetime.now(timezone.utc)
if fetched.tzinfo is None:
fetched = fetched.replace(tzinfo=timezone.utc)
existing = await db.execute(
select(RegimeFundamentalObservation).where(
RegimeFundamentalObservation.effective_date == effective
)
)
row = existing.scalar_one_or_none()
payload = {
"f1_score": observation.get("f1_score"),
"f3_score": observation.get("f3_score"),
"capex_json": json.dumps(observation.get("capex") or {}),
"good_news_stock_down": str(observation.get("good_news_stock_down") or "unknown")[:10],
"reasoning": observation.get("reasoning"),
"source": str(observation.get("source") or "unknown")[:30],
"fetched_at": fetched,
}
if row is None:
db.add(RegimeFundamentalObservation(
effective_date=effective,
created_at=datetime.now(timezone.utc),
**payload,
))
else:
for key, value in payload.items():
setattr(row, key, value)
async def get_fundamental_observations(db: AsyncSession) -> list[dict]:
"""The whole observation series, oldest first, for point-in-time scoring."""
result = await db.execute(
select(RegimeFundamentalObservation).order_by(
RegimeFundamentalObservation.effective_date.asc()
)
)
out: list[dict] = []
for row in result.scalars().all():
try:
capex = json.loads(row.capex_json)
except (TypeError, ValueError):
capex = {}
out.append({
"effective_date": row.effective_date,
"f1_score": row.f1_score,
"f3_score": row.f3_score,
"capex": capex,
"good_news_stock_down": row.good_news_stock_down,
"reasoning": row.reasoning,
"source": row.source,
"fetched_at": row.fetched_at.isoformat() if row.fetched_at else None,
})
return out
def observation_asof(observations: list[dict] | None, as_of: date) -> dict | None:
"""Latest observation effective on or before ``as_of``.
This *is* the effective-date gate now. The settings-blob version had to
recompute it per call because there was only ever one observation to gate;
with a series, "which reading was live that day" is just a lookup.
"""
chosen: dict | None = None
for observation in observations or []:
if observation["effective_date"] <= as_of:
chosen = observation
else:
break
return chosen
async def set_fundamental_overrides(
db: AsyncSession,
capex: dict[str, str] | None = None,
@@ -954,7 +1263,15 @@ async def set_fundamental_overrides(
"fetched_at": now.isoformat(),
"effective_date": _next_weekday(now.date()).isoformat(),
})
await update_setting(db, KEY_FUNDAMENTALS, json.dumps(current))
# The blob (what the live card reads) and the series row (what the
# point-in-time replay reads) are the same observation. Committed together:
# `update_setting` commits internally, so using it here would leave a window
# where a failure publishes the reading to the card but not to the record,
# and the two would disagree permanently with nothing to detect it.
await settings_store.upsert_setting(db, KEY_FUNDAMENTALS, json.dumps(current))
if observation_changed:
await record_fundamental_observation(db, current)
await db.commit()
return current
@@ -1058,12 +1375,59 @@ def _snapshot_revision(snapshot: dict) -> int:
return 1
def _context_from_legacy_overlay(overlay: dict) -> dict:
"""Rebuild the categorical channel from a pre-rename snapshot's overlay.
The channel was called ``fundamental_overlay`` until 2026-08-12 and stored
the same underlying facts -- the capex map, the earnings reaction, the
effective date. The rename shipped without a methodology bump (no score
changed), so those rows are still served and were never reseeded: reading
only the new key would turn every one of them into ``unknown`` and silently
discard real recorded evidence -- historical Path colours, and any exposure
the event study could legitimately count.
Derived, not guessed. The hyperscaler list comes from the overlay's own
capex keys, which is exactly the basket that was observed at the time rather
than today's configured one.
"""
capex = overlay.get("capex") or {}
reaction = overlay.get("good_news_stock_down")
names = list(capex)
pending = bool(overlay.get("pending"))
stale = bool(overlay.get("stale"))
observed = not pending and bool(overlay.get("fetched_at"))
capex_signal = _capex_signal(capex, names) if observed else "unknown"
reaction_signal = _reaction_signal(reaction) if observed else "unknown"
state = combine_fundamental_signals(capex_signal, reaction_signal)
return {
**overlay,
"state": state,
"evidence_quality": _evidence_quality(
capex, reaction, names,
observed=observed, stale=stale, source=overlay.get("source"),
),
"capex_signal": capex_signal,
"reaction_signal": reaction_signal,
"usable": _usable_context(observed, pending, stale, state),
}
def _parse_snapshot(raw: str) -> dict | None:
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return None
return parsed if parsed.get("methodology") == METHODOLOGY else None
if parsed.get("methodology") != METHODOLOGY:
return None
# Normalise here rather than at each call site: every reader of a stored
# snapshot goes through this function, so a legacy row cannot reach one of
# them un-adapted.
if "fundamental_context" not in parsed and "fundamental_overlay" in parsed:
parsed["fundamental_context"] = _context_from_legacy_overlay(
parsed["fundamental_overlay"] or {}
)
return parsed
async def _latest_snapshot_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict] | None:
@@ -1082,6 +1446,10 @@ async def update_regime_monitor(
) -> dict:
config = await get_regime_config(db)
overrides = await get_fundamental_overrides(db)
# Carries the pre-v5 single-slot observation into the series on first run, so
# a deployment does not lose the live reading. A no-op once recorded, and a
# no-op for the placeholder blob (no fetched_at).
await record_fundamental_observation(db, overrides)
if _fundamentals_stale(overrides, config) and not overrides.get("locked"):
try:
overrides = await refresh_fundamental_overrides(db, config=config)
@@ -1131,6 +1499,9 @@ async def update_regime_monitor(
breadth_series = _mapping_series(breadth)
divergence_series = _mapping_series(divergence)
# Loaded once, after any refresh, so a reseed scores each replayed date with
# the observation that was effective on it rather than with today's.
observations = await get_fundamental_observations(db)
latest_result: dict | None = None
snapshots_written = 0
for snapshot_date in dates:
@@ -1144,6 +1515,7 @@ async def update_regime_monitor(
breadth_series,
divergence_series,
breadth_counts,
observations=observations,
)
written, latest_result = await _upsert_snapshot(
db,
@@ -1221,16 +1593,22 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
quality["is_fresh"] = bool(quality.get("inputs_fresh")) and snapshot_age <= 4
result["data_quality"] = quality
# The snapshot's overlay is the point-in-time record; the reader also wants
# the current observation even when it is not effective until the next
# session, because otherwise refreshing it looks like it did nothing.
# The snapshot's `fundamental_context` is the point-in-time record; the
# reader also wants the current observation even when it is not effective
# until the next session, or refreshing it looks like it did nothing.
config = await get_regime_config(db)
overrides = await get_fundamental_overrides(db)
live = current_observation(overrides, config, date.today())
# Deliberately reads the *snapshot's* overlay, not the live one: this is how
# Deliberately reads the *snapshot's* record, not the live one: this is how
# the reader tells "shown here" from "in the stored record".
live["observed_in_snapshot"] = bool((result.get("fundamental_overlay") or {}).get("available"))
result["fundamental_context"] = live
live["observed_in_snapshot"] = bool(
(result.get("fundamental_context") or {}).get("available")
)
# `fundamental_context` is the stored channel and stays the snapshot's;
# `fundamental_live` is what we know right now. Collapsing the two under one
# key is what made a just-collected observation look like it had been
# backdated into history.
result["fundamental_live"] = live
result["available"] = True
return result
@@ -1248,10 +1626,17 @@ async def get_regime_history(db: AsyncSession, days: int = 800) -> list[dict]:
if data is None:
continue
state, warning = data.get("state") or {}, data.get("warning") or {}
context = data.get("fundamental_context") or {}
out.append({
"date": row.date.isoformat(),
"state": state.get("score") if state.get("band") is not None else None,
"warning": warning.get("score") if warning.get("band") is not None else None,
# The third channel, carried per point so the Path view can colour a
# dot by the fundamental context that was on the record that day.
# Rows written before the channel existed carry nothing, which reads
# as "unknown" -- correct, since nothing was observed then either.
"fundamental_state": context.get("state") or "unknown",
"evidence_quality": context.get("evidence_quality") or "unavailable",
"state_coverage": state.get("coverage"),
"warning_coverage": warning.get("coverage"),
"basket_hash": (data.get("basket") or {}).get("hash"),
@@ -1383,7 +1768,7 @@ async def refresh_fundamental_overrides(
f1 = _score_capex_states(capex, names)
reaction = str(parsed.get("good_news_stock_down", "")).strip().lower()
if reaction not in GNSD_STATES:
reaction = "mixed"
reaction = "unknown"
f3 = _GNSD_SCORES.get(reaction)
now = datetime.now(timezone.utc)
result = {
@@ -1398,7 +1783,11 @@ async def refresh_fundamental_overrides(
"locked": False,
"source": llm.get("provider"),
}
await update_setting(db, KEY_FUNDAMENTALS, json.dumps(result))
# One transaction: see set_fundamental_overrides on why these two writes must
# not be able to land separately.
await settings_store.upsert_setting(db, KEY_FUNDAMENTALS, json.dumps(result))
await record_fundamental_observation(db, result)
await db.commit()
logger.info(json.dumps({
"event": "regime_fundamentals_refreshed",
"f1": result["f1_score"],
+410 -39
View File
@@ -39,6 +39,162 @@ session, the calendar anchors, 100% coverage on every row, and a row-wise
`state_v4 <= state_v3` invariant. Reading a calibration result out of a run whose
pipeline did not validate is meant to be structurally impossible.
## The fundamental channel (2026-08-12)
The monitor has **three channels**, not two scores with a decoration:
- **State** — current observable technical stress (price, breadth, credit, volatility).
- **Warning** — observable deterioration that may precede stress (breadth
divergence, relative strength, credit impulse).
- **Fundamental context** — a categorical state (`supportive` / `neutral` /
`adverse` / `unknown`) with an `evidence_quality` grade.
The third is **never a term in the other two**. They are read together by
confluence:
| Warning | Fundamentals | Reading |
|---|---|---|
| Calm | Supportive/neutral | Normal |
| Elevated | Supportive/neutral | Technical warning, not fundamentally confirmed |
| Calm | Adverse | Fundamental concern; tape has not confirmed |
| Elevated | Adverse | Confluence — highest attention |
`METHODOLOGY` stays **v4**: no score changed, so partitioning the history API and
discarding the event study cache would be churn. `STUDY_SCHEMA` moved to 3
instead, and is now the only thing that discards a stale report.
### Why the read is a channel and not a weight
Two things are true at once, and only this shape honours both.
**v3's reason for removing fundamentals from the score was wrong.** Not stale —
wrong. v3 argued that F1 (capex) and F3 (good-news-stock-down), carrying 12 + 8
of 100 Warning points, "could not change any published conclusion" because pegged
they produced a Warning of exactly 20.0, below the alarm threshold. That
arithmetic holds only when *every* technical sensor reads exactly zero, which is
the one case that never matters. Warning is a weighted average, so the sensors
add:
| technical Warning | without fundamentals | with them pegged | delta |
|---|---|---|---|
| 0 | 0.0 | 20.0 | +20.0 |
| 20 | 20.0 | 36.0 | +16.0 |
| 25 | 25.0 | **40.0** | +15.0 |
| 35 | 35.0 | **48.0** | +13.0 |
| 50 | 50.0 | 60.0 | +10.0 |
| 80 | 80.0 | 84.0 | +4.0 |
Pegged fundamentals lowered the technical Warning needed to reach the 40 quadrant
divider from 40 to 25. That is a 15-point shift in where the alert fires, which
is emphatically a changed conclusion. The v3 section below is kept as written,
with this correction attached, because its reasoning is cited elsewhere in this
file and a silent overwrite would hide that the error was ever made.
**But no weight is measurable either.** A weighted modifier was built and
reverted: 025 points added onto the technical Warning, sized so a maxed-out read
carried a calm tape over the 40 divider on its own. Nothing could justify the 25.
With ~10 correction events and essentially no fundamental history, any fusion
weight is a policy preference presented as a measurement — and the debate it
invites ("does the read deserve 10%, 20%, 30%?") has no evidence that can settle
it. Adding a slow categorical judgement to a fast continuous score also
manufactures precision by summing unlike things, and it forces a missing
observation to silently redistribute its weight onto the technical sensors, which
is the opposite of leaving it unknown.
So: the read gets a channel, not a coefficient. Both facts survive — the v3
removal was badly argued *and* no weight is defensible — because "report it
separately" is the only design that neither buries the observation nor invents a
number for it.
### Derivation
Deterministic, from the stored categorical facts. The LLM is an **extraction and
explanation layer**: it finds the capex guidance, classifies it, and cites it.
Fixed rules turn those facts into a state, so the same observation always yields
the same category.
`capex_signal`: any `cutting` → adverse; else any `holding` → neutral; else all
known `raising` → supportive; nothing known → unknown.
`reaction_signal`: `yes` → adverse, `mixed` → neutral, `no` → supportive,
`unknown` → unknown.
`mixed` and `unknown` are different reaction states and were merged until
2026-08-13. A failed LLM parse fell back to `mixed`, so an extraction error
became *neutral evidence* — an observation of normality manufactured out of a
bug. `mixed` now means an observed mixed reaction; anything unreadable, missing
or unattempted is `unknown` and contributes nothing.
Combined by precedence, never by averaging: **any adverse read carries**; both
unknown → unknown; every observed signal supportive → supportive; otherwise
neutral.
`unknown` is deliberately unreachable by combination. Averaging would let two
`cutting` reads and two `unknown` ones land on "neutral", presenting missing
evidence as evidence of normality — the same conflation `current_observation`
already refuses between "no observation" and "an observation of zero". Two cuts
and two unknowns read **adverse with `evidence_quality: partial`**.
`evidence_quality` is ordered by what an operator needs first: `unavailable`
(nothing collected) → `stale` (past `fundamental_staleness_days`) → `manual`
(hand override) → `complete` / `partial`.
### Presentation and alerts
The Path view colours each dot by the fundamental state recorded that day; the
axes are untouched, because context is confluence information rather than a
position on either axis. The card leads with the state and evidence grade.
Alerts stay **separate**, off one toggle:
- quadrant change — the market axes moved (existing);
- `regime_fundamental` — the context changed, e.g. neutral → adverse;
- `regime_confluence` — Warning elevated *and* fundamentals adverse.
`unknown` never alerts: an absence of evidence is not a change in the evidence,
and alerting on it would train the reader to ignore the channel. Both new
triggers seed silently on first run, as the quadrant alert does.
### The observation is now a real time series
`regime_fundamental_observations` (migration 033), one row per `effective_date`,
upserted. Before this it lived in a single `SystemSetting` slot that every
refresh overwrote, so no history existed at all — which made the read impossible
to replay, impossible to backtest, and meant a rebuild recorded every historical
session as if nothing had been observed. `update_regime_monitor` carries the
pre-existing single-slot observation into the series on its next run.
### What this does not establish
The table starts empty and fills one observation at a time, so the fundamental
rows are **untested, not failed**. Two things enforce that rather than one:
- they are **coverage-matched** — scored only on sessions where the channel had
usable context and on corrections whose warning horizon fell inside it, with a
market-only comparator over the identical window so any difference between them
is the channel and not the window;
- `measurable` stays false until `MIN_EVENTS_FOR_CONFIDENCE` corrections are
covered, and the panel prints "insufficient exposure" rather than a ratio.
Without the first, one day of coverage would render as 0/10 — recreating, one
observation later, exactly the tested-versus-unavailable confusion the flag was
added to prevent. The market rows are unchanged, and the 1/10 shipped-rule figure
remains a verdict on the technical sensors and the alert machinery alone.
The rationale for expecting the read to matter is the operator's: hyperscaler
capex is the demand side of the entire AI trade, and good earnings being sold is
a classic late-cycle tell. Both are plausible. Neither is measured here, and this
file's convention is that published numbers are reproducible.
**The path forward is accumulation, then a test — in that order.** Once enough
point-in-time observations exist, test whether the state improves prediction
*conditional on* Warning. If it does, a fitted and calibrated model has something
to fit; until then there is nothing to calibrate against. Backfilling would get
there faster: capex direction is derivable from the 10-Q/10-K capex line, which
the SEC fundamentals import already carries, and "good news, stock down" from
earnings dates plus next-day returns, which the Dolt earnings import already
carries. That last one is worth computing deterministically rather than asking
the LLM to judge, for the same reason the state derivation is rule-based.
## What changed in v4
**V1 stopped saturating at VIX 30.** `(vix - 15) / 15` reached 100 at VIX 30 —
@@ -81,6 +237,16 @@ a qualitative overlay reported beside the scores. Capex also stopped scoring
`raising` and `holding` identically at 0: `holding` is the deceleration case and
now scores 50, so a boom no longer reads the same as a stall.
> **Corrected 2026-08-12.** The claim in this paragraph is false. "Pegged
> they produced a Warning of exactly 20.0" describes only the case where every
> technical sensor reads zero; Warning is a weighted average, so in the general
> case those 20 points added +10 to +20 and moved the technical score needed to
> reach the 40 quadrant divider from 40 to 25. The observation was removed for
> being *underweighted*, on reasoning that mistook a corner case for the whole
> range. See "The fundamental channel" above for what replaced it — a separate
> categorical channel, not a restored weight. The capex `holding` rescale in the second half
> of this paragraph stands and is still live.
**The drawdown sensor stopped saturating.** v2 used `dd_pct * 5`, reaching 100 at
a 20% drawdown — the 90th percentile of the observed distribution. 39 of 408
sessions sat at exactly 100 with no resolution left, and the price pillar showed
@@ -126,7 +292,11 @@ upper half of the Warning axis was unreachable.
- 60-session SMH/SPY relative-strength deterioration, 30%.
- HY OAS 20-session widening, 25%.
Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v3 or v4.
**Fundamental context** — a categorical third channel, not a term in either
score. See "The fundamental channel" above.
Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v3
or v4.
## Calibration
@@ -279,31 +449,73 @@ reseed exists to close. The history API and main chart show only snapshots match
the current methodology, so a bump reseeds the series rather than splicing two
formulas into one line.
The fundamental overlay keeps its effective date (normally the next session after
The fundamental channel keeps its effective date (normally the next session after
collection) and is never replayed backward, so a rebuild cannot stamp today's
observation onto historical snapshots. Because the observation is stored in a
single slot, a refresh replaces the previously effective record: the snapshot
therefore reports the overlay as `pending` until the new effective date.
observation onto historical snapshots. Since the observations became a real
series (`regime_fundamental_observations`, migration 033), the effective-date
lookup *is* the gate: a replayed session gets whichever observation was live on
it, and sessions before the first one read `unknown`.
Two functions, deliberately: `fundamental_overlay` is the **record** and keeps
Two functions, deliberately: `fundamental_context` is the **record** and keeps
the gate — it runs for every replayed date during a rebuild, so it must never
grow a bypass flag. `current_observation` is the **live reading** behind
`fundamental_context`, and *reports* the effective date instead of blanking the
`fundamental_live`, and *reports* the effective date instead of blanking the
content.
Until 2026-08-07 the live reading called the gated function, so a just-collected
observation stayed hidden until the next weekday — three days over a weekend —
and refreshing appeared to do nothing. That was the opposite of what this section
already claimed. Showing it early cannot leak into a published number, because
nothing in the overlay is scored (see "Fundamentals left the score").
already claimed. Showing it early cannot leak into a published score, because
nothing in the channel is scored.
`current_observation` gates on `observed` (a non-null `fetched_at`, the one field
every path writing real content stamps). Without it, the default override —
`unknown` for every hyperscaler and `mixed` for the reaction — was reported as a
live observation with `available: true`, so the card presented placeholders as a
collected reading. Those are the absence of an observation, not an observation of
absence. `fundamental_overlay` never had this problem: no observation means no
effective date, which means `pending`, which already blanks the content.
`unknown` for every hyperscaler and, since 2026-08-13, `unknown` for the reaction
— was reported as a live observation with `available: true`, so the card
presented placeholders as a collected reading. Those are the absence of an
observation, not an observation of absence. `fundamental_context` never had this
problem: no observation means no effective date, which means `pending`, which
already blanks the content.
**`usable` is what may confirm; `available` is only what to display.** Three
distinct things, and collapsing any two of them is a bug:
- `state` — the last thing observed. Survives going stale, so the card can show it.
- `available`*timing*: there is an effective, non-stale record to display.
- `usable`*content*: available **and** the observation actually determined
something (`state != "unknown"`).
The confluence alert and all three coverage-matched study rules gate on `usable`.
Gating on `available` instead has two failure modes, and both were live at some
point in this design:
1. a reading past `fundamental_staleness_days` would corroborate every Warning
crossing indefinitely — the strongest claim this channel makes, from the data
with the least right to make it;
2. an LLM run that failed to extract anything produces a perfectly fresh
observation that knows nothing. Counting it as exposure means repeated
extraction failures slowly accumulate coverage until the fundamental rows flip
to a *measurable* 0/8 — a failed result published for a channel that never saw
a thing, which is precisely what coverage-matching exists to prevent.
**Pre-rename snapshots are adapted, not discarded.** The channel was stored as
`fundamental_overlay` until 2026-08-12. The rename shipped without a methodology
bump — no score changed — so those rows are still served and were never reseeded.
Reading only the new key would have turned every one of them into `unknown`,
silently dropping real recorded evidence: historical Path colours, and exposure
the event study can legitimately count. `_parse_snapshot` derives the channel
from a legacy overlay's own stored facts (its capex map supplies the basket, so
the derivation uses the names observed at the time rather than today's config).
Normalising there rather than at each call site means no reader can receive an
un-adapted row. Delete only after a reseed has rewritten the whole window.
**The blob and the series row are one transaction.** They are the same
observation seen by the live card and by the point-in-time replay; committing
them separately leaves a window where a failure publishes one and not the other,
and the two then disagree permanently with nothing to detect it. Both writers use
`settings_store.upsert_setting` (which does not commit) plus a single commit;
`record_fundamental_observation` deliberately takes no commit of its own so
`update_regime_monitor` keeps its own transaction boundary.
Each snapshot stores the fixed basket symbols, hash, and freeze date.
Reconstructed history before that freeze date is retrospective/exploratory.
@@ -321,39 +533,185 @@ today's number. The quadrant dividers rendered in Path view come from
## Warning study
The study calls the outcome a **10% correction**, not a regime break. The first
70% of sessions freezes the 80th-percentile warning threshold; alarm episodes are
measured on the final 30%. Because v3 dropped fundamentals from the score, the
study now measures exactly the live Warning score rather than a technical-only
approximation of it, and both are computed from one shared sensor definition
(`warning_sensor_scores`) so they cannot drift apart.
The study calls the outcome a **10% correction**, not a regime break. It measures
two rules against that outcome, plus enough context to tell whether either number
is any good.
A cached report is discarded when its methodology no longer matches, so the panel
reverts to "not run yet" after a bump rather than showing stale numbers. **Re-run
the Event Study job after cutting over to v4.**
A cached report is discarded when its methodology no longer matches *or* when
`STUDY_SCHEMA` moves, so the panel reverts to "not run yet" rather than showing
stale numbers or a report missing half its blocks. **Re-run the Event Study job
after a methodology cutover or a schema bump.**
### The headline is the rule that actually fires
Until 2026-08-12 the study measured a bare rising-edge crossing of an
80th-percentile threshold fitted on the first 70% of sessions. **Nothing consumes
that rule.** What reaches Telegram is `_collect_regime_quadrant`: a quadrant
change with State ≥ 50 and Warning ≥ 40 as fixed dividers, a ±5 hysteresis
deadband, a two-session confirmation, a 3-day cooldown, and a 75% coverage gate
on both axes. The two differ on every one of those axes, including the threshold
itself (a fitted ~32 against a shipped 40).
`replay_quadrant_changes` replays the shipped state machine over the whole
sample. Three details are reproduced rather than cleaned up, because a state
machine written from first principles gets each of them wrong:
- the prior session is classified against the **current baseline**, not against
its own predecessor, so confirmation asks "did yesterday already look like this
change" rather than "did yesterday change too";
- the baseline advances only when an alert actually fires, so a change blocked by
confirmation or cooldown is re-evaluated against the old quadrant next session;
- one cooldown is shared by every quadrant change, so a 3→4 alert can swallow a
4→2 alert three days later.
Two consequences worth stating. The alarm is dated at the **confirmation**, not
at the first crossing, which costs one session of lead by construction. And the
rule alerts on changes in both directions, so the replay's exits are recorded but
filtered out by `entry_alarms` — only entering a Warning-high quadrant is a
warning about anything.
The replay reuses `_compute_index` rather than re-deriving the axes. That is the
same anti-drift argument that produced `warning_sensor_scores`: the v2 study
re-derived Warning by hand and would have kept measuring the old construct
through a scoring change. State has no equivalent shared helper, so the snapshot
builder itself is the shared definition.
**Nothing is fitted, so nothing needs protecting from a training set.** There is
no split, and every detected correction is evaluable instead of the four that
happen to land in the last 30%. The `underpowered` and "threshold frozen on a
different construct" caveats do not apply to this variant.
### Reading the result
The report carries a `reliability` block and the UI renders its warnings, because
the headline numbers invite over-reading in two specific ways.
A bare "2 of 4" is unreadable in either direction, so the report scores four more
rules through the same `evaluate_alarms` harness over the same events and
sessions, and adds a null. All use fixed thresholds — a threshold fitted on the
full sample would have lookahead the shipped rule does not, and one fitted on a
split could only be scored on the holdout events.
| kind | rules | the question |
|---|---|---|
| ablation | Warning ≥ 40 bare, State ≥ 50 bare | does the quadrant machinery earn its place? |
| baseline | leader below its 50-DMA, VIX ≥ 20 | does the score earn its complexity? |
| null | K random alarms at the observed firing rate | is any of this better than chance? |
The two kinds must not be read as one list. If a baseline matches the score, the
composite is not earning its complexity and that is the finding — it does not
mean the monitor is worthless, since State and Warning exist to be *read*, but it
caps how much further calibration is justified. If the bare Warning crossing
beats the shipped rule, the machinery (not the sensor) is what is costing recall.
The null draws only from sessions a rule could actually have fired on. Over the
whole sample it would be diluted by warm-up sessions and would understate what
chance achieves — which matters, because with ~11 events and a 20-session horizon
roughly a sixth of the sample already sits inside a hit window. It is seeded, so
a re-run cannot move the report. Corrections cluster and uniform placement does
not, so it is the **floor, not the bar**: an alarm process that clustered would
beat it for reasons unrelated to foresight.
### First result (2026-08-12): the shipped rule is not distinguishable from chance
Replayed over 2021-07-14 → 2026-08-12. The 200-DMA warm-up means the baseline
only seeds on 2022-05-26, so 1056 of 1276 sessions are evaluable and 10 of the 11
detected corrections fall inside them.
| rule | kind | warned | FA/yr | median lead |
|---|---|---|---|---|
| **Quadrant alert (shipped)** | | **1/10** | **0.9** | 19d |
| Quadrant alert, both axes high | ablation | 0/10 | 0.9 | — |
| Warning ≥ 40, bare crossing | ablation | 3/10 | 4.8 | 20d |
| State ≥ 50, bare crossing | ablation | 0/10 | 0.7 | — |
| SMH below its 50-DMA | baseline | 7/10 | 6.7 | 8d |
| VIX ≥ 20 | baseline | 4/10 | 7.2 | 9.5d |
| Random alarms, same firing rate | null | 0.9 ± 0.8 | — | — |
**P(chance ≥ 1/10) = 0.65.** Alarms scattered at random over the same sessions at
the rule's own firing rate match or beat it two times in three. Whatever the
score knows, this rule is not transmitting it.
Three readings, in order of how much they should change:
**The machinery costs more than it protects.** The bare Warning crossing catches
3 with a 20-session lead; wrapping it in the quadrant rule drops that to 1. The
State condition is the largest single cost — requiring both axes high catches
nothing at all, which is what a coincident axis gating a leading one predicts.
Hysteresis, the two-session confirmation and the shared cooldown between them
take the rest, and the cooldown is shared across *every* quadrant change, so
exits consume the budget that entries need. Only 5 of the 15 replayed changes are
Warning-high entries.
**The crude baselines beat everything on recall, at a price.** SMH below its
50-DMA catches 7 of 10 — but at 6.7 false alarms a year against the shipped
rule's 0.9. That is a 7× recall improvement for 7× the noise, so it is not a
clean dominance and this table cannot settle it; the missing axis is what a false
alarm actually costs, which nothing here measures. What it does settle is that
the composite is not buying recall the 50-DMA does not already have.
**The 0.9 false alarms/year is not the achievement it looks like.** A rule that
almost never fires has few false alarms by construction. Read the two columns
together or not at all.
Recorded from an offline replay (live Alpaca + FRED, no database, breadth
computed from the same Alpaca closes rather than the stored universe). The job in
Admin → Jobs is the canonical path and reads breadth from the DB, so re-run it to
confirm these figures before treating them as the record.
**This is a verdict on the market channels only.** The fundamental and confluence
rows in the same table are marked `measurable: false` and print "not measurable"
rather than a ratio: with an empty observation series they never fire, and a 0/10
sitting in a comparison column would read as tested-and-failed. `false` here means
the input does not exist yet, not that the rule lost.
(The figures above were also produced under a briefly-built weighted modifier and
came back bit-identical, which is what confirmed the modifier was inert over the
whole window — the numbers depend on the technical sensors alone either way.)
**Not acted on.** Nothing in the alert path was changed on the strength of this.
The obvious candidates — dropping the State condition from the entry test,
separating the entry and exit cooldowns, or lowering the Warning divider — are
threshold changes to a live alerting rule and want their own decision.
### The coverage gap relocates, it does not close
Dropping the fitted threshold makes the whole sample evaluable, but most of the
extra events predate 2023-08. W3 does not exist there, so Warning renormalises to
`(W1×45 + W2×30)/75` and the fixed 40 divider is applied to a different construct
than it was reasoned about. The report therefore splits shipped-rule metrics at
the credit sensor's first session and the panel states both, because replacing
one misleading headline with a differently misleading one would be no gain.
Convenient side effect: the pre-credit era *is* the "Warning without W3"
ablation, measured on real sessions rather than simulated ones, so that ablation
is not run separately.
Alarms and events are assigned to eras by index, so an alarm days before the
boundary matching an event days after it lands in the earlier era. With the eras
years long and the events sparse, that costs nothing.
### The fitted variant, kept for continuity
The 70/30 percentile study is still computed and still reported, collapsed, with
its `reliability` block intact — it is a genuinely different question, and it is
what earlier revisions of this document report. Its caveats stand:
**The holdout is thin.** The study detects 11 corrections across 5 years but the
70/30 split leaves only 4 in the test period. Recall is therefore one event away
from a materially different headline, and in practice the event that flips is
decided by where the frozen threshold happens to land rather than by whether the
score saw anything. The v3 cutover run illustrates it: v3 scored 2/4 against v2's
3/4, but "v3 without the credit sensor" scores 3/4 at a *higher* threshold
(35.5) than shipped v3 misses it at (32.3) — because the alarm rule needs a
rising edge, and a lower threshold can mean the alarm already fired outside the
20-session horizon and never reset below. Below `MIN_EVENTS_FOR_CONFIDENCE`
holdout events the report says so explicitly.
70/30 split leaves only 4 in the test period. Recall is one event away from a
materially different headline, and in practice the event that flips is decided by
where the frozen threshold happens to land rather than by whether the score saw
anything. The v3 cutover run illustrates it: v3 scored 2/4 against v2's 3/4, but
"v3 without the credit sensor" scores 3/4 at a *higher* threshold (35.5) than
shipped v3 misses it at (32.3) — because the alarm rule needs a rising edge, and a
lower threshold can mean the alarm already fired outside the 20-session horizon
and never reset below. Below `MIN_EVENTS_FOR_CONFIDENCE` holdout events the
report says so explicitly.
Some events carry no information at all for comparison: in that run every
variant caught 2026-03-06, every variant missed 2026-06-05, and every variant
"caught" 2025-11-20 with a 1-session lead, which is coincident rather than a
warning.
warning. The headline recall does not currently discount those; a minimum-lead
rule is the obvious next change and has not been made.
**Sensor coverage can straddle the split.** The score renormalises over available
**Sensor coverage straddles the split.** The score renormalises over available
sensors, so a training window predating a sensor's history freezes the threshold
on a different construct than the holdout is measured against. At the v3 cutover
only 39% of training sessions had all three Warning sensors versus 100% of the
@@ -362,9 +720,22 @@ test period, because credit history begins 2023-07-25.
Restricting the threshold to sensor-matched training sessions was tried and is
*not* the fix: those sessions are a calm recent stretch, so the threshold drops
from 32.3 to 22.5 and false alarms rise from 3.3 to 8.6 per year. It trades a
coverage bias for a regime-selection bias. The honest position is that the
threshold is hypersensitive to window choice at this sample size; the report
states its limits rather than pretending to a precision it does not have.
coverage bias for a regime-selection bias. The honest position is that a fitted
threshold is hypersensitive to window choice at this sample size — which is the
strongest argument for making the unfitted shipped rule the headline.
### Considered and not done
**An ETF credit proxy (HYG/IEF) to extend W3 back over the whole sample.** It
would trade "two sensors versus three" for "proxy sensor versus real sensor" —
still a construct straddle, but no longer flagged by the coverage split. This is
the same objection that rejected `BAA10Y` as a percentile reference. If ever
revisited, check the impulse correlation on the three years of real-OAS overlap
first and report it as a sensitivity, never as the headline.
**A depth sweep (5%/7%/15% corrections) for more events.** `EVENT_COOLDOWN_DAYS`
is 40, so at shallower thresholds re-triggers inside a single decline merge or
drop and the denominator moves for cooldown reasons rather than market ones.
## Resolved in v4 (raised 2026-08-07, shipped 2026-08-08)
+146 -45
View File
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
CartesianGrid,
Cell,
Line,
LineChart,
ReferenceArea,
@@ -19,6 +18,8 @@ import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
import { Callout } from '../ui/Callout';
import { SkeletonCard } from '../ui/Skeleton';
import { formatDate } from '../../lib/format';
import { FUNDAMENTAL_VISUAL, QUADRANT_WASH, REGIME_VISUAL } from '../../lib/regime';
import type { EvidenceQuality, FundamentalState } from '../../lib/types';
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
// Time and Path are two projections of one series, so they share a card and a
@@ -38,8 +39,14 @@ type RangeKey = (typeof RANGES)[number]['key'];
/** Sessions drawn in Path view. The full series is unreadable as a path. */
const PATH_TRAIL = 60;
const STATE_COLOR = '#60a5fa';
const WARNING_COLOR = '#fb923c';
const STATE_COLOR = REGIME_VISUAL.state;
const WARNING_COLOR = REGIME_VISUAL.warning;
const FUNDAMENTAL_SYMBOL: Record<FundamentalState, string> = {
supportive: '▲',
neutral: '●',
adverse: '◆',
unknown: '○',
};
// Fall back to the shipped constants, not v2's shared 60/60, so a missing
// quadrant_config cannot draw dividers that disagree with the alert path.
@@ -50,13 +57,19 @@ interface PathPoint {
x: number;
y: number;
date: string;
/** The third channel as recorded that day. Colours the dot; never moves it. */
fundamental: FundamentalState;
evidence: EvidenceQuality;
/** Raw dated observations are interactive dots; the smoothed copy is line-only. */
raw: boolean;
recency: number;
}
/** Centered moving average to de-noise the path; today (last) kept exact. */
function smoothTrail(points: PathPoint[], half = 2): PathPoint[] {
const n = points.length;
return points.map((p, i) => {
if (i === n - 1) return { ...p };
if (i === n - 1) return { ...p, raw: false };
let sx = 0;
let sy = 0;
let c = 0;
@@ -65,14 +78,58 @@ function smoothTrail(points: PathPoint[], half = 2): PathPoint[] {
sy += points[j].y;
c += 1;
}
return { x: sx / c, y: sy / c, date: p.date };
return { ...p, x: sx / c, y: sy / c, raw: false };
});
}
/** Recency gradient: 0 = oldest (muted slate), 1 = newest (bright blue). */
function recencyColor(t: number): string {
const lerp = (a: number, b: number) => Math.round(a + (b - a) * t);
return `rgba(${lerp(71, 96)}, ${lerp(85, 165)}, ${lerp(105, 250)}, ${(0.3 + 0.7 * t).toFixed(2)})`;
function FundamentalGlyph({
cx,
cy,
state,
size,
opacity = 1,
}: {
cx: number;
cy: number;
state: FundamentalState;
size: number;
opacity?: number;
}) {
const visual = FUNDAMENTAL_VISUAL[state] ?? FUNDAMENTAL_VISUAL.unknown;
const common = { fill: visual.color, opacity, stroke: '#11131c', strokeWidth: 1 };
if (visual.glyph === 'up') {
return <polygon points={`${cx},${cy - size} ${cx - size},${cy + size} ${cx + size},${cy + size}`} {...common} />;
}
if (visual.glyph === 'diamond') {
return <polygon points={`${cx},${cy - size} ${cx - size},${cy} ${cx},${cy + size} ${cx + size},${cy}`} {...common} />;
}
if (visual.glyph === 'ring') {
return <circle cx={cx} cy={cy} r={size - 0.5} fill="transparent" opacity={opacity} stroke={visual.color} strokeWidth={1.5} />;
}
return <circle cx={cx} cy={cy} r={size - 0.5} {...common} />;
}
function PathPointShape({ cx = 0, cy = 0, payload }: { cx?: number; cy?: number; payload?: PathPoint }) {
if (!payload) return <g />;
return (
<FundamentalGlyph
cx={cx}
cy={cy}
state={payload.fundamental}
size={3.25 + payload.recency * 1.25}
opacity={0.58 + payload.recency * 0.42}
/>
);
}
function LatestPointShape({ cx = 0, cy = 0, payload }: { cx?: number; cy?: number; payload?: PathPoint }) {
if (!payload) return <g />;
return (
<g>
<circle cx={cx} cy={cy} r={7} fill="transparent" stroke="#ffffff" strokeWidth={1.75} />
<FundamentalGlyph cx={cx} cy={cy} state={payload.fundamental} size={4.5} />
</g>
);
}
function SegmentedControl<T extends string>({
@@ -94,8 +151,8 @@ function SegmentedControl<T extends string>({
type="button"
aria-pressed={value === option}
onClick={() => onChange(option)}
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
value === option ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
className={`min-h-9 rounded px-3 py-2 text-xs font-medium tabular-nums transition-colors ${
value === option ? 'bg-white/10 text-blue-300' : 'text-gray-400 hover:text-gray-200'
}`}
>
{option}
@@ -107,14 +164,19 @@ function SegmentedControl<T extends string>({
function PathTip({ active, payload }: { active?: boolean; payload?: { payload: PathPoint }[] }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
const p = payload.find((item) => item.payload.raw)?.payload ?? payload[0].payload;
const visual = FUNDAMENTAL_VISUAL[p.fundamental] ?? FUNDAMENTAL_VISUAL.unknown;
const evidence = p.evidence === 'unavailable' ? 'Unavailable' : `${p.evidence.replace(/_/g, ' ')} evidence`;
return (
<div className="glass px-2.5 py-1.5 text-[11px]">
<div className="glass px-3 py-2 text-xs">
<div className="text-gray-300">{formatDate(p.date)}</div>
<div className="text-gray-400">
State <span style={{ color: STATE_COLOR }}>{Math.round(p.x)}</span> · Warning{' '}
<span style={{ color: WARNING_COLOR }}>{Math.round(p.y)}</span>
</div>
<div className="mt-0.5 text-gray-400">
Fundamentals <span style={{ color: visual.color }}>{visual.label}</span> · {evidence}
</div>
</div>
);
}
@@ -144,7 +206,15 @@ export default function RegimeChart() {
}, [history.data, view, range]);
const pathPoints = useMemo<PathPoint[]>(
() => series.map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date })),
() => series.map((p, index, points) => ({
x: p.state as number,
y: p.warning as number,
date: p.date,
fundamental: p.fundamental_state ?? 'unknown',
evidence: p.evidence_quality ?? 'unavailable',
raw: true,
recency: points.length <= 1 ? 1 : index / (points.length - 1),
})),
[series],
);
const trail = useMemo(() => (view === 'Path' ? smoothTrail(pathPoints) : []), [pathPoints, view]);
@@ -159,7 +229,7 @@ export default function RegimeChart() {
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="text-[11px] uppercase tracking-wider text-gray-500">
<span className="text-xs uppercase tracking-wider text-gray-400">
{view === 'Time' ? 'State & Warning over time' : `State × Warning path · last ${PATH_TRAIL} sessions`}
</span>
<SegmentedControl options={VIEWS} value={view} onChange={setView} label="Chart view" />
@@ -168,7 +238,7 @@ export default function RegimeChart() {
<SegmentedControl options={RANGES.map((r) => r.key)} value={range} onChange={setRange} label="Time range" />
) : (
latest && (
<span className="text-[11px] text-gray-500">
<span className="text-xs text-gray-400">
now: State <span style={{ color: STATE_COLOR }}>{Math.round(latest.x)}</span> · Warning{' '}
<span style={{ color: WARNING_COLOR }}>{Math.round(latest.y)}</span>
</span>
@@ -182,14 +252,18 @@ export default function RegimeChart() {
<Callout variant="empty">Not enough coverage-qualified history yet it accumulates as the daily job runs.</Callout>
) : (
<>
<div className="mt-3 h-72">
<div
className="mt-3 h-72"
role="img"
aria-label={view === 'Time' ? 'State and Warning scores over time' : 'State by Warning path with fundamental context symbols'}
>
<ResponsiveContainer width="100%" height="100%">
{view === 'Time' ? (
<LineChart data={series} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis
dataKey="date"
tick={{ fill: '#6b7280', fontSize: 10 }}
tick={{ fill: '#9aa0b0', fontSize: 10 }}
tickFormatter={(d) => formatDate(String(d))}
minTickGap={28}
tickLine={false}
@@ -200,7 +274,7 @@ export default function RegimeChart() {
<YAxis
domain={[0, 100]}
ticks={[0, 25, 50, 75, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
tick={{ fill: '#9aa0b0', fontSize: 10 }}
width={34}
tickLine={false}
axisLine={false}
@@ -225,10 +299,13 @@ export default function RegimeChart() {
</LineChart>
) : (
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
<ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ef4444" fillOpacity={0.08} stroke="none" />
{/* One neutral at four opacities: denser = more axes elevated.
Hue here would collide with the fundamental glyphs drawn
on top of it — see QUADRANT_WASH. */}
<ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#ffffff" fillOpacity={QUADRANT_WASH.early_warning} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#ffffff" fillOpacity={QUADRANT_WASH.active_stress} stroke="none" />
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#ffffff" fillOpacity={QUADRANT_WASH.healthy} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ffffff" fillOpacity={QUADRANT_WASH.stabilizing} stroke="none" />
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
@@ -237,36 +314,37 @@ export default function RegimeChart() {
dataKey="x"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
tick={{ fill: '#9aa0b0', fontSize: 10 }}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#9aa0b0', fontSize: 10 }}
/>
<YAxis
type="number"
dataKey="y"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
tick={{ fill: '#9aa0b0', fontSize: 10 }}
width={30}
tickLine={false}
axisLine={false}
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#9aa0b0', fontSize: 10 }}
/>
<ZAxis range={[13, 13]} />
<ZAxis range={[18, 18]} />
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<PathTip />} />
<Scatter data={trail} line={{ stroke: 'rgba(96,165,250,0.18)', strokeWidth: 1.5 }} isAnimationActive={false}>
{trail.map((_, i) => (
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
))}
</Scatter>
<Scatter
data={trail}
line={{ stroke: 'rgba(255,255,255,0.18)', strokeWidth: 1.5 }}
shape={(props: { cx?: number; cy?: number }) => <circle cx={props.cx} cy={props.cy} r={0} />}
tooltipType="none"
isAnimationActive={false}
/>
<Scatter data={pathPoints} shape={<PathPointShape />} isAnimationActive={false} />
{latest && (
<Scatter
data={[latest]}
isAnimationActive={false}
shape={(props: { cx?: number; cy?: number }) => (
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke={STATE_COLOR} strokeWidth={2} />
)}
shape={<LatestPointShape />}
/>
)}
</ScatterChart>
@@ -275,7 +353,7 @@ export default function RegimeChart() {
</div>
{view === 'Time' ? (
<div className="mt-2 flex flex-wrap items-center gap-4 text-[11px] text-gray-400">
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-gray-400">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: STATE_COLOR }} />
State
@@ -284,20 +362,43 @@ export default function RegimeChart() {
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: WARNING_COLOR }} />
Warning
</span>
<span className="text-gray-600">dashed = each axis's elevated threshold ({xDiv} / {yDiv})</span>
<span className="text-gray-400">dashed = each axis's elevated threshold ({xDiv} / {yDiv})</span>
</div>
) : (
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
<span><span className="text-amber-400">Early warning</span> — calm, fragility rising</span>
<span><span className="text-orange-400">Active stress</span> — damaged and deteriorating</span>
<span><span className="text-emerald-400">Healthy</span> — calm, broadly supported</span>
<span><span className="text-red-400">Stabilizing</span> — damage remains, warning lower</span>
<span className="text-gray-600 sm:col-span-2">White dot = today; trail brightens toward the present, smoothed.</span>
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-xs text-gray-400 sm:grid-cols-2">
{/* Swatches, not coloured words: the quadrant names used the
fundamental channel's colours, so "Stabilizing" was rendered in
the adverse hue while meaning damage receding. */}
{([
['active_stress', 'Active stress', 'damaged and deteriorating'],
['early_warning', 'Early warning', 'calm, fragility rising'],
['stabilizing', 'Stabilizing', 'damage remains, warning lower'],
['healthy', 'Healthy', 'calm, broadly supported'],
] as const).map(([key, name, gloss]) => (
<span key={key} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-block h-3 w-3 shrink-0 rounded-sm border border-white/10"
style={{ background: `rgba(255,255,255,${QUADRANT_WASH[key] * 4})` }}
/>
<span className="text-gray-300">{name}</span> {gloss}
</span>
))}
<span className="text-gray-400 sm:col-span-2">Raw dated points grow toward today; the connecting line is smoothed. White ring = today.</span>
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-gray-400 sm:col-span-2">
<span>symbol + colour = fundamentals:</span>
{(['supportive', 'neutral', 'adverse', 'unknown'] as const).map((state) => (
<span key={state} className="inline-flex items-center gap-1.5">
<span aria-hidden="true" style={{ color: FUNDAMENTAL_VISUAL[state].color }}>{FUNDAMENTAL_SYMBOL[state]}</span>
{FUNDAMENTAL_VISUAL[state].label}
</span>
))}
</span>
</div>
)}
{crossesFreeze && (
<p className="mt-2 text-[11px] text-gray-600">
<p className="mt-2 text-xs text-gray-400">
History before {basketAsOf} is reconstructed against today's basket retrospective, not a live record.
</p>
)}
+1 -1
View File
@@ -9,7 +9,7 @@ interface DisclosureProps {
export function Disclosure({ summary, children }: DisclosureProps) {
return (
<details className="glass-sm group">
<summary className="flex cursor-pointer select-none items-center gap-2 px-4 py-2.5 text-xs font-medium text-gray-400 transition-colors hover:text-gray-200 [&::-webkit-details-marker]:hidden">
<summary className="flex min-h-11 cursor-pointer select-none items-center gap-2 px-4 py-2.5 text-xs font-medium text-gray-400 transition-colors hover:text-gray-200 [&::-webkit-details-marker]:hidden">
<span className="inline-block transition-transform duration-200 group-open:rotate-90"></span>
{summary}
</summary>
+65 -1
View File
@@ -1,4 +1,68 @@
import type { MarketRegime } from './types';
import type { FundamentalState, MarketRegime } from './types';
/** One visual vocabulary for the three-channel regime monitor. Keep chart SVG
* literals and DOM text in sync rather than letting Tailwind aliases and
* hard-coded colours describe the same state differently.
*
* **Hue identifies the channel, and only the channel.** Two collisions made
* that false and both are fixed here:
*
* - `supportive` was literally `state`, so teal meant "the State score" in the
* Time view and "fundamentals supportive" in the Path view of the same card.
* - `adverse` sat 19 degrees from `warning`, which is inside deuteranope
* confusion range for two channels that appear on adjacent tooltip lines.
*
* The market pair now sits at 27/190 degrees and the fundamental pair at
* 0/158, so every *cross-channel* pair is at least 27 degrees apart. All six
* clear 4.5:1 against `--surface`. Fundamentals additionally carry a glyph, so
* colour is never the sole encoding for the categorical channel.
*
* `neutral` and `unknown` are deliberately the same hue: they are two states of
* one channel, both meaning "no directional signal", separated by lightness
* (7.1:1 vs 5.4:1) and by glyph (filled circle vs ring). Do not "fix" their
* proximity by giving `unknown` a hue — that would make an absence of evidence
* look like a reading.
*
* These deliberately do *not* reuse `--up-text`/`--down-text`: those are the
* app's directional tokens, and `--up-text` is already this chart's State
* colour, which is how the first collision happened.
*/
export const REGIME_VISUAL = {
// Market channels — continuous scores, drawn as lines and positions.
state: '#6ec9db',
warning: '#fb923c',
// Fundamental channel — categorical, drawn as glyphs.
supportive: '#34d399',
neutral: '#9aa0b0',
adverse: '#f87171',
unknown: '#848a9c',
} as const;
/** Market quadrant severity as an opacity ramp on one neutral — never a hue.
*
* The quadrants are a State x Warning construct, so colouring them borrowed
* hues that already meant something else: "Healthy" was painted in the
* fundamental supportive colour and "Stabilizing" in the adverse one, which put
* an adverse glyph on an adverse-coloured background while meaning roughly the
* opposite (damage receding). Opacity carries how many axes are elevated, the
* position and labels carry which, and hue stays free to mean channel.
*/
export const QUADRANT_WASH = {
healthy: 0.015,
early_warning: 0.05,
stabilizing: 0.05,
active_stress: 0.085,
} as const;
export const FUNDAMENTAL_VISUAL: Record<
FundamentalState,
{ label: string; color: string; glyph: 'up' | 'circle' | 'diamond' | 'ring' }
> = {
supportive: { label: 'Supportive', color: REGIME_VISUAL.supportive, glyph: 'up' },
neutral: { label: 'Neutral', color: REGIME_VISUAL.neutral, glyph: 'circle' },
adverse: { label: 'Adverse', color: REGIME_VISUAL.adverse, glyph: 'diamond' },
unknown: { label: 'Unknown', color: REGIME_VISUAL.unknown, glyph: 'ring' },
};
export function regimeDot(label: MarketRegime['label']): string {
switch (label) {
+109 -23
View File
@@ -509,9 +509,24 @@ export interface RegimeReading {
trend?: { delta_7: number | null; delta_30: number | null };
}
/** Qualitative capex / earnings-reaction context. Not part of either score. */
export interface RegimeFundamentalOverlay {
export type FundamentalState = 'supportive' | 'neutral' | 'adverse' | 'unknown';
export type EvidenceQuality = 'complete' | 'partial' | 'stale' | 'manual' | 'unavailable';
/** The third channel: capex / earnings-reaction context, read alongside State
* and Warning by confluence. Deliberately never a term in either score — see
* the methodology doc on why no fusion weight is measurable yet. */
export interface RegimeFundamentalContext {
/** Derived from the stored facts by fixed rules, not by an LLM's judgement. */
state: FundamentalState;
evidence_quality: EvidenceQuality;
capex_signal: FundamentalState;
reaction_signal: FundamentalState;
/** Timing only: there is an effective, non-stale record to display. */
available: boolean;
/** Content too: it is available *and* actually determined something. A
* collected observation whose extraction failed is available but not usable,
* and only `usable` may confirm anything or count as study exposure. */
usable: boolean;
pending: boolean;
stale: boolean;
effective_date: string | null;
@@ -524,7 +539,7 @@ export interface RegimeFundamentalOverlay {
source: string | null;
fetched_at: string | null;
/** Whether anything was actually collected. Live reading only; the snapshot's
* point-in-time overlay omits it. */
* point-in-time record omits it. */
observed?: boolean;
observed_in_snapshot?: boolean;
}
@@ -533,6 +548,11 @@ export interface RegimeHistoryPoint {
date: string;
state: number | null;
warning: number | null;
/** The fundamental channel as recorded that day — drives the Path dot colour.
* Rows written before the channel existed read as "unknown", which is correct:
* nothing was observed then either. */
fundamental_state: FundamentalState;
evidence_quality: EvidenceQuality;
state_coverage: number | null;
warning_coverage: number | null;
basket_hash: string | null;
@@ -545,10 +565,12 @@ export interface RegimeMonitor {
date?: string;
state?: RegimeReading;
warning?: RegimeReading;
/** Point-in-time overlay recorded in the snapshot. */
fundamental_overlay?: RegimeFundamentalOverlay;
/** Current observation, even when it is not effective until the next session. */
fundamental_context?: RegimeFundamentalOverlay;
/** The channel as recorded in the snapshot — point-in-time, effective-date gated. */
fundamental_context?: RegimeFundamentalContext;
/** What we know right now, even when it is not effective until the next
* session. Separate from the above so a just-collected observation cannot
* look as though it had been backdated into the record. */
fundamental_live?: RegimeFundamentalContext;
inputs?: {
vix: number | null;
vix_date: string | null;
@@ -596,7 +618,7 @@ export interface RegimeFundamentals {
}
export type CapexState = 'raising' | 'holding' | 'cutting' | 'unknown';
export type GoodNewsReaction = 'yes' | 'no' | 'mixed';
export type GoodNewsReaction = 'yes' | 'no' | 'mixed' | 'unknown';
export interface RegimeFundamentalsUpdate {
capex?: Record<string, CapexState>;
@@ -611,9 +633,22 @@ export interface RegimeConfig {
}
// Event study — measured lead time of early-warning indicators vs. drawdowns
export interface EventStudyMetrics {
events: number;
events_warned: number;
events_missed: number;
alarm_episodes: number;
false_alarms: number;
/** null when the rule had no eligible sessions — undefined, not zero. */
false_alarms_per_year: number | null;
median_lead_days: number | null;
}
export interface EventStudyReport {
available: boolean;
reason?: string;
/** Report shape, independent of methodology. Mismatched reports are discarded. */
schema?: number;
methodology?: string;
generated_at?: string;
evaluation?: 'exploratory' | 'holdout';
@@ -624,14 +659,11 @@ export interface EventStudyReport {
event_threshold_pct: number;
event_cooldown_days: number;
horizon_days: number;
train_fraction: number;
warn_percentile: number;
warn_threshold: number;
basket_hash: string;
basket_asof: string;
credit_sensor_from?: string | null;
};
/** How far the headline metrics can be trusted. See _reliability(). */
/** How far the *fitted* variant's metrics can be trusted. See _reliability(). */
reliability?: {
events_detected: number;
events_in_holdout: number;
@@ -645,21 +677,75 @@ export interface EventStudyReport {
sample?: {
start: string;
end: string;
train_end: string;
test_start: string;
/** Where the quadrant baseline seeds — not a holdout boundary. */
evaluable_from: string;
sessions: number;
holdout_sessions: number;
evaluable_sessions: number;
events_detected: number;
events_evaluable: number;
};
metrics?: {
/** The quadrant-change rule that actually reaches Telegram. The headline. */
shipped?: {
rule: {
state_divider: number;
warning_divider: number;
margin: number;
confirm_sessions: number;
cooldown_days: number;
entry: string;
};
metrics: EventStudyMetrics;
events: { date: string; warned: boolean; lead_days: number | null }[];
quadrant_changes: number;
/** Debugging payload: every change the replay would have alerted on. Not rendered. */
fires: { index: number; date: string; from: string; to: string; state: number; warning: number }[];
/** Credit history starts partway through, so Warning is W1+W2 before it. */
by_era?: {
credit_from: string;
pre_credit: EventStudyMetrics & { label: string; start: string; end: string; sessions: number };
full_coverage: EventStudyMetrics & { label: string; start: string; end: string; sessions: number };
} | null;
};
/** The fundamental channel's actual exposure — its rows are scored on this
* window, not on the market rows' full sample. */
fundamental_coverage?: {
observations: number;
/** Sessions with usable (observed, effective, non-stale) context. */
sessions_eligible: number;
evaluable_sessions: number;
/** Corrections whose warning horizon had usable context. */
events_covered: number;
events_evaluable: number;
minimum_events: number;
/** False until enough corrections are covered: the fundamental rows are
* untested, not failed, and must not render as a 0/N result. */
measurable: boolean;
};
/** Ablations, external baselines, and the fundamental channel — all on fixed
* (unfitted) rules, so every row is scored on the same events. */
comparison?: (EventStudyMetrics & {
id: string;
label: string;
kind: 'ablation' | 'baseline' | 'fundamental';
note: string;
measurable: boolean;
})[];
null_model?: {
draws: number;
alarms_per_draw: number;
events: number;
events_warned: number;
events_missed: number;
alarm_episodes: number;
false_alarms: number;
false_alarms_per_year: number;
median_lead_days: number | null;
mean_warned: number;
sd_warned: number;
observed_warned: number;
p_at_least_observed: number;
} | null;
/** The original 70/30 fitted-threshold study, kept for continuity. */
fitted?: {
params: { train_fraction: number; warn_percentile: number; warn_threshold: number };
sample: { train_end: string; test_start: string; holdout_sessions: number };
metrics: EventStudyMetrics;
events: { date: string; warned: boolean; lead_days: number | null }[];
};
events?: { date: string; warned: boolean; lead_days: number | null }[];
recent_breadth?: { date: string; breadth: number; warning: number | null }[];
}
+436 -106
View File
@@ -6,6 +6,7 @@ import { Disclosure } from '../components/ui/Disclosure';
import { Badge } from '../components/ui/Badge';
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
import { useAuthStore } from '../stores/authStore';
import { FUNDAMENTAL_VISUAL } from '../lib/regime';
import {
getEventStudy,
getRegimeConfig,
@@ -17,11 +18,13 @@ import {
} from '../api/regime';
import type {
CapexState,
FundamentalState,
EventStudyMetrics,
EventStudyReport,
GoodNewsReaction,
RegimeBand,
RegimeConfig,
RegimeFundamentalOverlay,
RegimeFundamentalContext,
RegimeFundamentals,
RegimeFundamentalsUpdate,
RegimeMonitor,
@@ -39,7 +42,7 @@ const BAND_STYLES: Record<RegimeBand, { text: string; bar: string; ring: string;
function TrendChip({ label, delta }: { label: string; delta: number | null | undefined }) {
if (delta == null) {
return <span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-500">{label}: n/a</span>;
return <span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-400">{label}: n/a</span>;
}
const color = delta === 0 ? 'text-gray-400' : delta > 0 ? 'text-red-400' : 'text-emerald-400';
const arrow = delta === 0 ? '→' : delta > 0 ? '↑' : '↓';
@@ -68,21 +71,21 @@ function ScoreGauge({
// shared set would mislabel one of them. Render none rather than wrong ones.
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [];
return (
<div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}>
<div className={`glass h-full border p-5 ${style?.ring ?? 'border-white/[0.06]'}`}>
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-wider text-gray-500">{label}</div>
<div className="text-xs uppercase tracking-wider text-gray-400">{label}</div>
<div className="mt-1 flex items-baseline gap-2">
<span className={`font-display text-6xl font-bold ${style?.text ?? 'text-gray-500'}`}>
<span className={`font-display text-5xl font-bold ${style?.text ?? 'text-gray-500'}`}>
{score == null ? '—' : Math.round(score)}
</span>
{score != null && <span className="text-sm text-gray-500">/ 100</span>}
{score != null && <span className="text-sm text-gray-400">/ 100</span>}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<span className={`text-sm font-medium ${style?.text ?? 'text-gray-500'}`}>
{style?.label ?? 'Incomplete'}
</span>
<span className="text-xs text-gray-600">coverage {Math.round(reading?.coverage ?? 0)}%</span>
<span className="text-xs text-gray-400">coverage {Math.round(reading?.coverage ?? 0)}%</span>
</div>
</div>
<div className="flex gap-2">
@@ -102,7 +105,7 @@ function ScoreGauge({
/>
</div>
{/* Thresholds come from the reading: the two axes no longer share them. */}
<div className="relative mt-1.5 h-4 text-[10px] uppercase tracking-wider text-gray-600">
<div className="relative mt-1.5 h-4 text-xs uppercase tracking-wider text-gray-400">
<span className="absolute left-0">0</span>
{ticks.map((tick) => (
<span key={tick} className="absolute -translate-x-1/2 num" style={{ left: `${tick}%` }}>
@@ -113,86 +116,153 @@ function ScoreGauge({
</div>
</>
)}
<p className="mt-4 text-xs text-gray-500">{footnote}</p>
<p className="mt-4 text-xs leading-relaxed text-gray-400">{footnote}</p>
</div>
);
}
const CAPEX_TONE: Record<CapexState, string> = {
raising: 'text-emerald-400',
holding: 'text-amber-400',
cutting: 'text-red-400',
unknown: 'text-gray-500',
/** Mirrors `_capex_signal`: holding is the neutral case, so it takes the neutral
* colour rather than an amber that reads as a third severity and sits close to
* the Warning channel's orange. */
const CAPEX_COLOR: Record<CapexState, string> = {
raising: FUNDAMENTAL_VISUAL.supportive.color,
holding: FUNDAMENTAL_VISUAL.neutral.color,
cutting: FUNDAMENTAL_VISUAL.adverse.color,
unknown: FUNDAMENTAL_VISUAL.unknown.color,
};
const OVERLAY_TITLE = 'Fundamental overlay · context, not scored';
function sentenceCase(value: string): string {
const text = value.replace(/_/g, ' ');
return text.charAt(0).toUpperCase() + text.slice(1);
}
function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) {
const capex = overlay.capex ?? {};
const reaction = overlay.good_news_stock_down;
// Nothing collected: the stored default is "unknown" for every hyperscaler
// and "mixed" for the reaction, which are placeholders, not a reading.
if (overlay.observed === false) {
return (
<div className="glass border border-white/[0.06] p-5">
<div className="text-[11px] uppercase tracking-wider text-gray-500">{OVERLAY_TITLE}</div>
<p className="mt-3 text-xs text-gray-500">
No observation collected yet. An admin can collect one under Admin · Monitor settings. It is
context only it never enters State or Warning.
</p>
</div>
);
function reactionReading(reaction: GoodNewsReaction | null): { label: string; color: string } {
switch (reaction) {
case 'yes':
return { label: 'Yes · good news sold', color: FUNDAMENTAL_VISUAL.adverse.color };
case 'no':
return { label: 'No · ordinary reactions', color: FUNDAMENTAL_VISUAL.supportive.color };
case 'mixed':
return { label: 'Mixed · no clear pattern', color: FUNDAMENTAL_VISUAL.neutral.color };
default:
return { label: 'Unknown · not observed', color: FUNDAMENTAL_VISUAL.unknown.color };
}
}
function FundamentalSummaryCard({ overlay }: { overlay: RegimeFundamentalContext }) {
const tone = FUNDAMENTAL_VISUAL[overlay.state] ?? FUNDAMENTAL_VISUAL.unknown;
const observed = overlay.observed ?? Boolean(overlay.fetched_at);
const status = !observed
? 'No usable observation. This channel remains Unknown.'
: overlay.pending
? `Collected now; enters the point-in-time record ${overlay.effective_date ?? 'next session'}.`
: overlay.stale
? 'The last state is retained for context, but stale evidence cannot confirm alerts.'
: !overlay.usable
? 'An observation was collected, but no signal could be determined.'
: null;
return (
<div className="glass border border-white/[0.06] p-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">{OVERLAY_TITLE}</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
{overlay.source && <span>{overlay.source}</span>}
{/* When pending, the line below is the single carrier of this date. */}
{overlay.effective_date && !overlay.pending && <span>· effective {overlay.effective_date}</span>}
<div className="glass h-full border p-5" style={{ borderColor: `${tone.color}33` }}>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-400">Fundamentals · context</div>
<div className="flex flex-wrap gap-1.5">
{overlay.pending && <Badge label="pending" variant="manual" />}
{overlay.stale && <Badge label="stale" variant="manual" />}
</div>
</div>
{/* A pending observation is still shown — it is the freshest read we
have, and nothing here is scored. The date says when the stored
point-in-time record picks it up. */}
{overlay.pending && (
<p className="mt-3 text-xs text-amber-400/90">
Shown as collected. The point-in-time record picks it up{' '}
{overlay.effective_date ?? 'next session'} observations are never backdated.
<div className="mt-2 flex flex-wrap items-baseline gap-3">
<span className="font-display text-4xl font-bold" style={{ color: tone.color }}>{tone.label}</span>
<span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-300">
{sentenceCase(overlay.evidence_quality)} evidence
</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-2 text-xs">
<div className="rounded-lg bg-white/[0.025] px-3 py-2">
<div className="text-gray-400">Capex</div>
<div className="mt-0.5 font-medium" style={{ color: FUNDAMENTAL_VISUAL[overlay.capex_signal].color }}>
{sentenceCase(overlay.capex_signal)}
</div>
</div>
<div className="rounded-lg bg-white/[0.025] px-3 py-2">
<div className="text-gray-400">Reaction</div>
<div className="mt-0.5 font-medium" style={{ color: FUNDAMENTAL_VISUAL[overlay.reaction_signal].color }}>
{sentenceCase(overlay.reaction_signal)}
</div>
</div>
</div>
{status && <p className="mt-3 text-xs leading-relaxed text-gray-400">{status}</p>}
{(overlay.source || overlay.effective_date) && (
<p className="mt-3 text-[11px] text-gray-400">
{overlay.source ?? 'stored observation'}
{overlay.effective_date && ` · effective ${overlay.effective_date}`}
</p>
)}
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-2 flex items-baseline justify-between text-xs">
<span className="font-medium text-gray-300">Hyperscaler capex guidance</span>
<span className="num text-gray-500">{overlay.capex_stress ?? 'n/a'}</span>
</div>
<div className="space-y-1">
);
}
function FundamentalEvidence({ overlay }: { overlay: RegimeFundamentalContext }) {
const observed = overlay.observed ?? Boolean(overlay.fetched_at);
if (!observed) return null;
const capex = overlay.capex ?? {};
const reaction = reactionReading(overlay.good_news_stock_down);
return (
<Disclosure summary="Fundamental evidence · capex and earnings reaction">
<div className="grid gap-5 pt-1 sm:grid-cols-2">
<div>
<div className="mb-2 text-xs font-medium text-gray-200">Hyperscaler capex guidance</div>
{Object.keys(capex).length === 0 ? (
<p className="text-xs text-gray-400">No company-level observation.</p>
) : (
<div className="space-y-1.5">
{Object.entries(capex).map(([symbol, state]) => (
<div key={symbol} className="flex items-center justify-between text-xs">
<span className="font-mono text-gray-400">{symbol}</span>
<span className={CAPEX_TONE[state] ?? 'text-gray-500'}>{state}</span>
<span className="font-mono text-gray-300">{symbol}</span>
<span className="font-medium" style={{ color: CAPEX_COLOR[state] }}>{sentenceCase(state)}</span>
</div>
))}
</div>
)}
</div>
<div>
<div className="mb-2 flex items-baseline justify-between text-xs">
<span className="font-medium text-gray-300">Good news, stock down</span>
<span className="num text-gray-500">{overlay.earnings_stress ?? 'n/a'}</span>
</div>
<div className={`text-sm font-medium ${reaction === 'yes' ? 'text-red-400' : reaction === 'no' ? 'text-emerald-400' : 'text-gray-500'}`}>
{reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'}
<div className="mb-2 text-xs font-medium text-gray-200">Good news, stock down</div>
<div className="text-sm font-medium" style={{ color: reaction.color }}>{reaction.label}</div>
<p className="mt-2 text-xs text-gray-400">
Derived context: capex {overlay.capex_signal} · reaction {overlay.reaction_signal}.
</p>
</div>
</div>
{overlay.reasoning && (
<details className="mt-4 border-t border-white/[0.06] pt-3">
<summary className="cursor-pointer text-xs font-medium text-gray-400 hover:text-gray-200">
Source reasoning
</summary>
<p className="mt-2 text-xs leading-relaxed text-gray-300">{overlay.reasoning}</p>
</details>
)}
</Disclosure>
);
}
function ConfluenceStrip({ warning, context }: { warning: RegimeReading; context?: RegimeFundamentalContext }) {
const warningElevated = warning.band === 'elevated' || warning.band === 'breaking';
if (!warningElevated || !context?.usable || context.state !== 'adverse') return null;
return (
<div className="glass-sm relative overflow-hidden px-4 py-3" role="status">
<span className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-orange-400 to-red-400" aria-hidden="true" />
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 pl-1">
<span className="text-xs font-semibold uppercase tracking-wider text-orange-300">Confluence active</span>
<span className="text-sm text-gray-200">
Warning is {warning.band}; point-in-time fundamentals are adverse with {context.evidence_quality} evidence.
</span>
<span className="text-xs text-gray-400">Condition only · never a combined score</span>
</div>
{overlay.reasoning && <p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>}
</div>
);
}
@@ -209,7 +279,7 @@ function PillarTable({ state, warning }: { state: RegimeReading; warning: Regime
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-400">
<th className="px-4 py-3 font-medium">Pillar / sensor</th>
<th className="px-4 py-3 text-right font-medium">Score</th>
<th className="px-4 py-3 text-right font-medium">Weight</th>
@@ -221,7 +291,7 @@ function PillarTable({ state, warning }: { state: RegimeReading; warning: Regime
<tr className="border-b border-white/[0.06] bg-white/[0.02]">
<td colSpan={4} className="px-4 py-2 text-[11px] uppercase tracking-wider text-gray-400">
{title}
<span className="ml-2 normal-case tracking-normal text-gray-600">
<span className="ml-2 normal-case tracking-normal text-gray-400">
{reading.score ?? '—'} · {Math.round(reading.coverage)}% coverage
</span>
</td>
@@ -232,8 +302,8 @@ function PillarTable({ state, warning }: { state: RegimeReading; warning: Regime
<div className="font-medium text-gray-200">{pillar.label}</div>
<div className="mt-1 space-y-0.5">
{pillar.sensors.map((sensor) => (
<div key={sensor.id} className="text-xs text-gray-500">
<span className="font-mono text-gray-600">{sensor.id}</span> {sensor.label}:{' '}
<div key={sensor.id} className="text-xs text-gray-400">
<span className="font-mono text-gray-400">{sensor.id}</span> {sensor.label}:{' '}
<span className="num text-gray-400">{sensor.score == null ? 'n/a' : sensor.score}</span>
</div>
))}
@@ -256,7 +326,7 @@ function PillarTable({ state, warning }: { state: RegimeReading; warning: Regime
function MetaChip({ label, value, title }: { label: string; value: ReactNode; title?: string }) {
return (
<span className="rounded-lg bg-white/[0.03] px-2.5 py-1 text-[11px] text-gray-500" title={title}>
<span className="rounded-lg bg-white/[0.03] px-2.5 py-1 text-xs text-gray-400" title={title}>
{label} <span className="num text-gray-400">{value}</span>
</span>
);
@@ -288,56 +358,273 @@ function MetaStrip({ data }: { data: RegimeMonitor }) {
);
}
function EventStudyBody({ report }: { report: EventStudyReport }) {
const metrics = report.metrics;
function StatTiles({ metrics }: { metrics: EventStudyMetrics }) {
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge label={report.evaluation ?? 'exploratory'} variant={report.evaluation === 'holdout' ? 'auto' : 'manual'} />
{report.generated_at && <span className="text-xs text-gray-500">generated {new Date(report.generated_at).toLocaleDateString()}</span>}
{report.sample && <span className="text-xs text-gray-500">test {report.sample.test_start} {report.sample.end}</span>}
</div>
<p className="text-sm leading-relaxed text-gray-300">{report.summary}</p>
{metrics && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
['Warned', `${metrics.events_warned}/${metrics.events}`],
['Missed', metrics.events_missed],
['False alarms/year', metrics.false_alarms_per_year.toFixed(1)],
['False alarms/year', metrics.false_alarms_per_year?.toFixed(1) ?? '—'],
['Median lead', metrics.median_lead_days == null ? '—' : `${metrics.median_lead_days}d`],
].map(([label, value]) => (
<div key={String(label)} className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
<div className="text-[11px] text-gray-500">{label}</div>
<div className="text-xs text-gray-400">{label}</div>
<div className="mt-0.5 text-lg font-semibold text-gray-200">{value}</div>
</div>
))}
</div>
)}
{report.events && report.events.length > 0 && (
);
}
function EventTable({ events }: { events: { date: string; warned: boolean; lead_days: number | null }[] }) {
return (
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
<table className="w-full text-xs">
<thead><tr className="border-b border-white/[0.06] text-left text-gray-500">
<thead><tr className="border-b border-white/[0.06] text-left text-gray-400">
<th className="px-3 py-2 font-medium">Correction</th>
<th className="px-3 py-2 text-right font-medium">Warned</th>
<th className="px-3 py-2 text-right font-medium">Lead</th>
</tr></thead>
<tbody>{report.events.map((event) => (
<tbody>{events.map((event) => (
<tr key={event.date} className="border-b border-white/[0.03] last:border-0">
<td className="px-3 py-2 num text-gray-300">{event.date}</td>
<td className={`px-3 py-2 text-right ${event.warned ? 'text-emerald-400' : 'text-gray-500'}`}>{event.warned ? 'yes' : 'no'}</td>
<td className={`px-3 py-2 text-right ${event.warned ? 'text-emerald-400' : 'text-gray-400'}`}>{event.warned ? 'yes' : 'no'}</td>
<td className="px-3 py-2 text-right num text-gray-300">{event.lead_days == null ? '—' : `${event.lead_days}d`}</td>
</tr>
))}</tbody>
</table>
</div>
);
}
/** Shipped rule against ablations, external baselines, and chance.
*
* The two kinds answer different questions and must not be read as one list:
* an ablation asks whether the quadrant machinery earns its place, a baseline
* asks whether the score earns its complexity.
*/
function ComparisonTable({ report }: { report: EventStudyReport }) {
const shipped = report.shipped;
if (!shipped || !report.comparison?.length) return null;
const rows = [
{
id: 'shipped',
label: 'Quadrant alert (shipped)',
kind: 'shipped' as const,
note: shipped.rule.entry,
measurable: true,
...shipped.metrics,
},
...report.comparison,
];
const KIND_LABEL: Record<string, string> = {
shipped: 'shipped',
ablation: 'ablation',
baseline: 'baseline',
fundamental: 'fundamental',
};
return (
<div className="space-y-2">
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
<table className="w-full text-xs">
<thead><tr className="border-b border-white/[0.06] text-left text-gray-400">
<th className="px-3 py-2 font-medium">Rule</th>
<th className="px-3 py-2 text-right font-medium">Warned</th>
<th className="px-3 py-2 text-right font-medium">FA/yr</th>
<th className="px-3 py-2 text-right font-medium">Median lead</th>
</tr></thead>
<tbody>{rows.map((row) => (
<tr
key={row.id}
className={`border-b border-white/[0.03] last:border-0 ${row.kind === 'shipped' ? 'bg-white/[0.03]' : ''}`}
title={row.note}
>
<td className={`px-3 py-2 ${row.kind === 'shipped' ? 'font-medium text-gray-200' : 'text-gray-400'}`}>
{row.label}
<span className="ml-2 text-xs uppercase tracking-wide text-gray-400">{KIND_LABEL[row.kind]}</span>
</td>
{/* A rule whose input does not exist yet scores 0/N, and printing
that would read as tested-and-failed. Say "not measurable". */}
{row.measurable === false ? (
<td className="px-3 py-2 text-right text-xs italic text-gray-400" colSpan={3}>
{/* Not "no observations yet": once some exist but fewer than
the minimum are covered, that is simply false. Matches the
callout below. */}
insufficient exposure not measurable
</td>
) : (
<>
<td className="px-3 py-2 text-right num text-gray-300">{row.events_warned}/{row.events}</td>
<td className="px-3 py-2 text-right num text-gray-300">{row.false_alarms_per_year?.toFixed(1) ?? '—'}</td>
<td className="px-3 py-2 text-right num text-gray-300">{row.median_lead_days == null ? '—' : `${row.median_lead_days}d`}</td>
</>
)}
</tr>
))}
{report.null_model && (
<tr className="border-t border-white/[0.06] text-gray-400">
<td className="px-3 py-2">
Random alarms, same firing rate
<span className="ml-2 text-xs uppercase tracking-wide text-gray-400">null</span>
</td>
<td className="px-3 py-2 text-right num">
{report.null_model.mean_warned.toFixed(1)} ± {report.null_model.sd_warned.toFixed(1)}
</td>
<td className="px-3 py-2 text-right num"></td>
<td className="px-3 py-2 text-right num"></td>
</tr>
)}</tbody>
</table>
</div>
</div>
);
}
function StudyVerdict({ report }: { report: EventStudyReport }) {
const model = report.null_model;
if (!model) return null;
const chancePct = (model.p_at_least_observed * 100).toFixed(0);
const indistinguishable = model.p_at_least_observed >= 0.1;
// The number carries the claim, not the adjective. At ~10 corrections a p of
// 0.09 is not evidence of anything, so "beats the null" would over-state a
// result this panel is otherwise careful never to over-state.
return (
<Callout variant={indistinguishable ? 'warning' : 'info'}>
<strong>
{indistinguishable
? `Not distinguishable from chance (p = ${model.p_at_least_observed.toFixed(2)}).`
: `Above the firing-rate null (p = ${model.p_at_least_observed.toFixed(2)}).`}
</strong>{' '}
Random alarms match or beat {model.observed_warned}/{model.events} warned corrections in {chancePct}% of{' '}
{model.draws} draws placing {model.alarms_per_draw} alarms over the same sessions. Corrections cluster and random
placement does not, so this is the floor, not the bar.
</Callout>
);
}
/** The credit sensor starts partway through, so Warning is a different
* construct either side of it. The share is derived, never asserted: if one era
* carries no corrections there is no comparison to draw and the per-era ratios
* would be noise dressed up as a finding. */
function EraDisclosure({
eras,
divider,
}: {
eras: NonNullable<NonNullable<EventStudyReport['shipped']>['by_era']>;
divider: number | undefined;
}) {
const { pre_credit: pre, full_coverage: full } = eras;
const total = pre.sessions + full.sessions;
const share = total > 0 ? Math.round((pre.sessions / total) * 100) : 0;
// An era holding one or two corrections has a recall of 0/1 or 1/2, which is
// not a rate. Below this the eras get their false-alarm rates compared and
// nothing else.
const comparable = pre.events >= 3 && full.events >= 3;
return (
<Disclosure summary={`Sensor-era caveat · ${share}% of sessions predate credit`}>
<p className="text-xs leading-relaxed text-gray-400">
<strong>{share}% of the evaluated sessions predate the credit sensor.</strong> W3 begins {eras.credit_from}, so
before that Warning renormalises to W1+W2 and the fixed {divider} divider is applied to a different construct
than it was reasoned about. Dropping the training split makes every correction evaluable; it does not make the
coverage gap go away, it moves it from the threshold to the score.
{comparable ? (
<>
{' '}Two sensors:{' '}
<strong className="text-gray-300">{pre.events_warned}/{pre.events}</strong> at{' '}
{pre.false_alarms_per_year?.toFixed(1) ?? '—'} FA/yr. All three:{' '}
<strong className="text-gray-300">{full.events_warned}/{full.events}</strong> at{' '}
{full.false_alarms_per_year?.toFixed(1) ?? '—'} FA/yr.
</>
) : (
<>
{' '}The corrections do not straddle that boundary ({pre.events} before, {full.events} after), so the two
eras cannot be compared on recall only the false-alarm rates are meaningful ({pre.false_alarms_per_year?.toFixed(1) ?? '—'}{' '}
vs {full.false_alarms_per_year?.toFixed(1) ?? '—'} per year).
</>
)}
</p>
</Disclosure>
);
}
function EventStudyBody({ report }: { report: EventStudyReport }) {
const shipped = report.shipped;
const eras = shipped?.by_era;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge label={report.evaluation ?? 'exploratory'} variant={report.evaluation === 'holdout' ? 'auto' : 'manual'} />
{report.generated_at && <span className="text-xs text-gray-400">generated {new Date(report.generated_at).toLocaleDateString()}</span>}
{report.sample && <span className="text-xs text-gray-400">{report.sample.evaluable_from} {report.sample.end}</span>}
</div>
<StudyVerdict report={report} />
<p className="text-sm leading-relaxed text-gray-300">{report.summary}</p>
{shipped && <StatTiles metrics={shipped.metrics} />}
<ComparisonTable report={report} />
{shipped && shipped.events.length > 0 && (
<Disclosure summary={`Correction details · ${shipped.events.length} events`}>
<EventTable events={shipped.events} />
</Disclosure>
)}
{report.null_model && (
<Disclosure summary="Null-model interpretation">
<p className="text-xs leading-relaxed text-gray-400">
The null places {report.null_model.alarms_per_draw} alarms at random over the same sessions and at the
shipped rule's firing rate. Corrections cluster while random placement does not, so this is a floor rather
than a demanding benchmark: a clustering rule could beat it without genuine foresight.
</p>
</Disclosure>
)}
{report.fundamental_coverage && !report.fundamental_coverage.measurable && (
<Disclosure
summary={`Fundamental exposure · ${report.fundamental_coverage.events_covered}/${report.fundamental_coverage.events_evaluable} corrections covered`}
>
<p className="text-xs leading-relaxed text-gray-400">
<strong>Insufficient exposure — the fundamental rows are untested, not failed.</strong>{' '}
The channel had usable context on{' '}
<strong className="text-gray-300">
{report.fundamental_coverage.sessions_eligible} of{' '}
{report.fundamental_coverage.evaluable_sessions}
</strong>{' '}
evaluated sessions, covering{' '}
<strong className="text-gray-300">
{report.fundamental_coverage.events_covered} of{' '}
{report.fundamental_coverage.events_evaluable}
</strong>{' '}
corrections ({report.fundamental_coverage.minimum_events} needed;{' '}
{report.fundamental_coverage.observations} observation
{report.fundamental_coverage.observations === 1 ? '' : 's'} recorded). Those rows are
scored only on that window, never on the market rows' full sample otherwise a
fortnight of data would render as a 0/10 and read as a failed test. Read the market rows
as a verdict on the technical sensors and the alert machinery only.
</p>
</Disclosure>
)}
{eras && <EraDisclosure eras={eras} divider={shipped?.rule.warning_divider} />}
{report.fitted && (
<Disclosure summary={`Fitted-threshold variant · ${report.fitted.metrics.events_warned}/${report.fitted.metrics.events} on the 30% holdout`}>
<div className="space-y-3 pt-1">
<p className="text-xs leading-relaxed text-gray-400">
The original study, kept because it is what the methodology document reports: an{' '}
{report.fitted.params.warn_percentile}th-percentile Warning threshold (
{report.fitted.params.warn_threshold}) frozen on the first{' '}
{(report.fitted.params.train_fraction * 100).toFixed(0)}% of sessions and measured on the rest. Nothing
consumes this rule the shipped alert uses fixed dividers with hysteresis, confirmation and a cooldown.
</p>
<StatTiles metrics={report.fitted.metrics} />
{report.fitted.events.length > 0 && <EventTable events={report.fitted.events} />}
{report.reliability && (report.reliability.underpowered || report.reliability.sensor_coverage_mismatch) && (
<Callout variant="warning">
<div className="space-y-1.5">
{report.reliability.underpowered && (
<p>
<strong>Underpowered.</strong> Only {report.reliability.events_in_holdout} of{' '}
{report.reliability.events_detected} detected corrections fall in the test period (
{report.reliability.events_detected} detected corrections fall in the holdout (
{report.reliability.minimum_events}+ needed). Read the direction, not the ratio.
</p>
)}
@@ -355,6 +642,9 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
</Callout>
)}
</div>
</Disclosure>
)}
</div>
);
}
@@ -394,15 +684,26 @@ function FundamentalsEditor({
}) {
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');
// Mirrors _CAPEX_STATE_SCORES: raising 0, holding 50, cutting 100. Holding is
// the deceleration case and used to score identically to raising.
const capexPoints = knownCapex.reduce((sum, state) => sum + (state === 'cutting' ? 100 : state === 'holding' ? 50 : 0), 0);
const derivedF1 = knownCapex.length >= 3 ? Math.round((capexPoints / knownCapex.length) * 10) / 10 : null;
const derivedF3 = reaction === 'yes' ? 100 : reaction === 'no' ? 0 : null;
const values = Object.values(capex);
const counts = {
cutting: values.filter((s) => s === 'cutting').length,
holding: values.filter((s) => s === 'holding').length,
raising: values.filter((s) => s === 'raising').length,
unknown: values.filter((s) => s === 'unknown').length,
};
// Mirrors _capex_signal: any cut is adverse on partial evidence, any hold is
// neutral, all-known-raising is supportive, nothing known is unknown. No
// average — an average would let cuts and unknowns land on "neutral".
const capexSignal: FundamentalState =
counts.cutting > 0 ? 'adverse'
: counts.holding > 0 ? 'neutral'
: counts.raising > 0 ? 'supportive'
: 'unknown';
const reactionSignal: FundamentalState =
reaction === 'yes' ? 'adverse' : reaction === 'no' ? 'supportive' : reaction === 'mixed' ? 'neutral' : 'unknown';
return (
<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-400">
<span>Source: {data.source}</span>
{data.fetched_at && <span>· fetched {new Date(data.fetched_at).toLocaleDateString()}</span>}
{data.effective_date && <span>· effective {data.effective_date}</span>}
@@ -411,8 +712,8 @@ function FundamentalsEditor({
{data.reasoning && <p className="text-xs leading-relaxed text-gray-400">{data.reasoning}</p>}
<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>
<span className="font-medium text-gray-300">Capex guidance by hyperscaler</span>
<span style={{ color: FUNDAMENTAL_VISUAL[capexSignal].color }}>{capexSignal}</span>
</div>
<div className="grid grid-cols-2 gap-2">
{Object.entries(capex).map(([symbol, state]) => (
@@ -428,17 +729,24 @@ function FundamentalsEditor({
</label>
))}
</div>
<p className="mt-1.5 text-[11px] text-gray-600">Raising = 0, holding = 50, cutting = 100; at least three known names required.</p>
<p className="mt-1.5 text-xs text-gray-400">
{counts.cutting} cutting · {counts.holding} holding · {counts.raising} raising · {counts.unknown} unknown.
Any cut reads adverse on partial evidence; supportive needs every known name raising.
</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 className="font-medium text-gray-300">Good news, stock down</span>
<span className="ml-2" style={{ color: FUNDAMENTAL_VISUAL[reactionSignal].color }}>{reactionSignal}</span>
</span>
{/* "Mixed" is an observed mixed reaction; "unknown" is nobody looked or
the extraction failed. Collapsing them made a parse error read as
neutral evidence. */}
<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>
<option value="yes">Yes · good news sold</option>
<option value="no">No · reacting normally</option>
<option value="mixed">Mixed · observed, no clear pattern</option>
<option value="unknown">Unknown · not observed</option>
</select>
</label>
<div className="flex flex-wrap gap-2">
@@ -465,7 +773,7 @@ function ConfigEditor({ data, onSave, saving }: { data: RegimeConfig; onSave: (u
<input type="number" min={30} max={180} value={staleness} onChange={(event) => setStaleness(Number(event.target.value))} className="w-20 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200" />
<span>days</span>
</label>
<p className="text-[11px] text-gray-600">Changing the basket resets its freeze date and silently reseeds quadrant alerts.</p>
<p className="text-xs text-gray-400">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 basket &amp; freshness</button>
</div>
);
@@ -483,13 +791,13 @@ function AdminControls() {
<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>
<div className="mb-3 text-xs uppercase tracking-wider text-gray-400">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>}
</section>
<section>
<div className="mb-3 text-[11px] uppercase tracking-wider text-gray-500">Fixed basket &amp; freshness</div>
<div className="mb-3 text-xs uppercase tracking-wider text-gray-400">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} />}
{saveConfig.isError && <Callout variant="error">Save failed: {(saveConfig.error as Error).message}</Callout>}
@@ -508,7 +816,19 @@ export default function RegimePage() {
<div className="space-y-6 animate-slide-up">
<PageHeader
title="AI/Tech Risk Monitor"
subtitle="AI/Tech risk thermometer — observational only, feeds no entry, exit, or sizing decision"
subtitle="Market stress, early warning, and fundamental context"
actions={
<div className="flex flex-wrap items-center justify-end gap-2">
<Badge label="observational only" variant="default" />
{data?.date && <span className="num text-xs text-gray-400">as of {data.date}</span>}
{data?.available && (
<Badge
label={data.data_quality?.is_fresh ? 'fresh' : 'check data'}
variant={data.data_quality?.is_fresh ? 'auto' : 'manual'}
/>
)}
</div>
}
/>
{monitor.isLoading && <><SkeletonCard className="h-44" /><SkeletonTable rows={6} cols={4} /></>}
@@ -524,7 +844,7 @@ export default function RegimePage() {
</Callout>
)}
<div className="grid gap-4 lg:grid-cols-2">
<div className="grid gap-4 lg:grid-cols-3">
<ScoreGauge
label="State · stress right now"
reading={data.state}
@@ -542,17 +862,27 @@ export default function RegimePage() {
<ScoreGauge
label="Warning · deterioration & divergence"
reading={data.warning}
footnote="Breadth divergence, SMH/SPY rollover, and HY credit impulse. Missing sensors reduce coverage; they never default to 50."
footnote={
<>
Breadth divergence · SMH/SPY rollover · HY credit impulse.
{data.warning.coverage < 100 && ' Missing sensors are omitted rather than filled.'}
</>
}
/>
{data.fundamental_live && <FundamentalSummaryCard overlay={data.fundamental_live} />}
</div>
<ConfluenceStrip warning={data.warning} context={data.fundamental_context} />
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeChart /></Suspense>
{data.fundamental_live && <FundamentalEvidence overlay={data.fundamental_live} />}
<PillarTable state={data.state} warning={data.warning} />
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
<Disclosure summary="Data provenance · coverage and history">
<MetaStrip data={data} />
</Disclosure>
</>
)}
+19
View File
@@ -42,6 +42,14 @@
appearance: textfield;
}
/* --ink, not --up-text: a focus ring must not carry a semantic colour. The
directional token reads as "up/positive" and lands at poor contrast on the
controls that are already that colour. */
:where(button, a, input, select, textarea, summary, [tabindex]):focus-visible {
outline: 2px solid var(--ink);
outline-offset: 3px;
}
/* Atmosphere: faint starfield + soft rim-cyan / ember glows + film grain */
#root {
position: relative;
@@ -83,6 +91,17 @@
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
@layer components {
/* Mars horizon — fixed at the viewport bottom, atmosphere only, never data */
.app-horizon {
@@ -18,6 +18,15 @@ The script refuses to emit a band recommendation unless every hard gate passes.
That is deliberate: it must be structurally impossible to read a calibration
result out of a run whose pipeline did not validate.
**Fundamental channel note.** The sourced capex / earnings read is a separate
categorical channel and is never a term in State or Warning, so every variant and
gate below is unaffected by it. This harness passes no observation, which means
the ``fundamental_context`` on each replayed row reads ``unknown`` -- correct, and
the same thing production reports for a session nobody observed. Calibrating
anything *about* that channel needs an observation series passed through
``_compute_index(..., observations=...)``, and enough history to be worth
calibrating against.
Research branch only. Example:
.\\.venv\\Scripts\\python.exe scripts\\run_regime_monitor_calibration.py ^
+398 -1
View File
@@ -1,17 +1,27 @@
"""Tests for v3 correction events, warning alarm episodes, and report caveats."""
"""Tests for correction events, alarm episodes, the shipped-rule replay, and caveats."""
from __future__ import annotations
from copy import deepcopy
from datetime import date, timedelta
import pytest
from app.services.breadth_service import _breadth_from_closes, compute_divergence_series
from app.services.event_study_service import (
MIN_EVENTS_FOR_CONFIDENCE,
STRESS_QUADRANT,
WARNING_QUADRANTS,
_era_split,
_null_model,
_percentile,
_reliability,
alarm_episodes,
below_average_series,
detect_events,
entry_alarms,
evaluate_alarms,
replay_quadrant_changes,
)
@@ -19,6 +29,33 @@ def _days(count: int, start: date = date(2021, 1, 1)) -> list[date]:
return [start + timedelta(days=index) for index in range(count)]
def _row(
warning: float,
state: float = 0.0,
*,
warning_coverage: float = 100.0,
state_coverage: float = 100.0,
fresh: bool = True,
) -> dict:
return {
"state": state,
"warning": warning,
"state_coverage": state_coverage,
"warning_coverage": warning_coverage,
"inputs_fresh": fresh,
}
def _rows(
dates: list[date], warnings: list[float], patch: dict[int, dict] | None = None
) -> dict[date, dict]:
"""One publishable row per date, with per-position replacements."""
built = {day: _row(value) for day, value in zip(dates, warnings)}
for index, replacement in (patch or {}).items():
built[dates[index]] = replacement
return built
def test_detect_events_uses_rising_edge_and_cooldown():
closes = [100.0] * 300 + [85.0] * 5 + [100.0] * 50 + [85.0] * 5
events = detect_events(closes, _days(len(closes)), threshold_pct=15.0, cooldown=40)
@@ -88,6 +125,366 @@ def test_evaluate_alarms_counts_episodes_not_alarm_days():
assert result["median_lead_days"] == 17.5
# ---------------------------------------------------------------------------
# The shipped quadrant rule, replayed
# ---------------------------------------------------------------------------
def test_replay_seeds_silently_and_needs_two_sessions():
"""A one-session spike is not an alert; the second session confirms it.
The alarm is therefore dated at the confirmation rather than at the first
crossing, which costs one session of lead. That is what ships.
"""
dates = _days(10)
spike = _rows(dates, [30] * 5 + [70] + [30] * 4)
assert replay_quadrant_changes(spike, dates) == []
held = _rows(dates, [30] * 5 + [70, 70] + [30] * 3)
fires = replay_quadrant_changes(held, dates)
# The rule alerts on quadrant changes in both directions, so the return to
# calm fires too. Only the entry is a warning about anything.
assert [(f["index"], f["from"], f["to"]) for f in fires] == [
(6, "3", "1"),
(9, "1", "3"),
]
assert entry_alarms(fires, WARNING_QUADRANTS) == [6]
def test_confirmation_classifies_the_prior_session_against_the_baseline():
"""Not against its own predecessor -- the distinction changes the answer.
Warning 42 sits inside the hysteresis deadband. Measured from the standing
"3" baseline it is still "3", so it cannot confirm a move to "1". A chain
that classified each session against the one before it would read 42 as "1"
(having just seen 70) and fire a day later, which production does not do.
"""
dates = _days(10)
rows = _rows(dates, [30, 30, 30, 30, 70, 42, 70, 30, 30, 30])
assert replay_quadrant_changes(rows, dates) == []
def test_cooldown_suppresses_and_the_baseline_only_advances_on_a_fire():
dates = _days(10)
rows = _rows(dates, [30, 30, 30, 30, 70, 70, 30, 30, 30, 30])
fires = replay_quadrant_changes(rows, dates)
# Entry confirmed on day 5. The exit confirms on day 7 but lands inside the
# 3-day cooldown, so it is re-evaluated and fires on day 8 instead.
assert [(f["index"], f["from"], f["to"]) for f in fires] == [
(5, "3", "1"),
(8, "1", "3"),
]
assert entry_alarms(fires, WARNING_QUADRANTS) == [5]
def test_low_coverage_sessions_cannot_confirm():
"""The confirmation source has to be a session that published a band."""
dates = _days(10)
warnings = [30, 30, 30, 30, 30, 70, 70, 30, 30, 30]
visible = replay_quadrant_changes(_rows(dates, warnings), dates)
assert entry_alarms(visible, WARNING_QUADRANTS) == [6]
# Day 5 is the only session that could confirm the entry on day 6; below
# MIN_COVERAGE it never published a band, so day 4 is the prior instead.
hidden = _rows(dates, warnings, {5: _row(70, warning_coverage=70.0)})
assert replay_quadrant_changes(hidden, dates) == []
def test_stale_inputs_block_todays_alert_but_not_tomorrows_confirmation():
"""is_fresh gates the live reading only; the prior session comes from history."""
dates = _days(10)
rows = _rows(dates, [30] * 4 + [70, 70, 70] + [30] * 3, {5: _row(70, fresh=False)})
fires = replay_quadrant_changes(rows, dates)
assert entry_alarms(fires, WARNING_QUADRANTS) == [6]
def test_entry_alarms_ignore_movement_inside_the_set():
fires = [
{"index": 3, "from": "3", "to": "1"},
{"index": 9, "from": "1", "to": "2"},
{"index": 20, "from": "2", "to": "4"},
]
assert entry_alarms(fires, WARNING_QUADRANTS) == [3]
assert entry_alarms(fires, STRESS_QUADRANT) == [9]
def test_below_average_series_needs_a_full_window():
series = list(zip(_days(6), [10.0, 10.0, 10.0, 10.0, 4.0, 20.0]))
indicator = below_average_series(series, window=3)
assert _days(6)[1] not in indicator # warm-up
assert indicator[_days(6)[4]] == 100.0 # 4 is under the 3-day mean of 8
assert indicator[_days(6)[5]] == 0.0
def test_null_model_is_seeded_and_drawn_from_evaluable_sessions_only():
dates = _days(300)
events = [100, 180, 260]
first = _null_model(6, events, dates, horizon=20, start_index=50, observed_warned=2, draws=200)
second = _null_model(6, events, dates, horizon=20, start_index=50, observed_warned=2, draws=200)
assert first == second # a re-run must not move the report
assert 0.0 <= first["p_at_least_observed"] <= 1.0
assert first["alarms_per_draw"] == 6
assert first["mean_warned"] <= len(events)
# More alarms than there are sessions to place them on is not a null.
assert _null_model(500, events, dates, 20, 50, 2, draws=10) is None
assert _null_model(6, [], dates, 20, 50, 0, draws=10) is None
def test_era_split_reports_the_two_sensor_eras_separately():
"""The fuller sample is mostly pre-credit, where Warning is W1+W2 only."""
dates = _days(400)
eras = _era_split(
alarms=[80, 300],
event_indices=[90, 310],
dates=dates,
horizon=20,
start_index=10,
credit_from=dates[200],
)
assert eras["pre_credit"]["events"] == 1
assert eras["pre_credit"]["events_warned"] == 1
assert eras["full_coverage"]["events"] == 1
assert eras["full_coverage"]["events_warned"] == 1
assert eras["credit_from"] == dates[200].isoformat()
# No credit series at all means there is no boundary to split on.
assert _era_split([80], [90], dates, 20, 10, None) is None
def _business_days(count: int, end: date = date(2026, 8, 7)) -> list[date]:
out: list[date] = []
cursor = end
while len(out) < count:
if cursor.weekday() < 5:
out.append(cursor)
cursor -= timedelta(days=1)
return list(reversed(out))
def _synthetic_path(sessions: int) -> list[float]:
"""A rising leader with two deep drawdowns, so corrections exist to detect."""
closes: list[float] = []
for index in range(sessions):
if index < 350:
closes.append(100.0 + index * 0.25)
elif index < 400:
closes.append(187.5 - (index - 350) * 0.9)
elif index < 650:
closes.append(142.5 + (index - 400) * 0.4)
elif index < 700:
closes.append(242.5 - (index - 650) * 1.1)
else:
closes.append(187.5 + (index - 700) * 0.3)
return closes
async def test_report_assembles_every_rule_from_synthetic_inputs(monkeypatch):
"""End-to-end: the shipped replay, ablations, baselines and null all score.
Synthetic rather than recorded because the point is the wiring -- that every
rule is measured on the same events over the same sessions and the report
carries what the panel reads. The numbers are meaningless by construction.
"""
import app.services.event_study_service as ess
sessions = 900
dates = _business_days(sessions)
closes = _synthetic_path(sessions)
leader = list(zip(dates, closes))
# SPY grinds up throughout, so the leader's relative strength rolls over
# exactly when it falls.
market = list(zip(dates, [100.0 + index * 0.12 for index in range(sessions)]))
# Breadth deteriorates ~15 sessions ahead of each decline, which is the
# divergence W1 exists to catch.
breadth = {}
for index, day in enumerate(dates):
weak = 335 <= index < 400 or 635 <= index < 700
breadth[day] = 30.0 if weak else 70.0
vix = [(day, 32.0 if (350 <= i < 400 or 650 <= i < 700) else 15.0) for i, day in enumerate(dates)]
# Credit starts late, exactly as ICE's 3-year cap makes it in production.
oas = [(day, 4.2 if (650 <= i < 700) else 3.0) for i, day in enumerate(dates) if i >= 500]
async def fake_config(_db):
return deepcopy(ess.rms.DEFAULT_CONFIG)
async def fake_prices(_config, _start, _end):
return {"SMH": leader, "QQQ": leader, "SPY": market}
async def fake_fred(series_id, _start, _end):
return {"VIXCLS": vix, "BAMLH0A0HYM2": oas}.get(series_id)
async def fake_breadth(_db, _symbols, window=200, min_tickers=20):
return breadth, {day: 30 for day in dates}
async def fake_observations(_db):
return []
monkeypatch.setattr(ess.rms, "get_regime_config", fake_config)
monkeypatch.setattr(ess.rms, "_fetch_prices", fake_prices)
monkeypatch.setattr(ess.rms, "_fetch_fred_series", fake_fred)
monkeypatch.setattr(ess.rms, "get_fundamental_observations", fake_observations)
monkeypatch.setattr(ess.breadth_service, "compute_breadth_details", fake_breadth)
monkeypatch.setattr(ess, "NULL_DRAWS", 100)
report = await ess.run_event_study(None)
assert report["available"] is True
assert report["schema"] == ess.STUDY_SCHEMA
# The shipped rule is measured on the whole sample, not a 30% holdout.
shipped = report["shipped"]
assert shipped["metrics"]["events"] == report["sample"]["events_evaluable"]
assert report["sample"]["events_evaluable"] >= 2
assert shipped["metrics"]["events"] >= report["fitted"]["metrics"]["events"]
assert len(shipped["events"]) == shipped["metrics"]["events"]
assert {row["kind"] for row in report["comparison"]} == {
"ablation", "baseline", "fundamental",
}
# Market rows share the headline's events, or the table lies. Fundamental
# rows deliberately do not: they are coverage-matched to the sessions the
# channel actually existed on, which is a different (here empty) window.
for row in report["comparison"]:
if row["kind"] != "fundamental":
assert row["events"] == shipped["metrics"]["events"]
assert row["false_alarms_per_year"] >= 0
else:
# No eligible sessions means the rate is undefined, not zero. A
# tiny-divisor fallback here printed 5e9 alarms/year.
assert row["false_alarms_per_year"] is None
# The credit sensor starts mid-sample, so the era split must be populated.
eras = shipped["by_era"]
assert eras["credit_from"] == dates[500].isoformat()
assert eras["pre_credit"]["events"] + eras["full_coverage"]["events"] == shipped["metrics"]["events"]
if report["null_model"] is not None:
assert 0.0 <= report["null_model"]["p_at_least_observed"] <= 1.0
assert report["null_model"]["observed_warned"] == shipped["metrics"]["events_warned"]
# With an empty observation series the fundamental rows are *untested*, not
# failed, and the report has to carry that distinction or a 0/10 in the table
# reads as a measured result.
coverage = report["fundamental_coverage"]
assert coverage["observations"] == 0
assert coverage["sessions_eligible"] == 0
assert coverage["events_covered"] == 0
assert coverage["measurable"] is False
fundamental_rows = [r for r in report["comparison"] if r["kind"] == "fundamental"]
assert {r["id"] for r in fundamental_rows} == {
"fundamental_adverse", "confluence", "market_over_covered",
}
assert all(row["measurable"] is False for row in fundamental_rows)
# Coverage-matched denominators: with no exposure these rows must not claim
# to have been scored against the market rows' 10 corrections.
assert all(row["events"] == 0 for row in fundamental_rows)
# Market rows are unaffected: their inputs exist for the whole window.
assert all(
row["measurable"] is True
for row in report["comparison"]
if row["kind"] != "fundamental"
)
def test_fundamental_rows_are_scored_only_on_their_own_exposure():
"""One day of coverage must not render as 0/10.
A fundamental rule scores zero whether it is wrong or merely absent, so
scoring it against corrections it could never have seen manufactures a
failed result out of a thin one — the same mistake the `measurable` flag
prevents for an empty table, arriving one observation later.
"""
import app.services.event_study_service as ess
dates = _days(300)
events = [50, 120, 200, 280]
# Context exists for a single stretch, covering only the 120 event's horizon.
rows = {
day: {
"fundamental_state": "adverse",
"fundamental_usable": 105 <= index <= 115,
}
for index, day in enumerate(dates)
}
covered = ess.covered_events(events, rows, dates, horizon=20)
assert covered == [120]
assert ess.eligible_sessions(rows, dates, start_index=0) == 11
# A stale stretch counts for nothing, however adverse it reads.
stale = {
day: {"fundamental_state": "adverse", "fundamental_usable": False}
for day in dates
}
assert ess.covered_events(events, stale, dates, horizon=20) == []
assert ess.eligible_sessions(stale, dates, start_index=0) == 0
assert ess.adverse_episodes(stale, dates, 0) == []
assert ess.confluence_episodes([120], stale, dates) == []
# And neither does a *fresh* observation that determined nothing. Repeated
# extraction failures would otherwise accumulate exposure until the rows
# flipped to a measurable 0/8 for a channel that never knew anything —
# the same tested-versus-unavailable confusion, arriving by a slower route.
empty = {
day: {"fundamental_state": "unknown", "fundamental_usable": False}
for day in dates
}
assert ess.covered_events(events, empty, dates, horizon=20) == []
assert ess.eligible_sessions(empty, dates, start_index=0) == 0
async def test_the_fundamental_channel_never_moves_the_warning_score():
"""The channel is compared, never fused. Warning must be identical either way.
A weighted modifier was built and reverted: with ~10 correction events and
almost no fundamental history any fusion weight is a policy preference
presented as a measurement.
"""
import app.services.event_study_service as ess
end = date(2026, 6, 26)
dates = _business_days(400, end)
rising = [(day, 100.0 + index * 0.2) for index, day in enumerate(dates)]
prices = {"SMH": rising, "QQQ": rising, "SPY": rising}
args = (prices, [(end, 20.0)], [(day, 4.0) for day in dates])
config = deepcopy(ess.rms.DEFAULT_CONFIG)
names = config["tickers"]["hyperscalers"]
tail = (rising, [(day, 20.0) for day in dates], dates, config)
def adverse(effective: date) -> list[dict]:
return [{
"effective_date": effective,
"f1_score": 100.0,
"f3_score": 100.0,
"capex": dict.fromkeys(names, "cutting"),
"good_news_stock_down": "yes",
"fetched_at": "2026-01-01T00:00:00+00:00",
}]
bare = ess._axis_rows(*args, *tail, None)
observed = ess._axis_rows(*args, *tail, adverse(dates[-20]))
latest, early = dates[-1], dates[-90]
assert observed[latest]["warning"] == bare[latest]["warning"]
assert observed[latest]["fundamental_state"] == "adverse"
assert bare[latest]["fundamental_state"] == "unknown"
# Sessions before the effective date stay unknown, so a rebuild cannot stamp
# today's reading onto history.
assert observed[early]["fundamental_state"] == "unknown"
# The confluence rule keeps only crossings the channel agrees with, and the
# fundamental rule fires on the transition into adverse -- both rising-edge,
# so both stay comparable with the market rows.
adverse_alarms = ess.adverse_episodes(observed, dates, 0)
assert [dates[i] for i in adverse_alarms] == [dates[-20]]
assert ess.adverse_episodes(bare, dates, 0) == []
assert ess.confluence_episodes([dates.index(early), dates.index(latest)], observed, dates) == [
dates.index(latest)
]
def test_breadth_from_fixed_closes_and_tapered_divergence():
dates = _days(10)
closes_by_symbol = {
+287 -31
View File
@@ -28,7 +28,7 @@ from app.services.regime_monitor_service import (
drawdown_pct,
f2_credit_spreads,
current_observation,
fundamental_overlay,
fundamental_context,
p1_trend_break,
p2_death_cross,
p3_drawdown,
@@ -40,6 +40,24 @@ from app.services.regime_monitor_service import (
)
async def _no_observations(_db):
return []
async def _skip_recording(_db, _observation):
return None
class _CommitOnlyDB:
"""Enough session for writers that own their own transaction boundary."""
def __init__(self) -> None:
self.commits = 0
async def commit(self) -> None:
self.commits += 1
def _dated(values: list[float], end: date = date(2026, 6, 26)) -> list[tuple[date, float]]:
return [
(end - timedelta(days=len(values) - 1 - index), value)
@@ -215,7 +233,7 @@ def test_score_pillars_gates_band_below_75_percent_coverage():
assert result["band"] is None
def test_fundamental_overlay_never_replays_before_effective_date_and_expires():
def test_fundamental_context_never_replays_before_effective_date_and_expires():
overrides = {
"f1_score": 0.0,
"f3_score": 100.0,
@@ -226,19 +244,19 @@ def test_fundamental_overlay_never_replays_before_effective_date_and_expires():
}
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
pending = fundamental_overlay(overrides, config, date(2026, 6, 1))
pending = fundamental_context(overrides, config, date(2026, 6, 1))
assert pending["pending"] is True
assert pending["available"] is False
assert pending["capex"] is None
# The effective date is still reported so a pending refresh is visible.
assert pending["effective_date"] == "2026-06-02"
live = fundamental_overlay(overrides, config, date(2026, 6, 2))
live = fundamental_context(overrides, config, date(2026, 6, 2))
assert live["available"] is True
assert live["good_news_stock_down"] == "yes"
assert live["earnings_stress"] == 100.0
expired = fundamental_overlay(overrides, config, date(2026, 8, 22))
expired = fundamental_context(overrides, config, date(2026, 8, 22))
assert expired["stale"] is True
assert expired["available"] is False
@@ -263,7 +281,7 @@ def test_live_observation_is_visible_before_its_effective_date():
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
before = date(2026, 6, 1)
record = fundamental_overlay(overrides, config, before)
record = fundamental_context(overrides, config, before)
now = current_observation(overrides, config, before)
# Same day, same observation: the record hides it, the live reading shows it.
@@ -314,35 +332,256 @@ def test_an_uncollected_observation_is_not_reported_as_collected():
assert current_observation(collected, DEFAULT_CONFIG, date(2026, 8, 7))["observed"] is True
def test_fundamentals_do_not_move_the_warning_score():
"""The v3 complaint: a maxed-out LLM read must not silently do nothing.
def test_fundamental_state_never_averages_unknown_into_neutral():
"""Missing evidence must not present as evidence of normality.
It no longer feeds Warning at all, so Warning is identical either way and
the observation is reported beside the score instead of buried in it.
This is the trap that mattered when the channel replaced the weighted
modifier: treating ``unknown`` as a middle value would let two ``cutting``
reads and two ``unknown`` ones land on "neutral". A single adverse read
carries on partial evidence; ``unknown`` survives only when *nothing* was
observed.
"""
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
assert rms._capex_signal(dict.fromkeys(names, "unknown"), names) == "unknown"
assert rms._capex_signal(dict.fromkeys(names, "raising"), names) == "supportive"
assert rms._capex_signal(dict.fromkeys(names, "holding"), names) == "neutral"
half_cut = {names[0]: "cutting", names[1]: "cutting", **dict.fromkeys(names[2:], "unknown")}
assert rms._capex_signal(half_cut, names) == "adverse"
assert rms._reaction_signal("yes") == "adverse"
assert rms._reaction_signal("no") == "supportive"
assert rms._reaction_signal("mixed") == "neutral"
assert rms._reaction_signal(None) == "unknown"
combine = rms.combine_fundamental_signals
assert combine("unknown", "unknown") == "unknown"
assert combine("adverse", "supportive") == "adverse" # one adverse read carries
assert combine("supportive", "unknown") == "supportive"
assert combine("neutral", "unknown") == "neutral"
assert combine("supportive", "neutral") == "neutral"
# Nothing combines *into* unknown -- that would be inventing missing evidence.
assert "unknown" not in {
combine(a, b)
for a in rms.FUNDAMENTAL_STATES
for b in rms.FUNDAMENTAL_STATES
if not (a == "unknown" and b == "unknown")
}
def test_fundamental_context_is_a_channel_not_a_term_in_warning():
"""The read is reported beside the scores and never added into them.
A weighted modifier was built and reverted: with ~10 correction events and
almost no fundamental history, any fusion weight is a policy preference
presented as a measurement, and adding a slow categorical judgement to a fast
continuous score manufactures precision by summing unlike things.
"""
end = date(2026, 6, 26)
rising = [100.0 + index * 0.2 for index in range(700)]
prices = {"SMH": _dated(rising, end), "QQQ": _dated(rising, end), "SPY": _dated(rising, end)}
args = (prices, [(end, 20.0)], [(end - timedelta(days=i), 4.0) for i in reversed(range(100))])
tail = (copy.deepcopy(DEFAULT_CONFIG), end, [(end, 55.0)], [(end, 20.0)], {end: 25})
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
quiet = _compute_index(*args, {"f1_score": None, "f3_score": None}, *tail)
screaming = _compute_index(
*args,
{
"f1_score": 100.0,
"f3_score": 100.0,
"capex": dict.fromkeys(DEFAULT_CONFIG["tickers"]["hyperscalers"], "cutting"),
"good_news_stock_down": "yes",
def observed(capex_state: str, reaction: str) -> dict:
return {
"capex": dict.fromkeys(names, capex_state),
"good_news_stock_down": reaction,
"effective_date": "2026-06-01",
"fetched_at": "2026-06-01T00:00:00+00:00",
"source": "openai",
}
unobserved = _compute_index(*args, {"f1_score": None, "f3_score": None}, *tail)
supportive = _compute_index(*args, observed("raising", "no"), *tail)
adverse = _compute_index(*args, observed("cutting", "yes"), *tail)
# Every Warning is identical: the channel is not a term in the score.
scores = {
snapshot["warning"]["score"]
for snapshot in (unobserved, supportive, adverse)
}
assert len(scores) == 1
assert {p["id"] for p in unobserved["warning"]["pillars"]} == set(WARNING_WEIGHTS)
# And it never touches coverage, so a missing observation cannot suppress a
# band or silently redistribute weight onto the technical sensors.
assert len({s["warning"]["coverage"] for s in (unobserved, supportive, adverse)}) == 1
assert unobserved["fundamental_context"]["state"] == "unknown"
assert unobserved["fundamental_context"]["evidence_quality"] == "unavailable"
assert supportive["fundamental_context"]["state"] == "supportive"
assert adverse["fundamental_context"]["state"] == "adverse"
assert adverse["fundamental_context"]["evidence_quality"] == "complete"
def test_a_fresh_but_empty_observation_is_available_to_show_and_not_usable():
"""Collected-but-determined-nothing must not count as evidence.
`available` is about timing (there is an effective, non-stale record to
display); `usable` is about content. An LLM run that failed to extract
anything produces a perfectly fresh observation that knows nothing — and if
that counted, repeated extraction failures would slowly accumulate study
exposure until the fundamental rows reported a measurable 0/8 for a channel
that had never seen a thing.
"""
config = copy.deepcopy(DEFAULT_CONFIG)
names = config["tickers"]["hyperscalers"]
as_of = date(2026, 6, 26)
base = {
"effective_date": "2026-06-01",
"fetched_at": "2026-06-01T00:00:00+00:00",
"source": "openai",
}
empty = fundamental_context(
{**base, "capex": dict.fromkeys(names, "unknown"), "good_news_stock_down": "unknown"},
config, as_of,
)
assert empty["state"] == "unknown"
assert empty["available"] is True # there is a record, and it has a date
assert empty["usable"] is False # but it says nothing
# One real signal is enough to be usable, on partial evidence.
partial = fundamental_context(
{
**base,
"capex": {names[0]: "cutting", **dict.fromkeys(names[1:], "unknown")},
"good_news_stock_down": "unknown",
},
*tail,
config, as_of,
)
assert partial["state"] == "adverse"
assert partial["usable"] is True
assert partial["evidence_quality"] == "partial"
# Stale is neither available nor usable — `available` means effective *and*
# non-stale. What survives is `state`, which the card renders on its own
# (with the stale badge) so the last thing observed stays visible.
stale = fundamental_context(
{
**base,
"effective_date": "2026-01-01",
"capex": dict.fromkeys(names, "cutting"),
"good_news_stock_down": "yes",
},
config, as_of,
)
assert stale["state"] == "adverse"
assert stale["stale"] is True
assert stale["available"] is False
assert stale["usable"] is False
# Nothing collected at all: neither.
absent = fundamental_context({}, config, as_of)
assert (absent["available"], absent["usable"]) == (False, False)
def test_the_live_reading_publishes_the_same_fields_as_the_record():
""""Same shape" has to mean the same fields, not the same ones it needs.
The frontend types both payloads as one interface, so a field present on the
record and missing from the live reading is an undefined at runtime that
TypeScript cannot catch across a trusted server boundary.
"""
config = copy.deepcopy(DEFAULT_CONFIG)
names = config["tickers"]["hyperscalers"]
as_of = date(2026, 6, 26)
observation = {
"effective_date": "2026-06-01",
"fetched_at": "2026-06-01T00:00:00+00:00",
"source": "openai",
"capex": dict.fromkeys(names, "cutting"),
"good_news_stock_down": "yes",
}
record = fundamental_context(observation, config, as_of)
live = current_observation(observation, config, as_of)
assert set(record) <= set(live)
assert (live["state"], live["usable"]) == ("adverse", True)
# A just-collected observation is shown but is not yet in force, so it is
# available to read and not yet usable as evidence.
pending = current_observation(
{**observation, "effective_date": "2026-07-01"}, config, as_of
)
assert (pending["pending"], pending["available"], pending["usable"]) == (True, True, False)
# And an extraction that determined nothing is never usable, however fresh.
empty = current_observation(
{**observation, "capex": dict.fromkeys(names, "unknown"), "good_news_stock_down": "unknown"},
config, as_of,
)
assert (empty["state"], empty["usable"]) == ("unknown", False)
def test_pre_rename_snapshots_keep_their_recorded_fundamental_evidence():
"""The rename shipped without a methodology bump, so those rows were never reseeded.
Reading only the new key would turn real observations into `unknown` and
silently drop historical Path colours and legitimate study exposure.
"""
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
legacy = {
"methodology": rms.METHODOLOGY,
"date": "2026-07-01",
"state": {"score": 10.0, "band": "stable"},
"warning": {"score": 20.0, "band": "stable"},
"fundamental_overlay": {
"available": True,
"pending": False,
"stale": False,
"effective_date": "2026-06-20",
"capex": {names[0]: "cutting", **dict.fromkeys(names[1:], "raising")},
"good_news_stock_down": "yes",
"source": "openai",
"fetched_at": "2026-06-19T00:00:00+00:00",
},
}
parsed = rms._parse_snapshot(json.dumps(legacy))
context = parsed["fundamental_context"]
assert context["state"] == "adverse"
assert context["evidence_quality"] == "complete"
assert context["usable"] is True
assert context["effective_date"] == "2026-06-20"
# A pending legacy overlay carried no facts, so it stays unknown rather than
# inventing an observation for a session nobody had looked at.
blank = json.loads(json.dumps(legacy))
blank["fundamental_overlay"] = {"pending": True, "stale": False, "capex": None}
blank_context = rms._parse_snapshot(json.dumps(blank))["fundamental_context"]
assert blank_context["state"] == "unknown"
assert blank_context["evidence_quality"] == "unavailable"
assert blank_context["usable"] is False
# A row already carrying the new key is left exactly as written.
modern = json.loads(json.dumps(legacy))
modern["fundamental_context"] = {"state": "supportive", "usable": True}
assert rms._parse_snapshot(json.dumps(modern))["fundamental_context"]["state"] == "supportive"
def test_evidence_quality_ranks_what_an_operator_needs_first():
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
config = copy.deepcopy(DEFAULT_CONFIG)
full = dict.fromkeys(names, "raising")
partial = {names[0]: "raising", **dict.fromkeys(names[1:], "unknown")}
def quality(capex, reaction, *, observed=True, stale=False, source="openai"):
return rms._evidence_quality(
capex, reaction, names, observed=observed, stale=stale, source=source
)
assert quiet["warning"]["score"] == screaming["warning"]["score"]
assert {p["id"] for p in quiet["warning"]["pillars"]} == set(WARNING_WEIGHTS)
assert screaming["fundamental_overlay"]["available"] is True
assert screaming["fundamental_overlay"]["capex_stress"] == 100.0
assert quality(full, "no") == "complete"
assert quality(partial, "no") == "partial"
assert quality(full, None) == "partial" # reaction unknown
assert quality(full, "no", source="manual") == "manual"
assert quality(full, "no", stale=True) == "stale"
# Nothing collected outranks every other grade.
assert quality(full, "no", observed=False, stale=True, source="manual") == "unavailable"
assert set(rms.EVIDENCE_QUALITY) >= {quality(full, "no"), quality(partial, "no")}
assert config["tickers"]["hyperscalers"] == names
def test_capex_score_separates_holding_from_raising():
@@ -375,10 +614,12 @@ async def test_legacy_numeric_fundamentals_do_not_leak_into_v4(monkeypatch):
result = await rms.get_fundamental_overrides(object())
assert result["methodology"] == "v4"
assert result["methodology"] == rms.METHODOLOGY
assert result["f1_score"] is None
assert result["f3_score"] is None
assert result["good_news_stock_down"] == "mixed"
# Not "mixed": an unreadable blob is an absence of an observation, and
# "mixed" is a genuinely observed mixed reaction.
assert result["good_news_stock_down"] == "unknown"
@pytest.mark.asyncio
@@ -442,11 +683,12 @@ async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch):
async def fake_update(_db, _key, value):
saved.update(json.loads(value))
return None
monkeypatch.setattr(rms, "get_fundamental_overrides", fake_get)
monkeypatch.setattr(rms, "update_setting", fake_update)
monkeypatch.setattr(rms.settings_store, "upsert_setting", fake_update)
result = await rms.set_fundamental_overrides(object(), locked=False)
result = await rms.set_fundamental_overrides(_CommitOnlyDB(), locked=False)
assert result["locked"] is False
assert result["fetched_at"] == stored["fetched_at"]
@@ -476,14 +718,22 @@ async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch):
async def fake_update(_db, _key, value):
saved.update(json.loads(value))
return None
monkeypatch.setattr(rms, "get_fundamental_overrides", fake_get)
monkeypatch.setattr(rms, "update_setting", fake_update)
monkeypatch.setattr(rms.settings_store, "upsert_setting", fake_update)
# A manual save now also appends to the point-in-time series.
monkeypatch.setattr(rms, "record_fundamental_observation", _skip_recording)
capex = {names[0]: "cutting", **dict.fromkeys(names[1:], "holding")}
db = _CommitOnlyDB()
result = await rms.set_fundamental_overrides(
object(), capex=capex, good_news_stock_down="mixed"
db, capex=capex, good_news_stock_down="mixed"
)
# The series row is a second write after update_setting's own commit, so the
# writer has to take one -- record_fundamental_observation deliberately does
# not, or it would steal update_regime_monitor's transaction boundary.
assert db.commits == 1
assert result["f1_score"] == 62.5 # one cutting (100) + three holding (50)
assert result["f3_score"] is None
@@ -498,7 +748,9 @@ async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch):
async def test_prior_snapshot_is_immutable_without_explicit_rebuild(db_session):
snapshot_date = date(2026, 6, 26)
first = {
"methodology": "v4",
# Must be the *current* methodology: a foreign row does not parse, so it
# reads as absent and the rewrite guard never comes into play.
"methodology": rms.METHODOLOGY,
"date": snapshot_date.isoformat(),
"state": {"score": 10.0, "band": "stable"},
"warning": {"score": 20.0, "band": "stable"},
@@ -571,6 +823,8 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
monkeypatch.setattr(rms.breadth_service, "compute_breadth_details", fake_breadth)
monkeypatch.setattr(rms, "_latest_snapshot_row", fake_latest)
monkeypatch.setattr(rms, "_upsert_snapshot", fake_upsert)
monkeypatch.setattr(rms, "get_fundamental_observations", _no_observations)
monkeypatch.setattr(rms, "record_fundamental_observation", _skip_recording)
result = await rms.update_regime_monitor(FakeDB())
@@ -636,6 +890,8 @@ async def test_a_stale_sensor_revision_reseeds_stored_history(
("_fetch_fred_series", fake_fred),
("_latest_snapshot_row", fake_latest),
("_upsert_snapshot", fake_upsert),
("get_fundamental_observations", _no_observations),
("record_fundamental_observation", _skip_recording),
):
monkeypatch.setattr(rms, name, value)
monkeypatch.setattr(rms.breadth_service, "compute_breadth_details", fake_breadth)
@@ -722,7 +978,7 @@ def test_compute_index_uses_one_max_price_vote_and_has_no_combined_score():
price = next(p for p in result["state"]["pillars"] if p["id"] == "price")
sensor_scores = [sensor["score"] for sensor in price["sensors"] if sensor["score"] is not None]
assert price["score"] == max(sensor_scores)
assert result["methodology"] == "v4"
assert result["methodology"] == rms.METHODOLOGY
assert "combined" not in result
assert result["basket"]["members_available"] == 25
+143
View File
@@ -5,10 +5,16 @@ different realized ranges -- Warning never exceeded 64.9 in the 408 calibration
sessions, so a shared 60 left the whole upper half of that axis unreachable.
"""
import pytest
from app.services import alert_service
from app.services.alert_service import (
CONFLUENCE_TYPE,
FUND_TYPE,
QUAD_X_DIV,
QUAD_Y_DIV,
_classify_quadrant,
_collect_regime_fundamental,
_parse_quadrant_log_key,
_quadrant_log_key,
)
@@ -48,3 +54,140 @@ def test_quadrant_key_carries_basket_hash_and_parses_legacy_keys():
assert _parse_quadrant_log_key(key) == ("abc123", "3", 32.4, 54.6)
assert _parse_quadrant_log_key("3:32.4:54.6") == (None, "3", 32.4, 54.6)
assert _parse_quadrant_log_key("3") == (None, "3", None, None)
# ---------------------------------------------------------------------------
# Fundamental-context and confluence alerts
# ---------------------------------------------------------------------------
def _monitor(
warning_score: float, state: str, *, coverage: float = 100.0, usable: bool = True
) -> dict:
return {
"available": True,
"warning": {"score": warning_score, "coverage": coverage},
"fundamental_context": {
"state": state,
"evidence_quality": "complete" if usable else "stale",
# The state survives going stale so the card can still show it, and
# a failed extraction is fresh but knows nothing; `usable` is what
# says whether it may still confirm anything.
"available": usable,
"usable": usable,
},
"data_quality": {"is_fresh": True},
"quadrant_config": {"warning_divider": QUAD_Y_DIV},
}
class _LogSpyDB:
"""Records what would be logged; returns a canned "last logged key"."""
def __init__(self, last: dict[str, str | None]) -> None:
self.last = last
self.logged: list[tuple[str, str]] = []
@pytest.fixture
def patched(monkeypatch):
def apply(data: dict, last: dict[str, str | None]):
db = _LogSpyDB(last)
async def fake_monitor(_db):
return data
async def fake_last(_db, alert_type):
return db.last.get(alert_type)
def fake_log(_db, alert_type, key, value=None):
db.logged.append((alert_type, key))
import app.services.regime_monitor_service as rms
monkeypatch.setattr(rms, "get_regime_monitor", fake_monitor)
monkeypatch.setattr(alert_service, "_last_logged_key", fake_last)
monkeypatch.setattr(alert_service, "_log_alert", fake_log)
return db
return apply
@pytest.mark.asyncio
async def test_first_run_seeds_both_channels_without_alerting(patched):
db = patched(_monitor(60.0, "adverse"), {FUND_TYPE: None, CONFLUENCE_TYPE: None})
assert await _collect_regime_fundamental(db) == []
assert dict(db.logged) == {FUND_TYPE: "adverse", CONFLUENCE_TYPE: "yes"}
@pytest.mark.asyncio
async def test_fundamental_change_and_confluence_are_separate_messages(patched):
db = patched(_monitor(60.0, "adverse"), {FUND_TYPE: "neutral", CONFLUENCE_TYPE: "no"})
out = await _collect_regime_fundamental(db)
assert [alert_type for alert_type, _, _ in out] == [FUND_TYPE, CONFLUENCE_TYPE]
assert "neutral → adverse" in out[0][2]
assert "Confluence" in out[1][2]
# Neither message reports a fused score; they name which channel moved.
assert "not a score" in out[0][2]
@pytest.mark.asyncio
async def test_unknown_never_alerts(patched):
"""Absence of evidence is not a change in the evidence."""
db = patched(_monitor(60.0, "unknown"), {FUND_TYPE: "neutral", CONFLUENCE_TYPE: "no"})
assert await _collect_regime_fundamental(db) == []
@pytest.mark.asyncio
async def test_adverse_alone_is_not_confluence(patched):
"""A calm tape with adverse fundamentals is a context change, not confluence."""
db = patched(_monitor(10.0, "adverse"), {FUND_TYPE: "neutral", CONFLUENCE_TYPE: "no"})
out = await _collect_regime_fundamental(db)
assert [alert_type for alert_type, _, _ in out] == [FUND_TYPE]
@pytest.mark.asyncio
async def test_leaving_confluence_rebaselines_quietly(patched):
db = patched(_monitor(10.0, "neutral"), {FUND_TYPE: "neutral", CONFLUENCE_TYPE: "yes"})
assert await _collect_regime_fundamental(db) == []
assert (CONFLUENCE_TYPE, "no") in db.logged
@pytest.mark.asyncio
async def test_low_coverage_or_stale_inputs_stay_quiet(patched):
thin = _monitor(60.0, "adverse", coverage=50.0)
assert await _collect_regime_fundamental(
patched(thin, {FUND_TYPE: "neutral", CONFLUENCE_TYPE: "no"})
) == []
stale = _monitor(60.0, "adverse")
stale["data_quality"]["is_fresh"] = False
assert await _collect_regime_fundamental(
patched(stale, {FUND_TYPE: "neutral", CONFLUENCE_TYPE: "no"})
) == []
@pytest.mark.asyncio
async def test_a_stale_observation_cannot_confirm_a_new_crossing(patched):
"""The state is kept for display, but it stops being evidence.
Without this, one adverse read corroborates every Warning crossing for the
rest of time the strongest claim the channel makes, from the data with the
least right to make it.
"""
stale = _monitor(60.0, "adverse", usable=False)
db = patched(stale, {FUND_TYPE: "adverse", CONFLUENCE_TYPE: "no"})
assert await _collect_regime_fundamental(db) == []
# It also rebaselines to "no", so recollecting the observation re-arms it.
assert (CONFLUENCE_TYPE, "no") not in db.logged # already "no"; nothing to log
fresh = _monitor(60.0, "adverse", usable=True)
db2 = patched(fresh, {FUND_TYPE: "adverse", CONFLUENCE_TYPE: "no"})
out = await _collect_regime_fundamental(db2)
assert [alert_type for alert_type, _, _ in out] == [CONFLUENCE_TYPE]
@pytest.mark.asyncio
async def test_a_stale_state_change_does_not_alert(patched):
db = patched(_monitor(10.0, "adverse", usable=False), {FUND_TYPE: "neutral", CONFLUENCE_TYPE: "no"})
assert await _collect_regime_fundamental(db) == []