feat(risk-monitor): measure the rule that fires, and give fundamentals their own channel
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:
@@ -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")
|
||||||
@@ -14,6 +14,7 @@ from app.models.settings import SystemSetting, IngestionProgress
|
|||||||
from app.models.alert import AlertLog
|
from app.models.alert import AlertLog
|
||||||
from app.models.paper_trade import PaperTrade
|
from app.models.paper_trade import PaperTrade
|
||||||
from app.models.regime_snapshot import RegimeSnapshot
|
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.benchmark_price import BenchmarkPrice
|
||||||
from app.models.signal_context_snapshot import SignalContextSnapshot
|
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||||
from app.models.system_event import SystemEvent
|
from app.models.system_event import SystemEvent
|
||||||
@@ -39,6 +40,7 @@ __all__ = [
|
|||||||
"AlertLog",
|
"AlertLog",
|
||||||
"PaperTrade",
|
"PaperTrade",
|
||||||
"RegimeSnapshot",
|
"RegimeSnapshot",
|
||||||
|
"RegimeFundamentalObservation",
|
||||||
"BenchmarkPrice",
|
"BenchmarkPrice",
|
||||||
"SignalContextSnapshot",
|
"SignalContextSnapshot",
|
||||||
"SystemEvent",
|
"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
@@ -1351,8 +1351,11 @@ async def run_event_study_job() -> None:
|
|||||||
report = await run_event_study_and_store(db)
|
report = await run_event_study_and_store(db)
|
||||||
|
|
||||||
_runtime_progress(job_name, processed=1, total=1)
|
_runtime_progress(job_name, processed=1, total=1)
|
||||||
|
shipped = report.get("shipped") or {}
|
||||||
if report.get("available"):
|
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 = (
|
msg = (
|
||||||
f"{metrics.get('events_warned', 0)}/{metrics.get('events', 0)} warned, "
|
f"{metrics.get('events_warned', 0)}/{metrics.get('events', 0)} warned, "
|
||||||
f"{metrics.get('false_alarms_per_year', 0)} false alarms/year"
|
f"{metrics.get('false_alarms_per_year', 0)} false alarms/year"
|
||||||
@@ -1360,7 +1363,10 @@ async def run_event_study_job() -> None:
|
|||||||
else:
|
else:
|
||||||
msg = report.get("reason", "no data")
|
msg = report.get("reason", "no data")
|
||||||
_runtime_finish(job_name, "completed", processed=1, total=1, message=msg)
|
_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:
|
except Exception as exc:
|
||||||
_runtime_finish(job_name, "error", processed=0, total=1, message=str(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))
|
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
|
||||||
|
|||||||
@@ -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
|
# 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.
|
# from flip-flopping; the cooldown caps how often a genuine change can re-alert.
|
||||||
QUAD_TYPE = "regime_quadrant"
|
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_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_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
|
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:
|
else:
|
||||||
metrics = f"State {x:.0f} · Warning {y:.0f}"
|
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 = (
|
text = (
|
||||||
f"🧭 <b>AI/Tech risk quadrant change</b>\n"
|
f"🧭 <b>AI/Tech risk quadrant change</b>\n"
|
||||||
f"{QUAD_LABELS.get(prev, prev)} → {QUAD_LABELS.get(new_q, new_q)}\n"
|
f"{QUAD_LABELS.get(prev, prev)} → {QUAD_LABELS.get(new_q, new_q)}\n"
|
||||||
f"{metrics}\n"
|
f"{metrics}\n"
|
||||||
|
f"{context_line}"
|
||||||
f"coverage: state {state.get('coverage'):.0f}% / warning {warning.get('coverage'):.0f}%\n"
|
f"coverage: state {state.get('coverage'):.0f}% / warning {warning.get('coverage'):.0f}%\n"
|
||||||
f"<i>Risk thermometer - not a trade signal.</i>"
|
f"<i>Risk thermometer - not a trade signal.</i>"
|
||||||
)
|
)
|
||||||
return [(_quadrant_log_key(new_q, x, y, basket_hash), text)]
|
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
|
# Dispatch
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -961,6 +1064,11 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
|
|||||||
# cooldown/hysteresis handled in the collector (like score drops)
|
# cooldown/hysteresis handled in the collector (like score drops)
|
||||||
for key, text in await _collect_regime_quadrant(db):
|
for key, text in await _collect_regime_quadrant(db):
|
||||||
outgoing.append((QUAD_TYPE, key, text))
|
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"]:
|
if cfg["trade_closed"]:
|
||||||
for key, text, pnl_usd in await _collect_closed_trades(db):
|
for key, text, pnl_usd in await _collect_closed_trades(db):
|
||||||
|
|||||||
@@ -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
|
The outcome is a 10% correction in the leader, never a regime break. Two rules
|
||||||
freeze an 80th-percentile warning threshold, and reports alarm episodes only on
|
are measured against it, and they answer different questions:
|
||||||
the final 30%. It is still labelled exploratory while the fixed breadth basket
|
|
||||||
is reconstructed before its freeze date.
|
* **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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 breadth_service, settings_store
|
||||||
from app.services import regime_monitor_service as rms
|
from app.services import regime_monitor_service as rms
|
||||||
from app.services.admin_service import update_setting
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
KEY_REPORT = "regime_event_study"
|
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_THRESHOLD_PCT = 10.0
|
||||||
EVENT_COOLDOWN_DAYS = 40
|
EVENT_COOLDOWN_DAYS = 40
|
||||||
DRAWDOWN_LOOKBACK = 252
|
DRAWDOWN_LOOKBACK = 252
|
||||||
@@ -33,6 +81,21 @@ TRAIN_FRACTION = 0.70
|
|||||||
MIN_EVENTS_FOR_CONFIDENCE = 8
|
MIN_EVENTS_FOR_CONFIDENCE = 8
|
||||||
SENSOR_MISMATCH_TOLERANCE = 0.10
|
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:
|
def _median(values: list[float]) -> float | None:
|
||||||
if not values:
|
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],
|
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],
|
dates: list[date],
|
||||||
config: dict,
|
config: dict,
|
||||||
oas_series: rms.Series | None = None,
|
observations: list[dict] | None = None,
|
||||||
) -> tuple[dict[date, float], dict[date, int]]:
|
) -> dict[date, dict]:
|
||||||
"""Warning score per session plus how many sensors backed it.
|
"""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
|
Calling ``_compute_index`` rather than re-deriving the two axes is the same
|
||||||
measuring the old construct after a scoring change. Since v3 dropped
|
anti-drift argument that produced ``warning_sensor_scores``: the v2 study
|
||||||
fundamentals from the score, this is now exactly the live Warning score
|
re-derived Warning by hand and would have kept measuring the old construct
|
||||||
rather than a technical-only approximation of it.
|
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
|
``observations`` is the point-in-time fundamental series. It does not enter
|
||||||
available: a session backed by two sensors is not drawn from the same
|
either score -- the fundamental channel is categorical and read by confluence
|
||||||
distribution as one backed by three, and the frozen threshold assumes it is.
|
-- 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"]
|
rows: dict[date, dict] = {}
|
||||||
smh_full = prices.get(tickers["leaders"][0], [])
|
|
||||||
spy_full = prices.get(tickers["market"], [])
|
|
||||||
out: dict[date, float] = {}
|
|
||||||
backing: dict[date, int] = {}
|
|
||||||
for session in dates:
|
for session in dates:
|
||||||
sensors = rms.warning_sensor_scores(
|
snapshot = rms._compute_index(
|
||||||
breadth_divergence.get(session),
|
prices,
|
||||||
rms._closes_asof(smh_full, session),
|
vix_series,
|
||||||
rms._closes_asof(spy_full, session),
|
oas_series,
|
||||||
rms._window_asof(oas_series, session, rms.HY_OAS_WINDOW_DAYS),
|
{},
|
||||||
|
config,
|
||||||
|
session,
|
||||||
|
breadth_series=breadth_series,
|
||||||
|
divergence_series=divergence_series,
|
||||||
|
observations=observations or [],
|
||||||
)
|
)
|
||||||
score = rms.score_warning_sensors(sensors)
|
state = snapshot["state"]
|
||||||
if score is not None:
|
warning = snapshot["warning"]
|
||||||
out[session] = round(score, 2)
|
rows[session] = {
|
||||||
backing[session] = sum(1 for value in sensors.values() if value is not None)
|
"state": state.get("score"),
|
||||||
return out, backing
|
"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(
|
def _reliability(
|
||||||
@@ -192,9 +569,9 @@ def _reliability(
|
|||||||
events_detected: int,
|
events_detected: int,
|
||||||
events_in_holdout: int,
|
events_in_holdout: int,
|
||||||
) -> dict:
|
) -> 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
|
* 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
|
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
|
* The score renormalises over available sensors, so a training window that
|
||||||
predates a sensor's history freezes a threshold on a different construct
|
predates a sensor's history freezes a threshold on a different construct
|
||||||
than the holdout is measured against.
|
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)
|
expected = len(rms.WARNING_WEIGHTS)
|
||||||
train = [backing[d] for d in dates[:split] if d in backing]
|
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(
|
async def run_event_study(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
threshold_pct: float = EVENT_THRESHOLD_PCT,
|
threshold_pct: float = EVENT_THRESHOLD_PCT,
|
||||||
@@ -242,55 +742,202 @@ async def run_event_study(
|
|||||||
)
|
)
|
||||||
divergence = breadth_service.compute_divergence_series(breadth, benchmark)
|
divergence = breadth_service.compute_divergence_series(breadth, benchmark)
|
||||||
oas_series = await rms._fetch_fred_series("BAMLH0A0HYM2", start, end)
|
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
|
# 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
|
# 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
|
# scores on W1+W2 alone via renormalisation. Report where W3 starts rather
|
||||||
# than letting the threshold quietly straddle two sensor sets.
|
# 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)))
|
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]
|
train_values = [warning[d] for d in dates[:split] if d in warning]
|
||||||
warn_threshold = _percentile(train_values, WARN_PERCENTILE)
|
warn_threshold = _percentile(train_values, WARN_PERCENTILE)
|
||||||
if warn_threshold is None:
|
if warn_threshold is None:
|
||||||
return {"available": False, "reason": "insufficient warning history"}
|
return {"available": False, "reason": "insufficient warning history"}
|
||||||
|
holdout_events = [index for index in all_event_indices if index >= split]
|
||||||
all_events = detect_events(closes, dates, threshold_pct)
|
fitted_alarms = alarm_episodes(warning, dates, warn_threshold, start_index=split)
|
||||||
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_sessions = max(1, len(dates) - split)
|
holdout_sessions = max(1, len(dates) - split)
|
||||||
metrics["false_alarms_per_year"] = round(
|
fitted_metrics = _score_rule(
|
||||||
metrics["false_alarms"] / (holdout_sessions / 252.0), 2
|
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))
|
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"])
|
basket_asof = date.fromisoformat(config["basket_asof"])
|
||||||
retrospective = dates[split] < basket_asof
|
retrospective = dates[evaluable_start] < basket_asof
|
||||||
evaluation = "exploratory" if retrospective else "holdout"
|
evaluation = "exploratory" if retrospective else "holdout"
|
||||||
lead_text = (
|
lead_text = (
|
||||||
f"median lead {metrics['median_lead_days']:.0f} sessions"
|
f"median lead {shipped_metrics['median_lead_days']:.0f} sessions"
|
||||||
if metrics["median_lead_days"] is not None
|
if shipped_metrics["median_lead_days"] is not None
|
||||||
else "no successful warning lead"
|
else "no successful warning lead"
|
||||||
)
|
)
|
||||||
summary = (
|
summary = (
|
||||||
f"{evaluation.capitalize()} chronological test: warning episodes preceded "
|
f"{evaluation.capitalize()} replay of the shipped quadrant alert over "
|
||||||
f"{metrics['events_warned']}/{metrics['events']} 10% corrections; "
|
f"{evaluable_sessions} sessions: it entered Warning-high territory ahead of "
|
||||||
f"{metrics['events_missed']} missed, {metrics['false_alarms_per_year']:.1f} "
|
f"{shipped_metrics['events_warned']} of {shipped_metrics['events']} 10% "
|
||||||
f"false alarms/year, {lead_text}. "
|
f"corrections, with {shipped_metrics['false_alarms_per_year']:.1f} false "
|
||||||
f"{metrics['events']} of {reliability['events_detected']} detected corrections "
|
f"alarms/year and {lead_text}. Its dividers are fixed constants rather than "
|
||||||
f"fall in the test period"
|
f"fitted, so there is no training split and every detected correction is "
|
||||||
+ (
|
f"evaluable — compare it against the ablations and baselines below before "
|
||||||
"; too few to read recall as a property of the score."
|
f"reading the ratio as good or bad."
|
||||||
if reliability["underpowered"]
|
|
||||||
else "."
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
per_event = metrics.pop("per_event")
|
|
||||||
|
|
||||||
report = {
|
report = {
|
||||||
"available": True,
|
"available": True,
|
||||||
|
"schema": STUDY_SCHEMA,
|
||||||
"methodology": rms.METHODOLOGY,
|
"methodology": rms.METHODOLOGY,
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"evaluation": evaluation,
|
"evaluation": evaluation,
|
||||||
@@ -301,24 +948,69 @@ async def run_event_study(
|
|||||||
"event_threshold_pct": threshold_pct,
|
"event_threshold_pct": threshold_pct,
|
||||||
"event_cooldown_days": EVENT_COOLDOWN_DAYS,
|
"event_cooldown_days": EVENT_COOLDOWN_DAYS,
|
||||||
"horizon_days": horizon,
|
"horizon_days": horizon,
|
||||||
"train_fraction": TRAIN_FRACTION,
|
"credit_sensor_from": credit_from.isoformat() if credit_from else None,
|
||||||
"warn_percentile": WARN_PERCENTILE,
|
|
||||||
"warn_threshold": round(warn_threshold, 1),
|
|
||||||
"credit_sensor_from": credit_from,
|
|
||||||
"basket_hash": rms._basket_hash(config["breadth_basket"]),
|
"basket_hash": rms._basket_hash(config["breadth_basket"]),
|
||||||
"basket_asof": config["basket_asof"],
|
"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": {
|
"sample": {
|
||||||
"start": dates[0].isoformat(),
|
"start": dates[0].isoformat(),
|
||||||
"end": dates[-1].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(),
|
"train_end": dates[split - 1].isoformat(),
|
||||||
"test_start": dates[split].isoformat(),
|
"test_start": dates[split].isoformat(),
|
||||||
"sessions": len(dates),
|
|
||||||
"holdout_sessions": holdout_sessions,
|
"holdout_sessions": holdout_sessions,
|
||||||
},
|
},
|
||||||
"metrics": metrics,
|
"metrics": fitted_metrics,
|
||||||
|
"events": fitted_events,
|
||||||
|
},
|
||||||
"reliability": reliability,
|
"reliability": reliability,
|
||||||
"events": per_event,
|
|
||||||
"recent_breadth": [
|
"recent_breadth": [
|
||||||
{"date": d.isoformat(), "breadth": breadth[d], "warning": warning.get(d)}
|
{"date": d.isoformat(), "breadth": breadth[d], "warning": warning.get(d)}
|
||||||
for d in dates[-90:]
|
for d in dates[-90:]
|
||||||
@@ -328,10 +1020,13 @@ async def run_event_study(
|
|||||||
logger.info(json.dumps({
|
logger.info(json.dumps({
|
||||||
"event": "regime_event_study_complete",
|
"event": "regime_event_study_complete",
|
||||||
"evaluation": evaluation,
|
"evaluation": evaluation,
|
||||||
"events": metrics["events"],
|
"shipped_events": shipped_metrics["events"],
|
||||||
"events_detected": reliability["events_detected"],
|
"shipped_warned": shipped_metrics["events_warned"],
|
||||||
"warned": metrics["events_warned"],
|
"shipped_false_alarms_per_year": shipped_metrics["false_alarms_per_year"],
|
||||||
"false_alarms_per_year": 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"],
|
"underpowered": reliability["underpowered"],
|
||||||
"sensor_coverage_mismatch": reliability["sensor_coverage_mismatch"],
|
"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)
|
report = json.loads(setting.value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
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
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ two deliberately separate outputs:
|
|||||||
* Warning: deterioration/divergence that may precede State (breadth divergence,
|
* Warning: deterioration/divergence that may precede State (breadth divergence,
|
||||||
relative strength, credit impulse).
|
relative strength, credit impulse).
|
||||||
|
|
||||||
Both scores are quantitative and daily. The sourced hyperscaler capex and
|
* Fundamental context: a categorical channel (supportive / neutral / adverse /
|
||||||
earnings-reaction observations are a qualitative *overlay* since v3 rather than
|
unknown) with an evidence-quality grade, derived by fixed rules from the
|
||||||
weighted sensors: at a combined 20 points they could not reach the event
|
sourced hyperscaler capex and earnings-reaction observations.
|
||||||
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.
|
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
|
Daily snapshots are the point-in-time record. The first run under a new
|
||||||
``METHODOLOGY`` rewrites every session inside ``REBUILD_LOOKBACK_DAYS`` once;
|
``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.config import settings
|
||||||
from app.exceptions import ProviderError, ValidationError
|
from app.exceptions import ProviderError, ValidationError
|
||||||
|
from app.models.regime_fundamental_observation import RegimeFundamentalObservation
|
||||||
from app.models.regime_snapshot import RegimeSnapshot
|
from app.models.regime_snapshot import RegimeSnapshot
|
||||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||||
from app.services import breadth_service, settings_store
|
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
|
# 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
|
# 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.
|
# 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
|
# Bumped when a fix changes what historical rows *should* contain without
|
||||||
# changing the live formula, so stored history needs one reseed. Deliberately
|
# changing the live formula, so stored history needs one reseed. Deliberately
|
||||||
@@ -173,6 +184,34 @@ WARNING_WEIGHTS = {
|
|||||||
"credit_impulse": 25.0,
|
"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,
|
# Fixed at the v2 launch. These are liquid S&P 500/Nasdaq AI, semiconductor,
|
||||||
# infrastructure, cloud, and enterprise-software names that the platform's
|
# infrastructure, cloud, and enterprise-software names that the platform's
|
||||||
# normal universe sync already stores.
|
# normal universe sync already stores.
|
||||||
@@ -196,7 +235,12 @@ DEFAULT_CONFIG: dict = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CAPEX_STATES = ("raising", "holding", "cutting", "unknown")
|
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
|
# 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
|
# 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.
|
# 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)
|
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:
|
def _sensor(sensor_id: str, label: str, score: float | None, **details: object) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": sensor_id,
|
"id": sensor_id,
|
||||||
@@ -572,26 +714,66 @@ def _overlay_timing(
|
|||||||
return effective, pending, age, stale
|
return effective, pending, age, stale
|
||||||
|
|
||||||
|
|
||||||
def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
|
def fundamental_context(overrides: dict, config: dict, as_of: date) -> dict:
|
||||||
"""Point-in-time qualitative overlay. Never feeds State or Warning since v3.
|
"""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
|
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
|
rebuild replays historical dates, and stamping today's read onto 2024
|
||||||
onto 2024 snapshots would be plain lookahead in the stored record.
|
snapshots would be plain lookahead in the stored record.
|
||||||
|
|
||||||
This is the *record*. For "what do we know right now", use
|
This is the *record*. For "what do we know right now", use
|
||||||
``current_observation`` -- do not add a bypass flag here, because this runs
|
``current_observation`` -- do not add a bypass flag here, because this runs
|
||||||
for every replayed date during a rebuild.
|
for every replayed date during a rebuild.
|
||||||
"""
|
"""
|
||||||
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
|
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 {
|
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,
|
"available": not pending and not stale,
|
||||||
|
"usable": _usable_context(observed, pending, stale, state),
|
||||||
"pending": pending,
|
"pending": pending,
|
||||||
"stale": stale,
|
"stale": stale,
|
||||||
"effective_date": effective.isoformat() if effective else None,
|
"effective_date": effective.isoformat() if effective else None,
|
||||||
"age_days": age,
|
"age_days": age,
|
||||||
"capex": None if pending else overrides.get("capex"),
|
"capex": capex,
|
||||||
"good_news_stock_down": None if pending else overrides.get("good_news_stock_down"),
|
"good_news_stock_down": reaction,
|
||||||
"capex_stress": None if pending else overrides.get("f1_score"),
|
"capex_stress": None if pending else overrides.get("f1_score"),
|
||||||
"earnings_stress": None if pending else overrides.get("f3_score"),
|
"earnings_stress": None if pending else overrides.get("f3_score"),
|
||||||
"reasoning": None if pending else overrides.get("reasoning"),
|
"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:
|
def current_observation(overrides: dict, config: dict, as_of: date) -> dict:
|
||||||
"""The observation as it stands now, for the live reading only.
|
"""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
|
rather than used to blank the content. A refresh stamps
|
||||||
``_next_weekday(today)``, so gating the live card hid a just-collected read
|
``_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
|
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.
|
published number; the stored snapshot keeps the gate.
|
||||||
"""
|
"""
|
||||||
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
|
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
|
# hyperscaler. Those are the absence of an observation, not an observation
|
||||||
# of absence, and must never be presented as collected. ``fetched_at`` is
|
# 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
|
# the collection timestamp and is the only field written on every path that
|
||||||
# produces real content (LLM refresh and manual save both stamp it).
|
# produces real content (LLM refresh and manual save both stamp it).
|
||||||
observed = bool(overrides.get("fetched_at"))
|
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 {
|
return {
|
||||||
"observed": observed,
|
"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
|
# Live availability is about usefulness, not effectiveness: a pending
|
||||||
# observation is the freshest thing we have -- but nothing collected is
|
# observation is the freshest thing we have -- but nothing collected is
|
||||||
# never available.
|
# never available.
|
||||||
@@ -656,8 +860,16 @@ def _compute_index(
|
|||||||
breadth_series: Series | None = None,
|
breadth_series: Series | None = None,
|
||||||
divergence_series: Series | None = None,
|
divergence_series: Series | None = None,
|
||||||
breadth_counts: dict[date, int] | None = None,
|
breadth_counts: dict[date, int] | None = None,
|
||||||
|
observations: list[dict] | None = None,
|
||||||
) -> dict:
|
) -> 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"]
|
tickers = config["tickers"]
|
||||||
smh = _closes_asof(prices.get(tickers["leaders"][0], []), as_of)
|
smh = _closes_asof(prices.get(tickers["leaders"][0], []), as_of)
|
||||||
qqq = _closes_asof(prices.get(tickers["confirm"][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)
|
sensors = warning_sensor_scores(divergence, smh, spy, oas_window)
|
||||||
relative_strength = sensors["relative_strength"]
|
relative_strength = sensors["relative_strength"]
|
||||||
credit_impulse = sensors["credit_impulse"]
|
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 = [
|
state_pillars = [
|
||||||
{
|
{
|
||||||
@@ -768,7 +983,7 @@ def _compute_index(
|
|||||||
"date": as_of.isoformat(),
|
"date": as_of.isoformat(),
|
||||||
"state": state,
|
"state": state,
|
||||||
"warning": warning,
|
"warning": warning,
|
||||||
"fundamental_overlay": overlay,
|
"fundamental_context": context,
|
||||||
"quadrant_config": {
|
"quadrant_config": {
|
||||||
"state_divider": QUADRANT_STATE_DIVIDER,
|
"state_divider": QUADRANT_STATE_DIVIDER,
|
||||||
"warning_divider": QUADRANT_WARNING_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_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,
|
"breadth_date": breadth_item[0].isoformat() if breadth_item else None,
|
||||||
"fundamentals_fetched_at": overrides.get("fetched_at"),
|
"fundamentals_fetched_at": overrides.get("fetched_at"),
|
||||||
"fundamentals_effective_date": overlay.get("effective_date"),
|
"fundamentals_effective_date": context.get("effective_date"),
|
||||||
"fundamentals_age_days": overlay.get("age_days"),
|
"fundamentals_age_days": context.get("age_days"),
|
||||||
},
|
},
|
||||||
"data_quality": {
|
"data_quality": {
|
||||||
"minimum_coverage": MIN_COVERAGE,
|
"minimum_coverage": MIN_COVERAGE,
|
||||||
@@ -859,7 +1074,7 @@ async def get_fundamental_overrides(db: AsyncSession) -> dict:
|
|||||||
"f1_score": None,
|
"f1_score": None,
|
||||||
"f3_score": None,
|
"f3_score": None,
|
||||||
"capex": {name: "unknown" for name in names},
|
"capex": {name: "unknown" for name in names},
|
||||||
"good_news_stock_down": "mixed",
|
"good_news_stock_down": "unknown",
|
||||||
"locked": False,
|
"locked": False,
|
||||||
"reasoning": None,
|
"reasoning": None,
|
||||||
"fetched_at": 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:
|
if stored.get("methodology") not in CATEGORICAL_FUNDAMENTAL_METHODOLOGIES:
|
||||||
return default
|
return default
|
||||||
capex = _normalise_capex_states(stored.get("capex"), names)
|
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:
|
if reaction not in GNSD_STATES:
|
||||||
reaction = "mixed"
|
reaction = "unknown"
|
||||||
return {
|
return {
|
||||||
**default,
|
**default,
|
||||||
**stored,
|
**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
|
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(
|
async def set_fundamental_overrides(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
capex: dict[str, str] | None = None,
|
capex: dict[str, str] | None = None,
|
||||||
@@ -954,7 +1263,15 @@ async def set_fundamental_overrides(
|
|||||||
"fetched_at": now.isoformat(),
|
"fetched_at": now.isoformat(),
|
||||||
"effective_date": _next_weekday(now.date()).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
|
return current
|
||||||
|
|
||||||
|
|
||||||
@@ -1058,12 +1375,59 @@ def _snapshot_revision(snapshot: dict) -> int:
|
|||||||
return 1
|
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:
|
def _parse_snapshot(raw: str) -> dict | None:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(raw)
|
parsed = json.loads(raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
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:
|
async def _latest_snapshot_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict] | None:
|
||||||
@@ -1082,6 +1446,10 @@ async def update_regime_monitor(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
config = await get_regime_config(db)
|
config = await get_regime_config(db)
|
||||||
overrides = await get_fundamental_overrides(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"):
|
if _fundamentals_stale(overrides, config) and not overrides.get("locked"):
|
||||||
try:
|
try:
|
||||||
overrides = await refresh_fundamental_overrides(db, config=config)
|
overrides = await refresh_fundamental_overrides(db, config=config)
|
||||||
@@ -1131,6 +1499,9 @@ async def update_regime_monitor(
|
|||||||
|
|
||||||
breadth_series = _mapping_series(breadth)
|
breadth_series = _mapping_series(breadth)
|
||||||
divergence_series = _mapping_series(divergence)
|
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
|
latest_result: dict | None = None
|
||||||
snapshots_written = 0
|
snapshots_written = 0
|
||||||
for snapshot_date in dates:
|
for snapshot_date in dates:
|
||||||
@@ -1144,6 +1515,7 @@ async def update_regime_monitor(
|
|||||||
breadth_series,
|
breadth_series,
|
||||||
divergence_series,
|
divergence_series,
|
||||||
breadth_counts,
|
breadth_counts,
|
||||||
|
observations=observations,
|
||||||
)
|
)
|
||||||
written, latest_result = await _upsert_snapshot(
|
written, latest_result = await _upsert_snapshot(
|
||||||
db,
|
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
|
quality["is_fresh"] = bool(quality.get("inputs_fresh")) and snapshot_age <= 4
|
||||||
result["data_quality"] = quality
|
result["data_quality"] = quality
|
||||||
|
|
||||||
# The snapshot's overlay is the point-in-time record; the reader also wants
|
# The snapshot's `fundamental_context` is the point-in-time record; the
|
||||||
# the current observation even when it is not effective until the next
|
# reader also wants the current observation even when it is not effective
|
||||||
# session, because otherwise refreshing it looks like it did nothing.
|
# until the next session, or refreshing it looks like it did nothing.
|
||||||
config = await get_regime_config(db)
|
config = await get_regime_config(db)
|
||||||
overrides = await get_fundamental_overrides(db)
|
overrides = await get_fundamental_overrides(db)
|
||||||
live = current_observation(overrides, config, date.today())
|
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".
|
# the reader tells "shown here" from "in the stored record".
|
||||||
live["observed_in_snapshot"] = bool((result.get("fundamental_overlay") or {}).get("available"))
|
live["observed_in_snapshot"] = bool(
|
||||||
result["fundamental_context"] = live
|
(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
|
result["available"] = True
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -1248,10 +1626,17 @@ async def get_regime_history(db: AsyncSession, days: int = 800) -> list[dict]:
|
|||||||
if data is None:
|
if data is None:
|
||||||
continue
|
continue
|
||||||
state, warning = data.get("state") or {}, data.get("warning") or {}
|
state, warning = data.get("state") or {}, data.get("warning") or {}
|
||||||
|
context = data.get("fundamental_context") or {}
|
||||||
out.append({
|
out.append({
|
||||||
"date": row.date.isoformat(),
|
"date": row.date.isoformat(),
|
||||||
"state": state.get("score") if state.get("band") is not None else None,
|
"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,
|
"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"),
|
"state_coverage": state.get("coverage"),
|
||||||
"warning_coverage": warning.get("coverage"),
|
"warning_coverage": warning.get("coverage"),
|
||||||
"basket_hash": (data.get("basket") or {}).get("hash"),
|
"basket_hash": (data.get("basket") or {}).get("hash"),
|
||||||
@@ -1383,7 +1768,7 @@ async def refresh_fundamental_overrides(
|
|||||||
f1 = _score_capex_states(capex, names)
|
f1 = _score_capex_states(capex, names)
|
||||||
reaction = str(parsed.get("good_news_stock_down", "")).strip().lower()
|
reaction = str(parsed.get("good_news_stock_down", "")).strip().lower()
|
||||||
if reaction not in GNSD_STATES:
|
if reaction not in GNSD_STATES:
|
||||||
reaction = "mixed"
|
reaction = "unknown"
|
||||||
f3 = _GNSD_SCORES.get(reaction)
|
f3 = _GNSD_SCORES.get(reaction)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
result = {
|
result = {
|
||||||
@@ -1398,7 +1783,11 @@ async def refresh_fundamental_overrides(
|
|||||||
"locked": False,
|
"locked": False,
|
||||||
"source": llm.get("provider"),
|
"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({
|
logger.info(json.dumps({
|
||||||
"event": "regime_fundamentals_refreshed",
|
"event": "regime_fundamentals_refreshed",
|
||||||
"f1": result["f1_score"],
|
"f1": result["f1_score"],
|
||||||
|
|||||||
@@ -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
|
`state_v4 <= state_v3` invariant. Reading a calibration result out of a run whose
|
||||||
pipeline did not validate is meant to be structurally impossible.
|
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: 0–25 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
|
## What changed in v4
|
||||||
|
|
||||||
**V1 stopped saturating at VIX 30.** `(vix - 15) / 15` reached 100 at VIX 30 —
|
**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
|
`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.
|
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
|
**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
|
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
|
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%.
|
- 60-session SMH/SPY relative-strength deterioration, 30%.
|
||||||
- HY OAS 20-session widening, 25%.
|
- 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
|
## 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
|
the current methodology, so a bump reseeds the series rather than splicing two
|
||||||
formulas into one line.
|
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
|
collection) and is never replayed backward, so a rebuild cannot stamp today's
|
||||||
observation onto historical snapshots. Because the observation is stored in a
|
observation onto historical snapshots. Since the observations became a real
|
||||||
single slot, a refresh replaces the previously effective record: the snapshot
|
series (`regime_fundamental_observations`, migration 033), the effective-date
|
||||||
therefore reports the overlay as `pending` until the new 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
|
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
|
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.
|
content.
|
||||||
|
|
||||||
Until 2026-08-07 the live reading called the gated function, so a just-collected
|
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 —
|
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
|
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
|
already claimed. Showing it early cannot leak into a published score, because
|
||||||
nothing in the overlay is scored (see "Fundamentals left the score").
|
nothing in the channel is scored.
|
||||||
|
|
||||||
`current_observation` gates on `observed` (a non-null `fetched_at`, the one field
|
`current_observation` gates on `observed` (a non-null `fetched_at`, the one field
|
||||||
every path writing real content stamps). Without it, the default override —
|
every path writing real content stamps). Without it, the default override —
|
||||||
`unknown` for every hyperscaler and `mixed` for the reaction — was reported as a
|
`unknown` for every hyperscaler and, since 2026-08-13, `unknown` for the reaction
|
||||||
live observation with `available: true`, so the card presented placeholders as a
|
— was reported as a live observation with `available: true`, so the card
|
||||||
collected reading. Those are the absence of an observation, not an observation of
|
presented placeholders as a collected reading. Those are the absence of an
|
||||||
absence. `fundamental_overlay` never had this problem: no observation means no
|
observation, not an observation of absence. `fundamental_context` never had this
|
||||||
effective date, which means `pending`, which already blanks the content.
|
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.
|
Each snapshot stores the fixed basket symbols, hash, and freeze date.
|
||||||
Reconstructed history before that freeze date is retrospective/exploratory.
|
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
|
## Warning study
|
||||||
|
|
||||||
The study calls the outcome a **10% correction**, not a regime break. The first
|
The study calls the outcome a **10% correction**, not a regime break. It measures
|
||||||
70% of sessions freezes the 80th-percentile warning threshold; alarm episodes are
|
two rules against that outcome, plus enough context to tell whether either number
|
||||||
measured on the final 30%. Because v3 dropped fundamentals from the score, the
|
is any good.
|
||||||
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.
|
|
||||||
|
|
||||||
A cached report is discarded when its methodology no longer matches, so the panel
|
A cached report is discarded when its methodology no longer matches *or* when
|
||||||
reverts to "not run yet" after a bump rather than showing stale numbers. **Re-run
|
`STUDY_SCHEMA` moves, so the panel reverts to "not run yet" rather than showing
|
||||||
the Event Study job after cutting over to v4.**
|
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
|
### Reading the result
|
||||||
|
|
||||||
The report carries a `reliability` block and the UI renders its warnings, because
|
A bare "2 of 4" is unreadable in either direction, so the report scores four more
|
||||||
the headline numbers invite over-reading in two specific ways.
|
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
|
**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
|
70/30 split leaves only 4 in the test period. Recall is one event away from a
|
||||||
from a materially different headline, and in practice the event that flips is
|
materially different headline, and in practice the event that flips is decided by
|
||||||
decided by where the frozen threshold happens to land rather than by whether the
|
where the frozen threshold happens to land rather than by whether the score saw
|
||||||
score saw anything. The v3 cutover run illustrates it: v3 scored 2/4 against v2's
|
anything. The v3 cutover run illustrates it: v3 scored 2/4 against v2's 3/4, but
|
||||||
3/4, but "v3 without the credit sensor" scores 3/4 at a *higher* threshold
|
"v3 without the credit sensor" scores 3/4 at a *higher* threshold (35.5) than
|
||||||
(35.5) than shipped v3 misses it at (32.3) — because the alarm rule needs a
|
shipped v3 misses it at (32.3) — because the alarm rule needs a rising edge, and a
|
||||||
rising edge, and a lower threshold can mean the alarm already fired outside the
|
lower threshold can mean the alarm already fired outside the 20-session horizon
|
||||||
20-session horizon and never reset below. Below `MIN_EVENTS_FOR_CONFIDENCE`
|
and never reset below. Below `MIN_EVENTS_FOR_CONFIDENCE` holdout events the
|
||||||
holdout events the report says so explicitly.
|
report says so explicitly.
|
||||||
|
|
||||||
Some events carry no information at all for comparison: in that run every
|
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
|
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
|
"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
|
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
|
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
|
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
|
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
|
*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
|
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
|
coverage bias for a regime-selection bias. The honest position is that a fitted
|
||||||
threshold is hypersensitive to window choice at this sample size; the report
|
threshold is hypersensitive to window choice at this sample size — which is the
|
||||||
states its limits rather than pretending to a precision it does not have.
|
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)
|
## Resolved in v4 (raised 2026-08-07, shipped 2026-08-08)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Cell,
|
|
||||||
Line,
|
Line,
|
||||||
LineChart,
|
LineChart,
|
||||||
ReferenceArea,
|
ReferenceArea,
|
||||||
@@ -19,6 +18,8 @@ import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
|
|||||||
import { Callout } from '../ui/Callout';
|
import { Callout } from '../ui/Callout';
|
||||||
import { SkeletonCard } from '../ui/Skeleton';
|
import { SkeletonCard } from '../ui/Skeleton';
|
||||||
import { formatDate } from '../../lib/format';
|
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.
|
// 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
|
// 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. */
|
/** Sessions drawn in Path view. The full series is unreadable as a path. */
|
||||||
const PATH_TRAIL = 60;
|
const PATH_TRAIL = 60;
|
||||||
|
|
||||||
const STATE_COLOR = '#60a5fa';
|
const STATE_COLOR = REGIME_VISUAL.state;
|
||||||
const WARNING_COLOR = '#fb923c';
|
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
|
// 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.
|
// quadrant_config cannot draw dividers that disagree with the alert path.
|
||||||
@@ -50,13 +57,19 @@ interface PathPoint {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
date: string;
|
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. */
|
/** Centered moving average to de-noise the path; today (last) kept exact. */
|
||||||
function smoothTrail(points: PathPoint[], half = 2): PathPoint[] {
|
function smoothTrail(points: PathPoint[], half = 2): PathPoint[] {
|
||||||
const n = points.length;
|
const n = points.length;
|
||||||
return points.map((p, i) => {
|
return points.map((p, i) => {
|
||||||
if (i === n - 1) return { ...p };
|
if (i === n - 1) return { ...p, raw: false };
|
||||||
let sx = 0;
|
let sx = 0;
|
||||||
let sy = 0;
|
let sy = 0;
|
||||||
let c = 0;
|
let c = 0;
|
||||||
@@ -65,14 +78,58 @@ function smoothTrail(points: PathPoint[], half = 2): PathPoint[] {
|
|||||||
sy += points[j].y;
|
sy += points[j].y;
|
||||||
c += 1;
|
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 FundamentalGlyph({
|
||||||
function recencyColor(t: number): string {
|
cx,
|
||||||
const lerp = (a: number, b: number) => Math.round(a + (b - a) * t);
|
cy,
|
||||||
return `rgba(${lerp(71, 96)}, ${lerp(85, 165)}, ${lerp(105, 250)}, ${(0.3 + 0.7 * t).toFixed(2)})`;
|
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>({
|
function SegmentedControl<T extends string>({
|
||||||
@@ -94,8 +151,8 @@ function SegmentedControl<T extends string>({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-pressed={value === option}
|
aria-pressed={value === option}
|
||||||
onClick={() => onChange(option)}
|
onClick={() => onChange(option)}
|
||||||
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
|
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-500 hover:text-gray-300'
|
value === option ? 'bg-white/10 text-blue-300' : 'text-gray-400 hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
@@ -107,14 +164,19 @@ function SegmentedControl<T extends string>({
|
|||||||
|
|
||||||
function PathTip({ active, payload }: { active?: boolean; payload?: { payload: PathPoint }[] }) {
|
function PathTip({ active, payload }: { active?: boolean; payload?: { payload: PathPoint }[] }) {
|
||||||
if (!active || !payload?.length) return null;
|
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 (
|
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-300">{formatDate(p.date)}</div>
|
||||||
<div className="text-gray-400">
|
<div className="text-gray-400">
|
||||||
State <span style={{ color: STATE_COLOR }}>{Math.round(p.x)}</span> · Warning{' '}
|
State <span style={{ color: STATE_COLOR }}>{Math.round(p.x)}</span> · Warning{' '}
|
||||||
<span style={{ color: WARNING_COLOR }}>{Math.round(p.y)}</span>
|
<span style={{ color: WARNING_COLOR }}>{Math.round(p.y)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-0.5 text-gray-400">
|
||||||
|
Fundamentals <span style={{ color: visual.color }}>{visual.label}</span> · {evidence}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -144,7 +206,15 @@ export default function RegimeChart() {
|
|||||||
}, [history.data, view, range]);
|
}, [history.data, view, range]);
|
||||||
|
|
||||||
const pathPoints = useMemo<PathPoint[]>(
|
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],
|
[series],
|
||||||
);
|
);
|
||||||
const trail = useMemo(() => (view === 'Path' ? smoothTrail(pathPoints) : []), [pathPoints, view]);
|
const trail = useMemo(() => (view === 'Path' ? smoothTrail(pathPoints) : []), [pathPoints, view]);
|
||||||
@@ -159,7 +229,7 @@ export default function RegimeChart() {
|
|||||||
<div className="glass p-5">
|
<div className="glass p-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center 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`}
|
{view === 'Time' ? 'State & Warning over time' : `State × Warning path · last ${PATH_TRAIL} sessions`}
|
||||||
</span>
|
</span>
|
||||||
<SegmentedControl options={VIEWS} value={view} onChange={setView} label="Chart view" />
|
<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" />
|
<SegmentedControl options={RANGES.map((r) => r.key)} value={range} onChange={setRange} label="Time range" />
|
||||||
) : (
|
) : (
|
||||||
latest && (
|
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{' '}
|
now: State <span style={{ color: STATE_COLOR }}>{Math.round(latest.x)}</span> · Warning{' '}
|
||||||
<span style={{ color: WARNING_COLOR }}>{Math.round(latest.y)}</span>
|
<span style={{ color: WARNING_COLOR }}>{Math.round(latest.y)}</span>
|
||||||
</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>
|
<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%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
{view === 'Time' ? (
|
{view === 'Time' ? (
|
||||||
<LineChart data={series} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
|
<LineChart data={series} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
|
||||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
|
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
tick={{ fill: '#9aa0b0', fontSize: 10 }}
|
||||||
tickFormatter={(d) => formatDate(String(d))}
|
tickFormatter={(d) => formatDate(String(d))}
|
||||||
minTickGap={28}
|
minTickGap={28}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
@@ -200,7 +274,7 @@ export default function RegimeChart() {
|
|||||||
<YAxis
|
<YAxis
|
||||||
domain={[0, 100]}
|
domain={[0, 100]}
|
||||||
ticks={[0, 25, 50, 75, 100]}
|
ticks={[0, 25, 50, 75, 100]}
|
||||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
tick={{ fill: '#9aa0b0', fontSize: 10 }}
|
||||||
width={34}
|
width={34}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
@@ -225,10 +299,13 @@ export default function RegimeChart() {
|
|||||||
</LineChart>
|
</LineChart>
|
||||||
) : (
|
) : (
|
||||||
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
|
<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" />
|
{/* One neutral at four opacities: denser = more axes elevated.
|
||||||
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
|
Hue here would collide with the fundamental glyphs drawn
|
||||||
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
|
on top of it — see QUADRANT_WASH. */}
|
||||||
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ef4444" fillOpacity={0.08} stroke="none" />
|
<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)" />
|
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
|
||||||
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
|
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
|
||||||
<ReferenceLine y={yDiv} 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"
|
dataKey="x"
|
||||||
domain={[0, 100]}
|
domain={[0, 100]}
|
||||||
ticks={[0, 20, 40, 60, 80, 100]}
|
ticks={[0, 20, 40, 60, 80, 100]}
|
||||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
tick={{ fill: '#9aa0b0', fontSize: 10 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
|
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
|
<YAxis
|
||||||
type="number"
|
type="number"
|
||||||
dataKey="y"
|
dataKey="y"
|
||||||
domain={[0, 100]}
|
domain={[0, 100]}
|
||||||
ticks={[0, 20, 40, 60, 80, 100]}
|
ticks={[0, 20, 40, 60, 80, 100]}
|
||||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
tick={{ fill: '#9aa0b0', fontSize: 10 }}
|
||||||
width={30}
|
width={30}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={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 />} />
|
<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}>
|
<Scatter
|
||||||
{trail.map((_, i) => (
|
data={trail}
|
||||||
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
|
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} />}
|
||||||
</Scatter>
|
tooltipType="none"
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Scatter data={pathPoints} shape={<PathPointShape />} isAnimationActive={false} />
|
||||||
{latest && (
|
{latest && (
|
||||||
<Scatter
|
<Scatter
|
||||||
data={[latest]}
|
data={[latest]}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
shape={(props: { cx?: number; cy?: number }) => (
|
shape={<LatestPointShape />}
|
||||||
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke={STATE_COLOR} strokeWidth={2} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ScatterChart>
|
</ScatterChart>
|
||||||
@@ -275,7 +353,7 @@ export default function RegimeChart() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === 'Time' ? (
|
{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="flex items-center gap-1.5">
|
||||||
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: STATE_COLOR }} />
|
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: STATE_COLOR }} />
|
||||||
State
|
State
|
||||||
@@ -284,20 +362,43 @@ export default function RegimeChart() {
|
|||||||
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: WARNING_COLOR }} />
|
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: WARNING_COLOR }} />
|
||||||
Warning
|
Warning
|
||||||
</span>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
|
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-xs text-gray-400 sm:grid-cols-2">
|
||||||
<span><span className="text-amber-400">Early warning</span> — calm, fragility rising</span>
|
{/* Swatches, not coloured words: the quadrant names used the
|
||||||
<span><span className="text-orange-400">Active stress</span> — damaged and deteriorating</span>
|
fundamental channel's colours, so "Stabilizing" was rendered in
|
||||||
<span><span className="text-emerald-400">Healthy</span> — calm, broadly supported</span>
|
the adverse hue while meaning damage receding. */}
|
||||||
<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>
|
['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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{crossesFreeze && (
|
{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.
|
History before {basketAsOf} is reconstructed against today's basket — retrospective, not a live record.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface DisclosureProps {
|
|||||||
export function Disclosure({ summary, children }: DisclosureProps) {
|
export function Disclosure({ summary, children }: DisclosureProps) {
|
||||||
return (
|
return (
|
||||||
<details className="glass-sm group">
|
<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>
|
<span className="inline-block transition-transform duration-200 group-open:rotate-90">▸</span>
|
||||||
{summary}
|
{summary}
|
||||||
</summary>
|
</summary>
|
||||||
|
|||||||
@@ -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 {
|
export function regimeDot(label: MarketRegime['label']): string {
|
||||||
switch (label) {
|
switch (label) {
|
||||||
|
|||||||
+109
-23
@@ -509,9 +509,24 @@ export interface RegimeReading {
|
|||||||
trend?: { delta_7: number | null; delta_30: number | null };
|
trend?: { delta_7: number | null; delta_30: number | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Qualitative capex / earnings-reaction context. Not part of either score. */
|
export type FundamentalState = 'supportive' | 'neutral' | 'adverse' | 'unknown';
|
||||||
export interface RegimeFundamentalOverlay {
|
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;
|
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;
|
pending: boolean;
|
||||||
stale: boolean;
|
stale: boolean;
|
||||||
effective_date: string | null;
|
effective_date: string | null;
|
||||||
@@ -524,7 +539,7 @@ export interface RegimeFundamentalOverlay {
|
|||||||
source: string | null;
|
source: string | null;
|
||||||
fetched_at: string | null;
|
fetched_at: string | null;
|
||||||
/** Whether anything was actually collected. Live reading only; the snapshot's
|
/** 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?: boolean;
|
||||||
observed_in_snapshot?: boolean;
|
observed_in_snapshot?: boolean;
|
||||||
}
|
}
|
||||||
@@ -533,6 +548,11 @@ export interface RegimeHistoryPoint {
|
|||||||
date: string;
|
date: string;
|
||||||
state: number | null;
|
state: number | null;
|
||||||
warning: 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;
|
state_coverage: number | null;
|
||||||
warning_coverage: number | null;
|
warning_coverage: number | null;
|
||||||
basket_hash: string | null;
|
basket_hash: string | null;
|
||||||
@@ -545,10 +565,12 @@ export interface RegimeMonitor {
|
|||||||
date?: string;
|
date?: string;
|
||||||
state?: RegimeReading;
|
state?: RegimeReading;
|
||||||
warning?: RegimeReading;
|
warning?: RegimeReading;
|
||||||
/** Point-in-time overlay recorded in the snapshot. */
|
/** The channel as recorded in the snapshot — point-in-time, effective-date gated. */
|
||||||
fundamental_overlay?: RegimeFundamentalOverlay;
|
fundamental_context?: RegimeFundamentalContext;
|
||||||
/** Current observation, even when it is not effective until the next session. */
|
/** What we know right now, even when it is not effective until the next
|
||||||
fundamental_context?: RegimeFundamentalOverlay;
|
* 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?: {
|
inputs?: {
|
||||||
vix: number | null;
|
vix: number | null;
|
||||||
vix_date: string | null;
|
vix_date: string | null;
|
||||||
@@ -596,7 +618,7 @@ export interface RegimeFundamentals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CapexState = 'raising' | 'holding' | 'cutting' | 'unknown';
|
export type CapexState = 'raising' | 'holding' | 'cutting' | 'unknown';
|
||||||
export type GoodNewsReaction = 'yes' | 'no' | 'mixed';
|
export type GoodNewsReaction = 'yes' | 'no' | 'mixed' | 'unknown';
|
||||||
|
|
||||||
export interface RegimeFundamentalsUpdate {
|
export interface RegimeFundamentalsUpdate {
|
||||||
capex?: Record<string, CapexState>;
|
capex?: Record<string, CapexState>;
|
||||||
@@ -611,9 +633,22 @@ export interface RegimeConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Event study — measured lead time of early-warning indicators vs. drawdowns
|
// 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 {
|
export interface EventStudyReport {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
/** Report shape, independent of methodology. Mismatched reports are discarded. */
|
||||||
|
schema?: number;
|
||||||
methodology?: string;
|
methodology?: string;
|
||||||
generated_at?: string;
|
generated_at?: string;
|
||||||
evaluation?: 'exploratory' | 'holdout';
|
evaluation?: 'exploratory' | 'holdout';
|
||||||
@@ -624,14 +659,11 @@ export interface EventStudyReport {
|
|||||||
event_threshold_pct: number;
|
event_threshold_pct: number;
|
||||||
event_cooldown_days: number;
|
event_cooldown_days: number;
|
||||||
horizon_days: number;
|
horizon_days: number;
|
||||||
train_fraction: number;
|
|
||||||
warn_percentile: number;
|
|
||||||
warn_threshold: number;
|
|
||||||
basket_hash: string;
|
basket_hash: string;
|
||||||
basket_asof: string;
|
basket_asof: string;
|
||||||
credit_sensor_from?: string | null;
|
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?: {
|
reliability?: {
|
||||||
events_detected: number;
|
events_detected: number;
|
||||||
events_in_holdout: number;
|
events_in_holdout: number;
|
||||||
@@ -645,21 +677,75 @@ export interface EventStudyReport {
|
|||||||
sample?: {
|
sample?: {
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
train_end: string;
|
/** Where the quadrant baseline seeds — not a holdout boundary. */
|
||||||
test_start: string;
|
evaluable_from: string;
|
||||||
sessions: number;
|
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: number;
|
||||||
events_warned: number;
|
mean_warned: number;
|
||||||
events_missed: number;
|
sd_warned: number;
|
||||||
alarm_episodes: number;
|
observed_warned: number;
|
||||||
false_alarms: number;
|
p_at_least_observed: number;
|
||||||
false_alarms_per_year: number;
|
} | null;
|
||||||
median_lead_days: 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 }[];
|
recent_breadth?: { date: string; breadth: number; warning: number | null }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+437
-107
@@ -6,6 +6,7 @@ import { Disclosure } from '../components/ui/Disclosure';
|
|||||||
import { Badge } from '../components/ui/Badge';
|
import { Badge } from '../components/ui/Badge';
|
||||||
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import { FUNDAMENTAL_VISUAL } from '../lib/regime';
|
||||||
import {
|
import {
|
||||||
getEventStudy,
|
getEventStudy,
|
||||||
getRegimeConfig,
|
getRegimeConfig,
|
||||||
@@ -17,11 +18,13 @@ import {
|
|||||||
} from '../api/regime';
|
} from '../api/regime';
|
||||||
import type {
|
import type {
|
||||||
CapexState,
|
CapexState,
|
||||||
|
FundamentalState,
|
||||||
|
EventStudyMetrics,
|
||||||
EventStudyReport,
|
EventStudyReport,
|
||||||
GoodNewsReaction,
|
GoodNewsReaction,
|
||||||
RegimeBand,
|
RegimeBand,
|
||||||
RegimeConfig,
|
RegimeConfig,
|
||||||
RegimeFundamentalOverlay,
|
RegimeFundamentalContext,
|
||||||
RegimeFundamentals,
|
RegimeFundamentals,
|
||||||
RegimeFundamentalsUpdate,
|
RegimeFundamentalsUpdate,
|
||||||
RegimeMonitor,
|
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 }) {
|
function TrendChip({ label, delta }: { label: string; delta: number | null | undefined }) {
|
||||||
if (delta == null) {
|
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 color = delta === 0 ? 'text-gray-400' : delta > 0 ? 'text-red-400' : 'text-emerald-400';
|
||||||
const arrow = delta === 0 ? '→' : delta > 0 ? '↑' : '↓';
|
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.
|
// shared set would mislabel one of them. Render none rather than wrong ones.
|
||||||
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [];
|
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [];
|
||||||
return (
|
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 className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<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">
|
<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)}
|
{score == null ? '—' : Math.round(score)}
|
||||||
</span>
|
</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>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
<span className={`text-sm font-medium ${style?.text ?? 'text-gray-500'}`}>
|
<span className={`text-sm font-medium ${style?.text ?? 'text-gray-500'}`}>
|
||||||
{style?.label ?? 'Incomplete'}
|
{style?.label ?? 'Incomplete'}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -102,7 +105,7 @@ function ScoreGauge({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Thresholds come from the reading: the two axes no longer share them. */}
|
{/* 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>
|
<span className="absolute left-0">0</span>
|
||||||
{ticks.map((tick) => (
|
{ticks.map((tick) => (
|
||||||
<span key={tick} className="absolute -translate-x-1/2 num" style={{ left: `${tick}%` }}>
|
<span key={tick} className="absolute -translate-x-1/2 num" style={{ left: `${tick}%` }}>
|
||||||
@@ -113,86 +116,153 @@ function ScoreGauge({
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CAPEX_TONE: Record<CapexState, string> = {
|
/** Mirrors `_capex_signal`: holding is the neutral case, so it takes the neutral
|
||||||
raising: 'text-emerald-400',
|
* colour rather than an amber that reads as a third severity and sits close to
|
||||||
holding: 'text-amber-400',
|
* the Warning channel's orange. */
|
||||||
cutting: 'text-red-400',
|
const CAPEX_COLOR: Record<CapexState, string> = {
|
||||||
unknown: 'text-gray-500',
|
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, ' ');
|
||||||
function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) {
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
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 (
|
return (
|
||||||
<div className="glass border border-white/[0.06] p-5">
|
<div className="glass h-full border p-5" style={{ borderColor: `${tone.color}33` }}>
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">{OVERLAY_TITLE}</div>
|
<div className="text-[11px] uppercase tracking-wider text-gray-400">Fundamentals · context</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{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>}
|
|
||||||
{overlay.pending && <Badge label="pending" variant="manual" />}
|
{overlay.pending && <Badge label="pending" variant="manual" />}
|
||||||
{overlay.stale && <Badge label="stale" variant="manual" />}
|
{overlay.stale && <Badge label="stale" variant="manual" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* A pending observation is still shown — it is the freshest read we
|
<div className="mt-2 flex flex-wrap items-baseline gap-3">
|
||||||
have, and nothing here is scored. The date says when the stored
|
<span className="font-display text-4xl font-bold" style={{ color: tone.color }}>{tone.label}</span>
|
||||||
point-in-time record picks it up. */}
|
<span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-300">
|
||||||
{overlay.pending && (
|
{sentenceCase(overlay.evidence_quality)} evidence
|
||||||
<p className="mt-3 text-xs text-amber-400/90">
|
</span>
|
||||||
Shown as collected. The point-in-time record picks it up{' '}
|
</div>
|
||||||
{overlay.effective_date ?? 'next session'} — observations are never backdated.
|
|
||||||
|
<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>
|
</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>
|
||||||
<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]) => (
|
{Object.entries(capex).map(([symbol, state]) => (
|
||||||
<div key={symbol} className="flex items-center justify-between text-xs">
|
<div key={symbol} className="flex items-center justify-between text-xs">
|
||||||
<span className="font-mono text-gray-400">{symbol}</span>
|
<span className="font-mono text-gray-300">{symbol}</span>
|
||||||
<span className={CAPEX_TONE[state] ?? 'text-gray-500'}>{state}</span>
|
<span className="font-medium" style={{ color: CAPEX_COLOR[state] }}>{sentenceCase(state)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-baseline justify-between text-xs">
|
<div className="mb-2 text-xs font-medium text-gray-200">Good news, stock down</div>
|
||||||
<span className="font-medium text-gray-300">Good news, stock down</span>
|
<div className="text-sm font-medium" style={{ color: reaction.color }}>{reaction.label}</div>
|
||||||
<span className="num text-gray-500">{overlay.earnings_stress ?? 'n/a'}</span>
|
<p className="mt-2 text-xs text-gray-400">
|
||||||
</div>
|
Derived context: capex {overlay.capex_signal} · reaction {overlay.reaction_signal}.
|
||||||
<div className={`text-sm font-medium ${reaction === 'yes' ? 'text-red-400' : reaction === 'no' ? 'text-emerald-400' : 'text-gray-500'}`}>
|
</p>
|
||||||
{reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'}
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{overlay.reasoning && <p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>}
|
|
||||||
</div>
|
</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]">
|
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<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 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">Score</th>
|
||||||
<th className="px-4 py-3 text-right font-medium">Weight</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]">
|
<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">
|
<td colSpan={4} className="px-4 py-2 text-[11px] uppercase tracking-wider text-gray-400">
|
||||||
{title}
|
{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
|
{reading.score ?? '—'} · {Math.round(reading.coverage)}% coverage
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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="font-medium text-gray-200">{pillar.label}</div>
|
||||||
<div className="mt-1 space-y-0.5">
|
<div className="mt-1 space-y-0.5">
|
||||||
{pillar.sensors.map((sensor) => (
|
{pillar.sensors.map((sensor) => (
|
||||||
<div key={sensor.id} className="text-xs text-gray-500">
|
<div key={sensor.id} className="text-xs text-gray-400">
|
||||||
<span className="font-mono text-gray-600">{sensor.id}</span> {sensor.label}:{' '}
|
<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>
|
<span className="num text-gray-400">{sensor.score == null ? 'n/a' : sensor.score}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -256,7 +326,7 @@ function PillarTable({ state, warning }: { state: RegimeReading; warning: Regime
|
|||||||
|
|
||||||
function MetaChip({ label, value, title }: { label: string; value: ReactNode; title?: string }) {
|
function MetaChip({ label, value, title }: { label: string; value: ReactNode; title?: string }) {
|
||||||
return (
|
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>
|
{label} <span className="num text-gray-400">{value}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -288,56 +358,273 @@ function MetaStrip({ data }: { data: RegimeMonitor }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventStudyBody({ report }: { report: EventStudyReport }) {
|
function StatTiles({ metrics }: { metrics: EventStudyMetrics }) {
|
||||||
const metrics = report.metrics;
|
|
||||||
return (
|
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">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
{[
|
{[
|
||||||
['Warned', `${metrics.events_warned}/${metrics.events}`],
|
['Warned', `${metrics.events_warned}/${metrics.events}`],
|
||||||
['Missed', metrics.events_missed],
|
['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`],
|
['Median lead', metrics.median_lead_days == null ? '—' : `${metrics.median_lead_days}d`],
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<div key={String(label)} className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
<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 className="mt-0.5 text-lg font-semibold text-gray-200">{value}</div>
|
||||||
</div>
|
</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]">
|
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
|
||||||
<table className="w-full text-xs">
|
<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 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">Warned</th>
|
||||||
<th className="px-3 py-2 text-right font-medium">Lead</th>
|
<th className="px-3 py-2 text-right font-medium">Lead</th>
|
||||||
</tr></thead>
|
</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">
|
<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 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>
|
<td className="px-3 py-2 text-right num text-gray-300">{event.lead_days == null ? '—' : `${event.lead_days}d`}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}</tbody>
|
))}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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) && (
|
{report.reliability && (report.reliability.underpowered || report.reliability.sensor_coverage_mismatch) && (
|
||||||
<Callout variant="warning">
|
<Callout variant="warning">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{report.reliability.underpowered && (
|
{report.reliability.underpowered && (
|
||||||
<p>
|
<p>
|
||||||
<strong>Underpowered.</strong> Only {report.reliability.events_in_holdout} of{' '}
|
<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.
|
{report.reliability.minimum_events}+ needed). Read the direction, not the ratio.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -355,6 +642,9 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
|
|||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Disclosure>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,15 +684,26 @@ function FundamentalsEditor({
|
|||||||
}) {
|
}) {
|
||||||
const [capex, setCapex] = useState<Record<string, CapexState>>(() => ({ ...data.capex }));
|
const [capex, setCapex] = useState<Record<string, CapexState>>(() => ({ ...data.capex }));
|
||||||
const [reaction, setReaction] = useState<GoodNewsReaction>(data.good_news_stock_down);
|
const [reaction, setReaction] = useState<GoodNewsReaction>(data.good_news_stock_down);
|
||||||
const knownCapex = Object.values(capex).filter((state) => state !== 'unknown');
|
const values = Object.values(capex);
|
||||||
// Mirrors _CAPEX_STATE_SCORES: raising 0, holding 50, cutting 100. Holding is
|
const counts = {
|
||||||
// the deceleration case and used to score identically to raising.
|
cutting: values.filter((s) => s === 'cutting').length,
|
||||||
const capexPoints = knownCapex.reduce((sum, state) => sum + (state === 'cutting' ? 100 : state === 'holding' ? 50 : 0), 0);
|
holding: values.filter((s) => s === 'holding').length,
|
||||||
const derivedF1 = knownCapex.length >= 3 ? Math.round((capexPoints / knownCapex.length) * 10) / 10 : null;
|
raising: values.filter((s) => s === 'raising').length,
|
||||||
const derivedF3 = reaction === 'yes' ? 100 : reaction === 'no' ? 0 : null;
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<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>
|
<span>Source: {data.source}</span>
|
||||||
{data.fetched_at && <span>· fetched {new Date(data.fetched_at).toLocaleDateString()}</span>}
|
{data.fetched_at && <span>· fetched {new Date(data.fetched_at).toLocaleDateString()}</span>}
|
||||||
{data.effective_date && <span>· effective {data.effective_date}</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>}
|
{data.reasoning && <p className="text-xs leading-relaxed text-gray-400">{data.reasoning}</p>}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs">
|
<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="font-medium text-gray-300">Capex guidance by hyperscaler</span>
|
||||||
<span className="num text-gray-500">score {derivedF1 ?? 'n/a'}</span>
|
<span style={{ color: FUNDAMENTAL_VISUAL[capexSignal].color }}>{capexSignal}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{Object.entries(capex).map(([symbol, state]) => (
|
{Object.entries(capex).map(([symbol, state]) => (
|
||||||
@@ -428,17 +729,24 @@ function FundamentalsEditor({
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<label className="flex items-center justify-between gap-3 text-xs text-gray-400">
|
<label className="flex items-center justify-between gap-3 text-xs text-gray-400">
|
||||||
<span>
|
<span>
|
||||||
<span className="font-medium text-gray-300">F3 · Good news, stock down</span>
|
<span className="font-medium text-gray-300">Good news, stock down</span>
|
||||||
<span className="ml-2 num text-gray-600">score {derivedF3 ?? 'n/a'}</span>
|
<span className="ml-2" style={{ color: FUNDAMENTAL_VISUAL[reactionSignal].color }}>{reactionSignal}</span>
|
||||||
</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)}>
|
<select className={SELECT_CLASS} value={reaction} onChange={(event) => setReaction(event.target.value as GoodNewsReaction)}>
|
||||||
<option value="yes">Yes · stress</option>
|
<option value="yes">Yes · good news sold</option>
|
||||||
<option value="no">No · ordinary</option>
|
<option value="no">No · reacting normally</option>
|
||||||
<option value="mixed">Mixed · unavailable</option>
|
<option value="mixed">Mixed · observed, no clear pattern</option>
|
||||||
|
<option value="unknown">Unknown · not observed</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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" />
|
<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>
|
<span>days</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-[11px] text-gray-600">Changing the basket resets its freeze date and silently reseeds quadrant alerts.</p>
|
<p className="text-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 & freshness</button>
|
<button className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50" disabled={saving || symbols.length < 20} onClick={() => onSave({ breadth_basket: symbols, fundamental_staleness_days: staleness })}>Save basket & freshness</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -483,13 +791,13 @@ function AdminControls() {
|
|||||||
<Disclosure summary="Admin · Monitor settings">
|
<Disclosure summary="Admin · Monitor settings">
|
||||||
<div className="grid gap-5 xl:grid-cols-2 xl:gap-6">
|
<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">
|
<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.isLoading && <SkeletonCard className="h-36" />}
|
||||||
{fundamentals.data && <FundamentalsEditor key={fundamentals.dataUpdatedAt} data={fundamentals.data} onSave={(body) => saveFundamentals.mutate(body)} onRefresh={() => refresh.mutate()} saving={saveFundamentals.isPending} refreshing={refresh.isPending} />}
|
{fundamentals.data && <FundamentalsEditor key={fundamentals.dataUpdatedAt} data={fundamentals.data} onSave={(body) => saveFundamentals.mutate(body)} onRefresh={() => refresh.mutate()} saving={saveFundamentals.isPending} refreshing={refresh.isPending} />}
|
||||||
{refresh.isError && <Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>}
|
{refresh.isError && <Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>}
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-3 text-[11px] uppercase tracking-wider text-gray-500">Fixed basket & freshness</div>
|
<div className="mb-3 text-xs uppercase tracking-wider text-gray-400">Fixed basket & freshness</div>
|
||||||
{config.isLoading && <SkeletonCard className="h-36" />}
|
{config.isLoading && <SkeletonCard className="h-36" />}
|
||||||
{config.data && <ConfigEditor key={config.dataUpdatedAt} data={config.data} onSave={(updates) => saveConfig.mutate(updates)} saving={saveConfig.isPending} />}
|
{config.data && <ConfigEditor key={config.dataUpdatedAt} data={config.data} onSave={(updates) => saveConfig.mutate(updates)} saving={saveConfig.isPending} />}
|
||||||
{saveConfig.isError && <Callout variant="error">Save failed: {(saveConfig.error as Error).message}</Callout>}
|
{saveConfig.isError && <Callout variant="error">Save failed: {(saveConfig.error as Error).message}</Callout>}
|
||||||
@@ -508,7 +816,19 @@ export default function RegimePage() {
|
|||||||
<div className="space-y-6 animate-slide-up">
|
<div className="space-y-6 animate-slide-up">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="AI/Tech Risk Monitor"
|
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} /></>}
|
{monitor.isLoading && <><SkeletonCard className="h-44" /><SkeletonTable rows={6} cols={4} /></>}
|
||||||
@@ -524,7 +844,7 @@ export default function RegimePage() {
|
|||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
<ScoreGauge
|
<ScoreGauge
|
||||||
label="State · stress right now"
|
label="State · stress right now"
|
||||||
reading={data.state}
|
reading={data.state}
|
||||||
@@ -542,17 +862,27 @@ export default function RegimePage() {
|
|||||||
<ScoreGauge
|
<ScoreGauge
|
||||||
label="Warning · deterioration & divergence"
|
label="Warning · deterioration & divergence"
|
||||||
reading={data.warning}
|
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>
|
</div>
|
||||||
|
|
||||||
|
<ConfluenceStrip warning={data.warning} context={data.fundamental_context} />
|
||||||
|
|
||||||
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeChart /></Suspense>
|
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeChart /></Suspense>
|
||||||
|
|
||||||
|
{data.fundamental_live && <FundamentalEvidence overlay={data.fundamental_live} />}
|
||||||
|
|
||||||
<PillarTable state={data.state} warning={data.warning} />
|
<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} />
|
<MetaStrip data={data} />
|
||||||
|
</Disclosure>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,14 @@
|
|||||||
appearance: textfield;
|
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 */
|
/* Atmosphere: faint starfield + soft rim-cyan / ember glows + film grain */
|
||||||
#root {
|
#root {
|
||||||
position: relative;
|
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 {
|
@layer components {
|
||||||
/* Mars horizon — fixed at the viewport bottom, atmosphere only, never data */
|
/* Mars horizon — fixed at the viewport bottom, atmosphere only, never data */
|
||||||
.app-horizon {
|
.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
|
That is deliberate: it must be structurally impossible to read a calibration
|
||||||
result out of a run whose pipeline did not validate.
|
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:
|
Research branch only. Example:
|
||||||
|
|
||||||
.\\.venv\\Scripts\\python.exe scripts\\run_regime_monitor_calibration.py ^
|
.\\.venv\\Scripts\\python.exe scripts\\run_regime_monitor_calibration.py ^
|
||||||
|
|||||||
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.services.breadth_service import _breadth_from_closes, compute_divergence_series
|
from app.services.breadth_service import _breadth_from_closes, compute_divergence_series
|
||||||
from app.services.event_study_service import (
|
from app.services.event_study_service import (
|
||||||
MIN_EVENTS_FOR_CONFIDENCE,
|
MIN_EVENTS_FOR_CONFIDENCE,
|
||||||
|
STRESS_QUADRANT,
|
||||||
|
WARNING_QUADRANTS,
|
||||||
|
_era_split,
|
||||||
|
_null_model,
|
||||||
_percentile,
|
_percentile,
|
||||||
_reliability,
|
_reliability,
|
||||||
alarm_episodes,
|
alarm_episodes,
|
||||||
|
below_average_series,
|
||||||
detect_events,
|
detect_events,
|
||||||
|
entry_alarms,
|
||||||
evaluate_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)]
|
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():
|
def test_detect_events_uses_rising_edge_and_cooldown():
|
||||||
closes = [100.0] * 300 + [85.0] * 5 + [100.0] * 50 + [85.0] * 5
|
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)
|
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
|
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():
|
def test_breadth_from_fixed_closes_and_tapered_divergence():
|
||||||
dates = _days(10)
|
dates = _days(10)
|
||||||
closes_by_symbol = {
|
closes_by_symbol = {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from app.services.regime_monitor_service import (
|
|||||||
drawdown_pct,
|
drawdown_pct,
|
||||||
f2_credit_spreads,
|
f2_credit_spreads,
|
||||||
current_observation,
|
current_observation,
|
||||||
fundamental_overlay,
|
fundamental_context,
|
||||||
p1_trend_break,
|
p1_trend_break,
|
||||||
p2_death_cross,
|
p2_death_cross,
|
||||||
p3_drawdown,
|
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]]:
|
def _dated(values: list[float], end: date = date(2026, 6, 26)) -> list[tuple[date, float]]:
|
||||||
return [
|
return [
|
||||||
(end - timedelta(days=len(values) - 1 - index), value)
|
(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
|
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 = {
|
overrides = {
|
||||||
"f1_score": 0.0,
|
"f1_score": 0.0,
|
||||||
"f3_score": 100.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}
|
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["pending"] is True
|
||||||
assert pending["available"] is False
|
assert pending["available"] is False
|
||||||
assert pending["capex"] is None
|
assert pending["capex"] is None
|
||||||
# The effective date is still reported so a pending refresh is visible.
|
# The effective date is still reported so a pending refresh is visible.
|
||||||
assert pending["effective_date"] == "2026-06-02"
|
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["available"] is True
|
||||||
assert live["good_news_stock_down"] == "yes"
|
assert live["good_news_stock_down"] == "yes"
|
||||||
assert live["earnings_stress"] == 100.0
|
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["stale"] is True
|
||||||
assert expired["available"] is False
|
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}
|
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
|
||||||
|
|
||||||
before = date(2026, 6, 1)
|
before = date(2026, 6, 1)
|
||||||
record = fundamental_overlay(overrides, config, before)
|
record = fundamental_context(overrides, config, before)
|
||||||
now = current_observation(overrides, config, before)
|
now = current_observation(overrides, config, before)
|
||||||
|
|
||||||
# Same day, same observation: the record hides it, the live reading shows it.
|
# 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
|
assert current_observation(collected, DEFAULT_CONFIG, date(2026, 8, 7))["observed"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_fundamentals_do_not_move_the_warning_score():
|
def test_fundamental_state_never_averages_unknown_into_neutral():
|
||||||
"""The v3 complaint: a maxed-out LLM read must not silently do nothing.
|
"""Missing evidence must not present as evidence of normality.
|
||||||
|
|
||||||
It no longer feeds Warning at all, so Warning is identical either way and
|
This is the trap that mattered when the channel replaced the weighted
|
||||||
the observation is reported beside the score instead of buried in it.
|
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)
|
end = date(2026, 6, 26)
|
||||||
rising = [100.0 + index * 0.2 for index in range(700)]
|
rising = [100.0 + index * 0.2 for index in range(700)]
|
||||||
prices = {"SMH": _dated(rising, end), "QQQ": _dated(rising, end), "SPY": _dated(rising, end)}
|
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))])
|
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})
|
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)
|
def observed(capex_state: str, reaction: str) -> dict:
|
||||||
screaming = _compute_index(
|
return {
|
||||||
*args,
|
"capex": dict.fromkeys(names, capex_state),
|
||||||
{
|
"good_news_stock_down": reaction,
|
||||||
"f1_score": 100.0,
|
|
||||||
"f3_score": 100.0,
|
|
||||||
"capex": dict.fromkeys(DEFAULT_CONFIG["tickers"]["hyperscalers"], "cutting"),
|
|
||||||
"good_news_stock_down": "yes",
|
|
||||||
"effective_date": "2026-06-01",
|
"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 quality(full, "no") == "complete"
|
||||||
assert {p["id"] for p in quiet["warning"]["pillars"]} == set(WARNING_WEIGHTS)
|
assert quality(partial, "no") == "partial"
|
||||||
assert screaming["fundamental_overlay"]["available"] is True
|
assert quality(full, None) == "partial" # reaction unknown
|
||||||
assert screaming["fundamental_overlay"]["capex_stress"] == 100.0
|
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():
|
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())
|
result = await rms.get_fundamental_overrides(object())
|
||||||
|
|
||||||
assert result["methodology"] == "v4"
|
assert result["methodology"] == rms.METHODOLOGY
|
||||||
assert result["f1_score"] is None
|
assert result["f1_score"] is None
|
||||||
assert result["f3_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
|
@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):
|
async def fake_update(_db, _key, value):
|
||||||
saved.update(json.loads(value))
|
saved.update(json.loads(value))
|
||||||
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr(rms, "get_fundamental_overrides", fake_get)
|
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["locked"] is False
|
||||||
assert result["fetched_at"] == stored["fetched_at"]
|
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):
|
async def fake_update(_db, _key, value):
|
||||||
saved.update(json.loads(value))
|
saved.update(json.loads(value))
|
||||||
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr(rms, "get_fundamental_overrides", fake_get)
|
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")}
|
capex = {names[0]: "cutting", **dict.fromkeys(names[1:], "holding")}
|
||||||
|
|
||||||
|
db = _CommitOnlyDB()
|
||||||
result = await rms.set_fundamental_overrides(
|
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["f1_score"] == 62.5 # one cutting (100) + three holding (50)
|
||||||
assert result["f3_score"] is None
|
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):
|
async def test_prior_snapshot_is_immutable_without_explicit_rebuild(db_session):
|
||||||
snapshot_date = date(2026, 6, 26)
|
snapshot_date = date(2026, 6, 26)
|
||||||
first = {
|
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(),
|
"date": snapshot_date.isoformat(),
|
||||||
"state": {"score": 10.0, "band": "stable"},
|
"state": {"score": 10.0, "band": "stable"},
|
||||||
"warning": {"score": 20.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.breadth_service, "compute_breadth_details", fake_breadth)
|
||||||
monkeypatch.setattr(rms, "_latest_snapshot_row", fake_latest)
|
monkeypatch.setattr(rms, "_latest_snapshot_row", fake_latest)
|
||||||
monkeypatch.setattr(rms, "_upsert_snapshot", fake_upsert)
|
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())
|
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),
|
("_fetch_fred_series", fake_fred),
|
||||||
("_latest_snapshot_row", fake_latest),
|
("_latest_snapshot_row", fake_latest),
|
||||||
("_upsert_snapshot", fake_upsert),
|
("_upsert_snapshot", fake_upsert),
|
||||||
|
("get_fundamental_observations", _no_observations),
|
||||||
|
("record_fundamental_observation", _skip_recording),
|
||||||
):
|
):
|
||||||
monkeypatch.setattr(rms, name, value)
|
monkeypatch.setattr(rms, name, value)
|
||||||
monkeypatch.setattr(rms.breadth_service, "compute_breadth_details", fake_breadth)
|
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")
|
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]
|
sensor_scores = [sensor["score"] for sensor in price["sensors"] if sensor["score"] is not None]
|
||||||
assert price["score"] == max(sensor_scores)
|
assert price["score"] == max(sensor_scores)
|
||||||
assert result["methodology"] == "v4"
|
assert result["methodology"] == rms.METHODOLOGY
|
||||||
assert "combined" not in result
|
assert "combined" not in result
|
||||||
assert result["basket"]["members_available"] == 25
|
assert result["basket"]["members_available"] == 25
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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 (
|
from app.services.alert_service import (
|
||||||
|
CONFLUENCE_TYPE,
|
||||||
|
FUND_TYPE,
|
||||||
QUAD_X_DIV,
|
QUAD_X_DIV,
|
||||||
QUAD_Y_DIV,
|
QUAD_Y_DIV,
|
||||||
_classify_quadrant,
|
_classify_quadrant,
|
||||||
|
_collect_regime_fundamental,
|
||||||
_parse_quadrant_log_key,
|
_parse_quadrant_log_key,
|
||||||
_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(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:32.4:54.6") == (None, "3", 32.4, 54.6)
|
||||||
assert _parse_quadrant_log_key("3") == (None, "3", None, None)
|
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) == []
|
||||||
|
|||||||
Reference in New Issue
Block a user