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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 11:15:09 +02:00
co-authored by Claude Opus 5
parent 3033ad83fd
commit 333989eeab
18 changed files with 3494 additions and 403 deletions
+108
View File
@@ -97,6 +97,14 @@ SIGNAL_BUNDLE_MAX_CHARS = 3900 # Telegram limit is 4096; keep room for HTML par
# Hysteresis (a deadband around each divider) stops a point sitting on a boundary
# from flip-flopping; the cooldown caps how often a genuine change can re-alert.
QUAD_TYPE = "regime_quadrant"
# The fundamental channel gets its own alerts rather than shifting a score:
# "the context changed" and "both channels are elevated" are different facts from
# "the market axes moved", and fusing them into one number would destroy exactly
# the information an operator uses to decide how much the alert is worth.
FUND_TYPE = "regime_fundamental"
CONFLUENCE_TYPE = "regime_confluence"
# States that count as fundamental risk for the confluence test.
FUND_ADVERSE = "adverse"
QUAD_X_DIV = 50.0 # v3 State divider (backend response is authoritative)
QUAD_Y_DIV = 40.0 # v3 Warning divider; the axes have different ranges
QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider
@@ -859,16 +867,111 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
)
else:
metrics = f"State {x:.0f} · Warning {y:.0f}"
# The fundamental channel is reported, never added in: this alert is about
# the two market axes, and the context is stated beside them so a reader can
# judge confluence themselves rather than being handed a fused number.
context = data.get("fundamental_context") or {}
context_line = (
f"fundamentals: {context.get('state', 'unknown')} "
f"({context.get('evidence_quality', 'unavailable')})\n"
)
text = (
f"🧭 <b>AI/Tech risk quadrant change</b>\n"
f"{QUAD_LABELS.get(prev, prev)}{QUAD_LABELS.get(new_q, new_q)}\n"
f"{metrics}\n"
f"{context_line}"
f"coverage: state {state.get('coverage'):.0f}% / warning {warning.get('coverage'):.0f}%\n"
f"<i>Risk thermometer - not a trade signal.</i>"
)
return [(_quadrant_log_key(new_q, x, y, basket_hash), text)]
async def _last_logged_key(db: AsyncSession, alert_type: str) -> str | None:
"""Most recent logged key for a type, our baseline for change detection."""
result = await db.execute(
select(AlertLog.dedup_key)
.where(AlertLog.alert_type == alert_type)
.order_by(AlertLog.created_at.desc())
.limit(1)
)
row = result.first()
return row[0] if row else None
async def _collect_regime_fundamental(db: AsyncSession) -> list[tuple[str, str, str]]:
"""Fundamental-context changes and market/fundamental confluence.
Two triggers, deliberately separate from the quadrant alert and from each
other, because they answer different questions: *what the evidence says* and
*whether both channels agree*. Neither is derived by moving a score.
``unknown`` never alerts. An absence of evidence is not a change in the
evidence, and alerting on it would train the reader to ignore the channel.
Both seed silently on first run, exactly as the quadrant alert does.
"""
from app.services.regime_monitor_service import get_regime_monitor
data = await get_regime_monitor(db)
if not data.get("available"):
return []
warning = data.get("warning") or {}
context = data.get("fundamental_context") or {}
state = str(context.get("state") or "unknown")
# `usable`, not `available`: the state is deliberately preserved past its
# staleness horizon so the card can keep showing the last thing observed, and
# an observation whose extraction failed is fresh but knows nothing. Neither
# may confirm anything — without this gate a months-old adverse read silently
# corroborates every new Warning crossing forever, which is the strongest
# claim this channel makes and the one it has least right to make.
usable = bool(context.get("usable"))
score = warning.get("score")
quality = data.get("data_quality") or {}
if not quality.get("is_fresh") or float(warning.get("coverage") or 0) < 75:
return []
quadrant_cfg = data.get("quadrant_config") or {}
y_div = float(quadrant_cfg.get("warning_divider", QUAD_Y_DIV))
warning_elevated = score is not None and float(score) >= y_div
out: list[tuple[str, str, str]] = []
previous_state = await _last_logged_key(db, FUND_TYPE)
if previous_state is None:
_log_alert(db, FUND_TYPE, state) # seed
elif previous_state != state and state != "unknown" and usable:
effective = context.get("effective_date")
out.append((
FUND_TYPE,
state,
f"📋 <b>Fundamental context changed</b>\n"
f"{previous_state}{state}\n"
f"evidence: {context.get('evidence_quality', 'unavailable')}"
+ (f" · effective {effective}" if effective else "")
+ "\n<i>Context channel — not a score, not a trade signal.</i>",
))
confluence = "yes" if (warning_elevated and state == FUND_ADVERSE and usable) else "no"
previous_confluence = await _last_logged_key(db, CONFLUENCE_TYPE)
if previous_confluence is None:
_log_alert(db, CONFLUENCE_TYPE, confluence) # seed
elif previous_confluence != confluence and confluence == "yes":
out.append((
CONFLUENCE_TYPE,
confluence,
f"⚠️ <b>Confluence: market and fundamental risk both elevated</b>\n"
f"Warning {float(score):.0f} (≥ {y_div:.0f}) with fundamentals {state}\n"
f"evidence: {context.get('evidence_quality', 'unavailable')}\n"
f"<i>Highest attention. Still a thermometer — not a trade signal.</i>",
))
elif previous_confluence != confluence:
# Falling out of confluence is a state change worth recording as the new
# baseline, but not worth a message.
_log_alert(db, CONFLUENCE_TYPE, confluence)
return out
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
@@ -961,6 +1064,11 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
# cooldown/hysteresis handled in the collector (like score drops)
for key, text in await _collect_regime_quadrant(db):
outgoing.append((QUAD_TYPE, key, text))
# Deliberately three separate messages off one toggle, not one fused
# signal: the market axes and the fundamental channel are different kinds
# of evidence, and an operator needs to know which one moved.
for alert_type, key, text in await _collect_regime_fundamental(db):
outgoing.append((alert_type, key, text))
if cfg["trade_closed"]:
for key, text, pnl_usd in await _collect_closed_trades(db):