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:
hjnormey
2026-04-22 06:07:46 +02:00
parent af5156f52d
commit 48dcf3fe13
2 changed files with 232 additions and 18 deletions

View File

@@ -5,6 +5,7 @@ import threading
import time
from collections import deque, namedtuple
from datetime import datetime, timezone
from typing import Optional
import numpy as np
import pandas as pd
from pathlib import Path
@@ -136,6 +137,7 @@ class DolphinActor(Strategy):
self._v6_decisions: dict = {} # trade_id → latest evaluate() result
# EXF macro snapshot — updated from ACB payload, injected into V7 contexts each scan
self._last_exf: dict = {} # keys: funding, dvol, fear_greed, taker
self._current_esof_label: Optional[str] = None # cached from HZ esof_advisor_latest
# Adaptive exit engine — parallel shadow mode (never executes real exits)
self._adaptive_exit = None
# Stablecoin symbols ÔÇö kept in eigen for purity, hard-blocked at picker
@@ -147,7 +149,58 @@ class DolphinActor(Strategy):
self.btc_prices: deque = deque(maxlen=BTC_VOL_WINDOW + 2)
self._bucket_assignments: dict = {}
self._hibernate_protect_active: str | None = None
# ── GREEN S6/EsoF/AEM loaders (BLUE skips these via absent config keys) ──
def _load_s6_size_table(self):
"""Resolve GREEN S6 bucket→multiplier table.
Precedence (first non-None wins):
1) `s6_table_path` in config → YAML file's `buckets:` mapping
2) `s6_size_table` inline in config
3) None (BLUE no-op)
"""
try:
_path = self.dolphin_config.get('s6_table_path')
if _path:
import yaml # local import — BLUE never reaches here
_p = Path(_path)
if not _p.is_absolute():
_p = Path.cwd() / _p
if _p.exists():
with open(_p, 'r') as _f:
_doc = yaml.safe_load(_f) or {}
_b = _doc.get('buckets')
if isinstance(_b, dict):
return {int(k): float(v) for k, v in _b.items()}
_inline = self.dolphin_config.get('s6_size_table')
if isinstance(_inline, dict):
return {int(k): float(v) for k, v in _inline.items()}
except Exception as _e:
self.log.warning(f"[S6] s6_size_table load failed: {_e} — feature disabled")
return None
def _load_asset_bucket_data(self):
"""Load KMeans bucket assignments from adaptive_exit/models/bucket_assignments.pkl.
Returns the authoritative `{"assignments": {symbol: int, ...}, ...}` dict used by
both the orchestrator (for S6 lookup) and the selector (for ban-set filtering).
"""
try:
import pickle
_path = self.dolphin_config.get(
'asset_bucket_pkl',
'adaptive_exit/models/bucket_assignments.pkl',
)
_p = Path(_path)
if not _p.is_absolute():
_p = Path.cwd() / _p
if _p.exists():
with open(_p, 'rb') as _f:
return pickle.load(_f)
except Exception as _e:
self.log.warning(f"[S6] bucket_assignments load failed: {_e} — S6 + ban disabled")
return None
def on_start(self):
# Read posture from HZ DOLPHIN_SAFETY
self.hz_client = self._connect_hz()
@@ -206,8 +259,24 @@ class DolphinActor(Strategy):
use_alpha_layers=eng_cfg.get('use_alpha_layers', True),
use_dynamic_leverage=eng_cfg.get('use_dynamic_leverage', True),
seed=eng_cfg.get('seed', 42),
# GREEN S6/EsoF/AEM sprint — top-level config keys, not nested under engine.
# BLUE leaves these unset → orchestrator reads None/False → BLUE no-op.
s6_size_table=self._load_s6_size_table(),
esof_sizing_table=self.dolphin_config.get('esof_sizing_table'),
asset_bucket_data=self._load_asset_bucket_data(),
use_int_leverage=bool(self.dolphin_config.get('use_int_leverage', False)),
)
self.engine = create_boost_engine(mode=boost_mode, **_engine_kwargs)
# Wire asset selector ban-set (shared file, BLUE-invariant when ban_set is None).
_ban = self.dolphin_config.get('asset_bucket_ban_set')
_bucket_data = _engine_kwargs.get('asset_bucket_data') or {}
if _ban:
try:
self.engine.asset_selector.asset_bucket_ban_set = set(int(b) for b in _ban)
self.engine.asset_selector.asset_bucket_assignments = dict(_bucket_data.get('assignments', {}))
except Exception as _e:
self.log.warning(f"[S6] Failed to wire asset_bucket_ban_set: {_e}")
self.engine.set_esoteric_hazard_multiplier(0.0) # gold spec: init guard, MUST precede set_mc_forewarner
# == MC-Forewarner injection ===========================================
@@ -509,10 +578,34 @@ class DolphinActor(Strategy):
added_func=self._on_scan_event,
updated_func=self._on_scan_event,
)
self.log.info("[HZ] Push listeners registered: acb_boost + latest_eigen_scan")
# EsoF advisor listener — feeds orchestrator regime gate at _try_entry.
# Callback is zero-work (JSON parse + dict write); the label is consumed
# just before step_bar on the timer thread.
features.add_entry_listener(
include_value=True,
key='esof_advisor_latest',
added_func=self._on_esof_event,
updated_func=self._on_esof_event,
)
self.log.info("[HZ] Push listeners registered: acb_boost + latest_eigen_scan + esof_advisor_latest")
except Exception as e:
self.log.error(f"Failed to setup ACB listener: {e}")
def _on_esof_event(self, event):
"""Cache EsoF label for the orchestrator regime gate. Tolerates stale JSON
schema — on any parse error we fall back to no label (orchestrator treats
None as UNKNOWN, the new renamed conflict-state default)."""
try:
val = event.value
if not val:
return
parsed = json.loads(val) if isinstance(val, str) else val
label = parsed.get('advisory_label') if isinstance(parsed, dict) else None
if isinstance(label, str) and label:
self._current_esof_label = label
except Exception:
self._current_esof_label = None
def _on_scan_event(self, event):
"""HZ reactor-thread callback -- fires immediately when NG7 writes to HZ.
Zero-work: stores raw string + sets edge-trigger flag. No JSON parsing,
@@ -683,6 +776,13 @@ class DolphinActor(Strategy):
getattr(self.engine, '_mc_gate_open', True),
)
# Feed EsoF label into orchestrator — consumed by regime gate at _try_entry top.
# Engine tolerates None (treated as UNKNOWN under the NEUTRAL→UNKNOWN rename).
try:
self.engine._current_esof_label = self._current_esof_label
except Exception:
pass
_step_start = time.monotonic()
try:
result = self.engine.step_bar(
@@ -1536,7 +1636,41 @@ class DolphinActor(Strategy):
exf=self._last_exf,
)
_shadow['asset'] = _pend_ae['asset']
self._adaptive_exit.log_shadow(_shadow)
# V7 head-to-head: pull the authoritative-path decision for
# the same trade at the same instant (may be None if V7 not
# wired or hasn't decided yet — log_shadow tolerates None).
_v7_dec_ae = self._v6_decisions.get(_tid_ae) or {}
_v7_action = _v7_dec_ae.get('action')
_v7_exit_reason = _v7_dec_ae.get('reason')
# Naive counterfactual: pure TP/STOP/MAX_HOLD at this bar.
_naive_would_have: Optional[str] = None
try:
_eng_cfg_ae = self.dolphin_config.get('engine', {})
_tp = float(_eng_cfg_ae.get('fixed_tp_pct', 0.0095))
_sl = float(_eng_cfg_ae.get('stop_pct', 0.0)) or 0.0
_mh = int(_eng_cfg_ae.get('max_hold_bars', 120))
_dir_ae = -1 if _pend_ae['side'] == 'SHORT' else 1
_ep_ae = float(_pend_ae['entry_price'] or 0.0)
_pnl_naive = (_dir_ae * (_ep_ae - float(_cur_px_ae)) / _ep_ae) if _ep_ae > 0 else 0.0
if _pnl_naive >= _tp:
_naive_would_have = 'TP'
elif _sl > 0 and _pnl_naive <= -_sl:
_naive_would_have = 'STOP'
elif _bars_ae >= _mh:
_naive_would_have = 'MAX_HOLD'
else:
_naive_would_have = 'HOLD'
except Exception:
_naive_would_have = None
self._adaptive_exit.log_shadow(
_shadow,
v7_action=_v7_action,
v7_exit_reason=_v7_exit_reason,
naive_would_have=_naive_would_have,
)
except Exception:
pass # shadow must never affect live trading