feat: replace regime monitor with v2 methodology
This commit is contained in:
@@ -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)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user