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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user