feat: replace regime monitor with v2 methodology

This commit is contained in:
2026-07-15 09:02:56 +02:00
parent fd21067a40
commit 1d5b1489be
17 changed files with 1599 additions and 1535 deletions
+99 -40
View File
@@ -58,7 +58,9 @@ _BOOL_DEFAULTS = {
KEY_SR: True,
KEY_SCORE_DROP: True,
KEY_DIGEST: True,
KEY_REGIME_QUADRANT: True,
# Experimental human-facing thermometer: opt in explicitly. Existing stored
# true values remain true; only missing/reset configurations default off.
KEY_REGIME_QUADRANT: False,
KEY_TRADE_CLOSED: True,
}
@@ -90,19 +92,19 @@ SIGNAL_BUNDLE_SECTIONS = (
)
SIGNAL_BUNDLE_MAX_CHARS = 3900 # Telegram limit is 4096; keep room for HTML parsing
# Regime quadrant-change alert: (regime index x early-warning) quadrant.
# Regime quadrant-change alert: (State x Warning) quadrant.
# 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"
QUAD_X_DIV = 40.0 # regime index divider (matches the frontend quadrant)
QUAD_Y_DIV = 60.0 # early-warning divider
QUAD_X_DIV = 60.0 # v2 State divider (backend response is authoritative)
QUAD_Y_DIV = 60.0 # v2 Warning divider
QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider
QUAD_COOLDOWN_DAYS = 3 # min days between quadrant-change alerts
QUAD_LABELS = {
"1": "① Hot & brittle",
"2": "② Transition",
"3": "③ Healthy & broad",
"4": "④ Real downturn",
"1": "Early warning",
"2": "Active stress",
"3": "Healthy",
"4": "Stressed / stabilizing",
}
AlertItem = tuple[str, str, str] # alert_type, dedup_key, text
@@ -693,49 +695,65 @@ def _closed_trade_bundle(
def _bools_to_quadrant(x_high: bool, y_high: bool) -> str:
if y_high:
return "2" if x_high else "1" # ② Transition / ① Hot & brittle
return "4" if x_high else "3" # ④ Real downturn / Healthy & broad
return "2" if x_high else "1" # Active stress / Early warning
return "4" if x_high else "3" # Stressed/stabilizing / Healthy
def _quadrant_to_bools(q: str) -> tuple[bool, bool]:
return {"1": (False, True), "2": (True, True), "3": (False, False), "4": (True, False)}[q]
def _classify_quadrant(x: float, y: float, prev: str | None, margin: float = QUAD_MARGIN) -> str:
"""Quadrant of (regime index x, early warning y), with per-axis hysteresis.
def _classify_quadrant(
x: float,
y: float,
prev: str | None,
margin: float = QUAD_MARGIN,
x_div: float = QUAD_X_DIV,
y_div: float = QUAD_Y_DIV,
) -> str:
"""Quadrant of (State x, Warning y), with per-axis hysteresis.
Each axis only flips once the value crosses its divider by ``margin`` in the
new direction, so a point parked on a divider keeps its current quadrant
instead of flip-flopping. ``prev`` None means a fresh (no-hysteresis) classify.
"""
if prev is None:
return _bools_to_quadrant(x >= QUAD_X_DIV, y >= QUAD_Y_DIV)
return _bools_to_quadrant(x >= x_div, y >= y_div)
px, py = _quadrant_to_bools(prev)
x_high = (x >= QUAD_X_DIV - margin) if px else (x >= QUAD_X_DIV + margin)
y_high = (y >= QUAD_Y_DIV - margin) if py else (y >= QUAD_Y_DIV + margin)
x_high = (x >= x_div - margin) if px else (x >= x_div + margin)
y_high = (y >= y_div - margin) if py else (y >= y_div + margin)
return _bools_to_quadrant(x_high, y_high)
def _quadrant_log_key(q: str, x: float, y: float) -> str:
return f"{q}:{x:.1f}:{y:.1f}"
def _quadrant_log_key(q: str, x: float, y: float, basket_hash: str | None = None) -> str:
return f"{basket_hash or 'legacy'}:{q}:{x:.1f}:{y:.1f}"
def _parse_quadrant_log_key(key: str | None) -> tuple[str | None, float | None, float | None]:
def _parse_quadrant_log_key(
key: str | None,
) -> tuple[str | None, str | None, float | None, float | None]:
if not key:
return None, None, None
return None, None, None, None
parts = key.split(":")
q = parts[0]
if parts[0] in QUAD_LABELS:
basket_hash, q, values = None, parts[0], parts[1:]
elif len(parts) >= 2:
basket_hash, q, values = parts[0], parts[1], parts[2:]
else:
return None, None, None, None
if q not in QUAD_LABELS:
return None, None, None
if len(parts) >= 3:
return None, None, None, None
if len(values) >= 2:
try:
return q, float(parts[1]), float(parts[2])
return basket_hash, q, float(values[0]), float(values[1])
except ValueError:
pass
return q, None, None
return basket_hash, q, None, None
async def _last_quadrant(db: AsyncSession) -> tuple[str | None, float | None, float | None, datetime | None]:
async def _last_quadrant(
db: AsyncSession,
) -> tuple[str | None, str | None, float | None, float | None, datetime | None]:
"""Most recently logged quadrant (and when), our baseline for change + cooldown."""
result = await db.execute(
select(AlertLog.dedup_key, AlertLog.created_at)
@@ -745,9 +763,9 @@ async def _last_quadrant(db: AsyncSession) -> tuple[str | None, float | None, fl
)
row = result.first()
if not row:
return None, None, None, None
prev_q, prev_x, prev_y = _parse_quadrant_log_key(row[0])
return prev_q, prev_x, prev_y, row[1]
return None, None, None, None, None
basket_hash, prev_q, prev_x, prev_y = _parse_quadrant_log_key(row[0])
return basket_hash, prev_q, prev_x, prev_y, row[1]
async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
@@ -758,25 +776,64 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
cooldown has elapsed. The dispatch loop logs the new quadrant on send, which
becomes the next baseline and resets the cooldown clock.
"""
from app.services.regime_monitor_service import get_regime_monitor
from app.services.regime_monitor_service import get_regime_history, get_regime_monitor
data = await get_regime_monitor(db)
if not data.get("available"):
return []
x = data.get("total_score")
y = (data.get("early_warning") or {}).get("score")
state = data.get("state") or {}
warning = data.get("warning") or {}
x = state.get("score")
y = warning.get("score")
if x is None or y is None:
return []
prev, prev_x, prev_y, prev_time = await _last_quadrant(db)
if prev is None:
_log_alert(db, QUAD_TYPE, _quadrant_log_key(_classify_quadrant(x, y, None), x, y)) # seed, no alert
quality = data.get("data_quality") or {}
if (
float(state.get("coverage") or 0) < 75
or float(warning.get("coverage") or 0) < 75
or not quality.get("is_fresh")
):
return []
new_q = _classify_quadrant(x, y, prev)
quadrant_cfg = data.get("quadrant_config") or {}
x_div = float(quadrant_cfg.get("state_divider", QUAD_X_DIV))
y_div = float(quadrant_cfg.get("warning_divider", QUAD_Y_DIV))
margin = float(quadrant_cfg.get("margin", QUAD_MARGIN))
basket_hash = str((data.get("basket") or {}).get("hash") or "unknown")
prev_hash, prev, prev_x, prev_y, prev_time = await _last_quadrant(db)
if prev is None or prev_hash != basket_hash:
seed = _classify_quadrant(x, y, None, margin, x_div, y_div)
_log_alert(db, QUAD_TYPE, _quadrant_log_key(seed, x, y, basket_hash))
return []
new_q = _classify_quadrant(x, y, prev, margin, x_div, y_div)
if new_q == prev:
return []
history = await get_regime_history(db, days=14)
valid = [
point for point in history
if point.get("state") is not None
and point.get("warning") is not None
and float(point.get("state_coverage") or 0) >= 75
and float(point.get("warning_coverage") or 0) >= 75
]
if len(valid) < 2:
return []
prior = valid[-2]
prior_q = _classify_quadrant(
float(prior["state"]),
float(prior["warning"]),
prev,
margin,
x_div,
y_div,
)
if prior_q != new_q:
return []
if prev_time is not None:
if prev_time.tzinfo is None:
prev_time = prev_time.replace(tzinfo=timezone.utc)
@@ -785,17 +842,19 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
if prev_x is not None and prev_y is not None:
metrics = (
f"regime {prev_x:.0f}{x:.0f} ({x - prev_x:+.0f}) · "
f"early-warning {prev_y:.0f}{y:.0f} ({y - prev_y:+.0f})"
f"State {prev_x:.0f}{x:.0f} ({x - prev_x:+.0f}) · "
f"Warning {prev_y:.0f}{y:.0f} ({y - prev_y:+.0f})"
)
else:
metrics = f"regime {x:.0f} · early-warning {y:.0f}"
metrics = f"State {x:.0f} · Warning {y:.0f}"
text = (
f"🧭 <b>Regime quadrant change</b>\n"
f"{QUAD_LABELS.get(prev, prev)}{QUAD_LABELS.get(new_q, new_q)}\n"
f"{metrics}"
f"{metrics}\n"
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), text)]
return [(_quadrant_log_key(new_q, x, y, basket_hash), text)]
# ---------------------------------------------------------------------------