feat(aem): per-bucket MAE_MULT table + shadow logging completeness
AEM stays shadow-only this sprint (V7 drives live exits); changes affect what AEM *would have done*, logged to CH for future V7→AEM demotion analysis. adaptive_exit_engine.py: - Replace single MAE_MULT_TIER1=3.5 with MAE_MULT_BY_BUCKET dict (B3→None disables MAE stop, B4→2.0 strict, B6→6.0 wide band) - evaluate() return dict extended: mae_mult_applied, mae_threshold, atr, p_threshold, giveback_k (all params that drove the decision) - adaptive_exit_shadow schema extended (new Nullable columns added via ALTER TABLE IF NOT EXISTS — backward-compat with pre-sprint rows) - log_shadow() signature extended: v7_action, v7_exit_reason, naive_would_have (TP/STOP/MAX_HOLD counterfactual at same instant) dolphin_actor.py: - AEM shadow call now passes V7 head-to-head decision and naive counterfactual so future retrospective requires no offline replay - EsoF listener registered on DOLPHIN_FEATURES map (esof_advisor_latest key); label fed into engine._current_esof_label before each step_bar - S6/bucket loaders (_load_s6_size_table, _load_asset_bucket_data) and constructor wiring for the new GREEN engine kwargs Plan refs: Tasks 5, 7, 10 — V7 path untouched, AEM return value is never gated, CH shadow is best-effort (daemon thread). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,7 +35,7 @@ import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
from typing import Dict, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -45,11 +45,28 @@ from adaptive_exit.continuation_model import ContinuationModelBank, FEATURE_COLS
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
P_THRESHOLD = 0.40 # P(continuation) below this → consider exit
|
||||
GIVEBACK_K = 0.50 # MFE giveback fraction
|
||||
MAE_MULT_TIER1 = 3.5 # vol multiplier for tier-1 stop
|
||||
MAE_MULT_TIER1 = 3.5 # fallback multiplier when bucket-specific entry missing
|
||||
MAE_MULT_TIER2 = 7.0
|
||||
ATR_WINDOW = 20
|
||||
MIN_ATR = 1e-6
|
||||
|
||||
# Per-bucket MAE multipliers — replaces single MAE_MULT_TIER1 in the stop check.
|
||||
# Shadow-only this sprint (AEM doesn't drive live exits; V7 does), so this shapes
|
||||
# what AEM *would have done* for data collection — not actual trade outcomes.
|
||||
# `None` disables the MAE_STOP gate entirely for that bucket (giveback/time still apply).
|
||||
# B3 — natural winners; shadow shows 5.0–5.1 MAE peaks before FIXED_TP succeeds
|
||||
# B4 — gross-negative alpha; cut fast before drawdown compounds
|
||||
# B6 — extreme-vol assets; wide band or we trip on noise
|
||||
MAE_MULT_BY_BUCKET: Dict[int, Optional[float]] = {
|
||||
0: 3.5,
|
||||
1: 3.0,
|
||||
2: 3.5,
|
||||
3: None,
|
||||
4: 2.0,
|
||||
5: 4.0,
|
||||
6: 6.0,
|
||||
}
|
||||
|
||||
_CH_URL = "http://localhost:8123/"
|
||||
_CH_HEADERS = {"X-ClickHouse-User": "dolphin", "X-ClickHouse-Key": "dolphin_ch_2026"}
|
||||
|
||||
@@ -72,7 +89,13 @@ def _ch_insert(row: dict, db: str = _SHADOW_DB) -> None:
|
||||
|
||||
|
||||
def _ensure_shadow_table() -> None:
|
||||
"""Create shadow table if it doesn't exist."""
|
||||
"""Create shadow table if it doesn't exist.
|
||||
|
||||
Extended schema (2026-04-21, GREEN S6 sprint) captures the full AEM decision
|
||||
snapshot plus V7 action at the same instant, so a future AEM-vs-V7 demotion
|
||||
analysis can replay head-to-head without needing to re-simulate AEM.
|
||||
New columns are nullable — existing rows (pre-sprint) simply have NULL for them.
|
||||
"""
|
||||
ddl = (
|
||||
f"CREATE TABLE IF NOT EXISTS {_SHADOW_DB}.{_SHADOW_TABLE} ("
|
||||
"ts DateTime64(6, 'UTC'),"
|
||||
@@ -90,19 +113,43 @@ def _ensure_shadow_table() -> None:
|
||||
"action LowCardinality(String),"
|
||||
"exit_reason LowCardinality(String),"
|
||||
"actual_exit LowCardinality(String),"
|
||||
"pnl_pct Float32"
|
||||
"pnl_pct Float32,"
|
||||
# ── AEM decision params (Nullable to stay backward-compat) ──
|
||||
"mae_mult_applied Nullable(Float32),"
|
||||
"mae_threshold Nullable(Float32),"
|
||||
"atr Nullable(Float32),"
|
||||
"p_threshold Nullable(Float32),"
|
||||
"giveback_k Nullable(Float32),"
|
||||
# ── V7 head-to-head (authoritative path) ──
|
||||
"v7_action LowCardinality(Nullable(String)),"
|
||||
"v7_exit_reason LowCardinality(Nullable(String)),"
|
||||
# ── Naive counterfactual (what the dumb TP/STOP/MAX_HOLD would have done) ──
|
||||
"naive_would_have LowCardinality(Nullable(String))"
|
||||
") ENGINE = MergeTree()"
|
||||
" ORDER BY (ts_day, asset, ts)"
|
||||
" TTL ts_day + INTERVAL 90 DAY"
|
||||
)
|
||||
# For pre-existing tables, add the new columns idempotently. CH treats
|
||||
# ADD COLUMN IF NOT EXISTS as a no-op when the column is already present.
|
||||
alters = [
|
||||
f"ALTER TABLE {_SHADOW_DB}.{_SHADOW_TABLE} ADD COLUMN IF NOT EXISTS mae_mult_applied Nullable(Float32)",
|
||||
f"ALTER TABLE {_SHADOW_DB}.{_SHADOW_TABLE} ADD COLUMN IF NOT EXISTS mae_threshold Nullable(Float32)",
|
||||
f"ALTER TABLE {_SHADOW_DB}.{_SHADOW_TABLE} ADD COLUMN IF NOT EXISTS atr Nullable(Float32)",
|
||||
f"ALTER TABLE {_SHADOW_DB}.{_SHADOW_TABLE} ADD COLUMN IF NOT EXISTS p_threshold Nullable(Float32)",
|
||||
f"ALTER TABLE {_SHADOW_DB}.{_SHADOW_TABLE} ADD COLUMN IF NOT EXISTS giveback_k Nullable(Float32)",
|
||||
f"ALTER TABLE {_SHADOW_DB}.{_SHADOW_TABLE} ADD COLUMN IF NOT EXISTS v7_action LowCardinality(Nullable(String))",
|
||||
f"ALTER TABLE {_SHADOW_DB}.{_SHADOW_TABLE} ADD COLUMN IF NOT EXISTS v7_exit_reason LowCardinality(Nullable(String))",
|
||||
f"ALTER TABLE {_SHADOW_DB}.{_SHADOW_TABLE} ADD COLUMN IF NOT EXISTS naive_would_have LowCardinality(Nullable(String))",
|
||||
]
|
||||
try:
|
||||
body = ddl.encode()
|
||||
req = urllib.request.Request(_CH_URL, data=body, method="POST")
|
||||
for k, v in _CH_HEADERS.items():
|
||||
req.add_header(k, v)
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
for stmt in (ddl, *alters):
|
||||
body = stmt.encode()
|
||||
req = urllib.request.Request(_CH_URL, data=body, method="POST")
|
||||
for k, v in _CH_HEADERS.items():
|
||||
req.add_header(k, v)
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception as e:
|
||||
print(f"[AdaptiveExitEngine] Warning: could not create shadow table: {e}")
|
||||
print(f"[AdaptiveExitEngine] Warning: could not create/alter shadow table: {e}")
|
||||
|
||||
|
||||
# ── Per-trade state ───────────────────────────────────────────────────────────
|
||||
@@ -324,12 +371,14 @@ class AdaptiveExitEngine:
|
||||
bucket_id=st.bucket_id,
|
||||
)
|
||||
|
||||
# Decision logic
|
||||
mae_threshold = max(0.005, MAE_MULT_TIER1 * atr)
|
||||
# Decision logic — per-bucket MAE multiplier. `None` entry disables the
|
||||
# MAE_STOP gate for that bucket (giveback + time checks still apply).
|
||||
mae_mult = MAE_MULT_BY_BUCKET.get(st.bucket_id, MAE_MULT_TIER1)
|
||||
mae_threshold = max(0.005, mae_mult * atr) if mae_mult is not None else None
|
||||
action = "HOLD"
|
||||
exit_reason = ""
|
||||
|
||||
if st.mae > mae_threshold:
|
||||
if mae_threshold is not None and st.mae > mae_threshold:
|
||||
action = "EXIT"
|
||||
exit_reason = "AE_MAE_STOP"
|
||||
elif (st.peak_mfe > 0 and st.mfe < GIVEBACK_K * st.peak_mfe
|
||||
@@ -352,10 +401,31 @@ class AdaptiveExitEngine:
|
||||
"bucket_id": st.bucket_id,
|
||||
"vel_div_entry": st.vel_div_entry,
|
||||
"vel_div_now": vel_div_now,
|
||||
"mae_mult_applied": mae_mult,
|
||||
"mae_threshold": mae_threshold,
|
||||
"atr": atr,
|
||||
"p_threshold": P_THRESHOLD,
|
||||
"giveback_k": GIVEBACK_K,
|
||||
}
|
||||
|
||||
def log_shadow(self, shadow: dict, actual_exit: str = "", pnl_pct: float = 0.0) -> None:
|
||||
"""Async log a shadow decision to ClickHouse."""
|
||||
def log_shadow(
|
||||
self,
|
||||
shadow: dict,
|
||||
actual_exit: str = "",
|
||||
pnl_pct: float = 0.0,
|
||||
v7_action: Optional[str] = None,
|
||||
v7_exit_reason: Optional[str] = None,
|
||||
naive_would_have: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Async log a shadow decision to ClickHouse.
|
||||
|
||||
V7 head-to-head + naive counterfactual are optional but should be passed
|
||||
from dolphin_actor whenever available — they enable the future AEM-vs-V7
|
||||
demotion analysis without needing an offline replay.
|
||||
"""
|
||||
def _opt(v):
|
||||
return None if v is None else float(v)
|
||||
|
||||
row = {
|
||||
"ts": int(time.time() * 1e6),
|
||||
"trade_id": shadow.get("trade_id", ""),
|
||||
@@ -372,5 +442,15 @@ class AdaptiveExitEngine:
|
||||
"exit_reason": shadow.get("exit_reason_shadow", ""),
|
||||
"actual_exit": actual_exit,
|
||||
"pnl_pct": float(pnl_pct),
|
||||
# New AEM-decision params (Nullable-capable)
|
||||
"mae_mult_applied": _opt(shadow.get("mae_mult_applied")),
|
||||
"mae_threshold": _opt(shadow.get("mae_threshold")),
|
||||
"atr": _opt(shadow.get("atr")),
|
||||
"p_threshold": _opt(shadow.get("p_threshold")),
|
||||
"giveback_k": _opt(shadow.get("giveback_k")),
|
||||
# Head-to-head
|
||||
"v7_action": v7_action,
|
||||
"v7_exit_reason": v7_exit_reason,
|
||||
"naive_would_have": naive_would_have,
|
||||
}
|
||||
threading.Thread(target=_ch_insert, args=(row,), daemon=True).start()
|
||||
|
||||
Reference in New Issue
Block a user