Checkpoint BLUE V7 long overlay work

This commit is contained in:
Codex
2026-05-08 19:54:13 +02:00
parent 351ce2044d
commit 83f007caa8
6 changed files with 5850 additions and 0 deletions

View File

@@ -0,0 +1,315 @@
"""Calibrate AlphaExitEngineV7 thresholds for synthetic LONG EFSM paths.
This script replays BLUE V7 decision journal price paths with side inverted to
LONG. It follows the original V7 SHORT calibration pattern:
1. Reconstruct per-trade path from V7 journal rows.
2. Compute the natural end-of-path LONG outcome.
3. Replay AlphaExitEngineV7 on the same path using side=LONG.
4. Sweep configurable threshold surfaces.
5. Compare the first V7 EXIT against the natural end outcome.
The output is a calibration proxy for EFSM FLIP_LONG trades, not proof from
actual exchange-filled LONG trades.
"""
from __future__ import annotations
import argparse
import base64
import csv
import json
import logging
import math
import sys
import urllib.request
from collections import defaultdict
from dataclasses import asdict
from pathlib import Path
from statistics import fmean
from typing import Any
ROOT = Path("/mnt/dolphinng5_predict")
sys.path.insert(0, str(ROOT / "nautilus_dolphin"))
sys.path.insert(1, str(ROOT))
from nautilus_dolphin.nautilus.alpha_exit_v7_engine import AlphaExitEngineV7, AlphaExitV7Config # noqa: E402
CH_URL = "http://localhost:8123/?database=dolphin"
AUTH = "Basic " + base64.b64encode(b"dolphin:dolphin_ch_2026").decode()
FEE_PCT = 0.0004
logging.getLogger("nautilus_dolphin.nautilus.alpha_exit_v7_engine").setLevel(logging.ERROR)
def _query(sql: str) -> str:
req = urllib.request.Request(CH_URL, data=sql.encode(), headers={"Authorization": AUTH})
return urllib.request.urlopen(req, timeout=60).read().decode()
def load_v7_rows(limit_trades: int = 0) -> list[dict[str, Any]]:
trade_filter = ""
if limit_trades > 0:
trade_filter = (
"AND trade_id IN ("
"SELECT trade_id FROM ("
"SELECT trade_id, max(ts) AS mx FROM v7_decision_events "
"WHERE strategy='blue' AND side='SHORT' GROUP BY trade_id ORDER BY mx DESC "
f"LIMIT {int(limit_trades)}"
"))"
)
sql = f"""
SELECT
ts, trade_id, asset, entry_price, current_price, quantity, leverage,
bar_idx, decision_seq, bars_held, ob_imbalance,
exf_funding, exf_dvol, exf_fear_greed, exf_taker
FROM v7_decision_events
WHERE strategy='blue' AND side='SHORT' {trade_filter}
ORDER BY trade_id ASC, decision_seq ASC, ts ASC
FORMAT CSVWithNames
"""
text = _query(sql)
rows: list[dict[str, Any]] = []
for r in csv.DictReader(text.splitlines()):
rows.append({
"ts": r["ts"],
"trade_id": r["trade_id"],
"asset": r["asset"],
"entry_price": float(r["entry_price"] or 0.0),
"current_price": float(r["current_price"] or 0.0),
"quantity": float(r["quantity"] or 0.0),
"leverage": float(r["leverage"] or 0.0),
"bar_idx": int(float(r["bar_idx"] or 0)),
"decision_seq": int(float(r["decision_seq"] or 0)),
"bars_held": int(float(r["bars_held"] or 0)),
"ob_imbalance": float(r["ob_imbalance"] or 0.0),
"exf_funding": float(r["exf_funding"] or 0.0),
"exf_dvol": float(r["exf_dvol"] or 0.0),
"exf_fear_greed": float(r["exf_fear_greed"] or 0.0),
"exf_taker": float(r["exf_taker"] or 0.0),
})
return rows
def group_paths(rows: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
grouped[row["trade_id"]].append(row)
paths = []
for path in grouped.values():
path.sort(key=lambda r: (r["decision_seq"], r["bar_idx"], r["ts"]))
clean = [r for r in path if r["entry_price"] > 0 and r["current_price"] > 0]
if len(clean) >= 2:
paths.append(clean)
paths.sort(key=lambda p: p[-1]["ts"])
return paths
def natural_long_return(path: list[dict[str, Any]]) -> float:
entry = path[0]["entry_price"]
last = path[-1]["current_price"]
return (last - entry) / entry - FEE_PCT if entry > 0 else 0.0
def pnl_dollars(path: list[dict[str, Any]], ret: float) -> float:
notional = abs(path[0]["entry_price"] * path[0]["quantity"])
return notional * ret
def replay_path(path: list[dict[str, Any]], cfg: AlphaExitV7Config) -> dict[str, Any]:
engine = AlphaExitEngineV7(
bar_duration_sec=11.0,
bounce_model_path="/tmp/nonexistent-bounce-model.pkl",
config=cfg,
)
ctx = engine.make_context(entry_price=path[0]["entry_price"], entry_bar=path[0]["bar_idx"], side=0)
first_exit = None
decisions = []
for row in path:
if hasattr(ctx, "set_exf"):
ctx.set_exf(
funding=row["exf_funding"],
dvol=row["exf_dvol"],
fear_greed=row["exf_fear_greed"],
taker=row["exf_taker"],
)
dec = engine.evaluate(
ctx,
current_price=row["current_price"],
current_bar=row["bar_idx"],
ob_imbalance=row["ob_imbalance"],
asset=row["asset"],
)
decisions.append(dec)
if first_exit is None and dec["action"] == "EXIT":
first_exit = (row, dec)
break
nat_ret = natural_long_return(path)
if first_exit is None:
exit_ret = nat_ret
exit_row = path[-1]
exit_dec = decisions[-1]
exited = False
else:
exit_row, exit_dec = first_exit
exit_ret = (exit_row["current_price"] - path[0]["entry_price"]) / path[0]["entry_price"] - FEE_PCT
exited = True
return {
"trade_id": path[0]["trade_id"],
"asset": path[0]["asset"],
"n_rows": len(path),
"natural_ret": nat_ret,
"natural_pnl": pnl_dollars(path, nat_ret),
"exit_ret": exit_ret,
"exit_pnl": pnl_dollars(path, exit_ret),
"delta_pnl": pnl_dollars(path, exit_ret) - pnl_dollars(path, nat_ret),
"exited": exited,
"exit_action": exit_dec.get("action"),
"exit_reason": exit_dec.get("reason") or "",
"exit_pressure": float(exit_dec.get("exit_pressure", 0.0) or 0.0),
"exit_bars_held": int(exit_dec.get("bars_held", 0) or 0),
"exit_mae": float(exit_dec.get("mae", 0.0) or 0.0),
"exit_mfe": float(exit_dec.get("mfe", 0.0) or 0.0),
"exit_mae_risk": float(exit_dec.get("mae_risk", 0.0) or 0.0),
"exit_mfe_risk": float(exit_dec.get("mfe_risk", 0.0) or 0.0),
}
def equity_stats(vals: list[float]) -> dict[str, float]:
eq = 1.0
peak = 1.0
dd = 0.0
for r in vals:
eq *= max(0.0, 1.0 + r)
peak = max(peak, eq)
dd = max(dd, (peak - eq) / peak if peak else 0.0)
return {
"n": len(vals),
"wr": sum(1 for r in vals if r > 0) / len(vals) if vals else 0.0,
"mean": fmean(vals) if vals else 0.0,
"compound": eq - 1.0,
"max_dd": dd,
}
def summarize(results: list[dict[str, Any]], cfg: AlphaExitV7Config, name: str) -> dict[str, Any]:
natural_rets = [r["natural_ret"] for r in results]
exit_rets = [r["exit_ret"] for r in results]
deltas = [r["delta_pnl"] for r in results]
return {
"name": name,
"config": asdict(cfg),
"n": len(results),
"exits": sum(1 for r in results if r["exited"]),
"exit_rate": sum(1 for r in results if r["exited"]) / len(results) if results else 0.0,
"natural": {
**equity_stats(natural_rets),
"pnl": sum(r["natural_pnl"] for r in results),
},
"v7": {
**equity_stats(exit_rets),
"pnl": sum(r["exit_pnl"] for r in results),
},
"delta_pnl": sum(deltas),
"positive_delta_trades": sum(1 for d in deltas if d > 0),
"negative_delta_trades": sum(1 for d in deltas if d < 0),
"avg_exit_pressure": fmean([r["exit_pressure"] for r in results if r["exited"]]) if any(r["exited"] for r in results) else 0.0,
"reasons": dict(sorted({
reason: sum(1 for r in results if r["exit_reason"] == reason)
for reason in {r["exit_reason"] for r in results}
}.items())),
}
def candidate_configs() -> list[tuple[str, AlphaExitV7Config]]:
out = [("short_default", AlphaExitV7Config())]
for threshold in [1.4, 1.7, 2.0, 2.35, 2.69, 3.0]:
out.append((f"exit_p{threshold}", AlphaExitV7Config(exit_pressure_threshold=threshold)))
for tier_scale in [0.5, 0.75, 1.0, 1.25, 1.5]:
out.append((
f"mae_scale_{tier_scale}",
AlphaExitV7Config(
mae_tier1_k=3.5 * tier_scale,
mae_tier2_k=7.0 * tier_scale,
mae_tier3_k=12.0 * tier_scale,
mae_tier1_floor=0.005 * tier_scale,
mae_tier2_floor=0.012 * tier_scale,
mae_tier3_floor=0.025 * tier_scale,
),
))
for mfe_scale in [0.5, 0.75, 1.25, 1.5]:
out.append((
f"mfe_risk_scale_{mfe_scale}",
AlphaExitV7Config(
mfe_convexity_exit_risk=1.5 * mfe_scale,
mfe_convexity_soft_risk=0.3 * mfe_scale,
mfe_accel_risk=0.2 * mfe_scale,
),
))
for late_start in [0.3, 0.45, 0.6, 0.75]:
out.append((f"late_start_{late_start}", AlphaExitV7Config(mae_late_start_frac=late_start)))
for threshold in [1.7, 2.0, 2.35]:
for mae_scale in [0.5, 0.75, 1.25]:
out.append((
f"combo_p{threshold}_mae{mae_scale}",
AlphaExitV7Config(
exit_pressure_threshold=threshold,
mae_tier1_k=3.5 * mae_scale,
mae_tier2_k=7.0 * mae_scale,
mae_tier3_k=12.0 * mae_scale,
mae_tier1_floor=0.005 * mae_scale,
mae_tier2_floor=0.012 * mae_scale,
mae_tier3_floor=0.025 * mae_scale,
),
))
return out
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--limit-trades", type=int, default=0)
parser.add_argument("--out", default="/tmp/v7_long_calibration.json")
args = parser.parse_args()
rows = load_v7_rows(limit_trades=args.limit_trades)
paths = group_paths(rows)
summaries = []
for name, cfg in candidate_configs():
results = [replay_path(path, cfg) for path in paths]
summaries.append(summarize(results, cfg, name))
summaries.sort(key=lambda r: (r["delta_pnl"], r["v7"]["pnl"], -r["exits"]), reverse=True)
payload = {
"method": "Synthetic LONG replay of BLUE SHORT V7 decision journal paths; bounce model disabled.",
"input": {
"rows": len(rows),
"paths": len(paths),
"limit_trades": args.limit_trades,
},
"top_by_delta": summaries[:20],
"all": summaries,
}
Path(args.out).write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
print(json.dumps({
"input": payload["input"],
"top_by_delta": [
{
"name": s["name"],
"n": s["n"],
"exits": s["exits"],
"exit_rate": s["exit_rate"],
"natural_pnl": s["natural"]["pnl"],
"v7_pnl": s["v7"]["pnl"],
"delta_pnl": s["delta_pnl"],
"natural_compound": s["natural"]["compound"],
"v7_compound": s["v7"]["compound"],
"v7_dd": s["v7"]["max_dd"],
"reasons": s["reasons"],
}
for s in summaries[:12]
],
}, indent=2, sort_keys=True))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,351 @@
"""Deterministic post-win LONG overlay EFSM.
This module does not place orders. It tags future entries after realized BLUE
SHORT exhaustion wins so the live/shadow caller can decide whether to flip the
next one or more SHORT-engine opportunities to LONG.
EFSM means Execution FSM. The EFSM is deliberately slot-based:
- a trigger arms N future slots
- each future entry consumes exactly one slot
- when slots reach zero, state resets to SHORT
- flipped LONG trades do not re-arm the overlay
- triggers observed while an arm is active are ignored unless explicitly
enabled by config
That prevents the bug class where a one/two-trade rebound probe becomes a
self-extending regime switch.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Mapping, Optional, Sequence
def _to_float(value: Any, default: float = 0.0) -> float:
try:
out = float(value)
except (TypeError, ValueError):
return default
return out if out == out else default
def _to_utc(ts: datetime | None) -> datetime | None:
if ts is None:
return None
if ts.tzinfo is None:
return ts.replace(tzinfo=timezone.utc)
return ts.astimezone(timezone.utc)
@dataclass(frozen=True)
class PostWinFlipTrigger:
"""A configurable trigger that arms future LONG flip slots."""
name: str
slots: int
min_pnl_abs: float = 0.0
max_pnl_abs: Optional[float] = None
min_pnl_pct: Optional[float] = None
min_leverage: Optional[float] = None
strict_min_pnl_abs: bool = True
strict_max_pnl_abs: bool = True
strict_min_leverage: bool = True
def matches(self, *, pnl: float, pnl_pct: float, leverage: float) -> bool:
if self.slots <= 0:
return False
if self.strict_min_pnl_abs:
if not pnl > self.min_pnl_abs:
return False
elif pnl < self.min_pnl_abs:
return False
if self.max_pnl_abs is not None:
if self.strict_max_pnl_abs:
if not pnl < self.max_pnl_abs:
return False
elif pnl > self.max_pnl_abs:
return False
if self.min_pnl_pct is not None and pnl_pct < self.min_pnl_pct:
return False
if self.min_leverage is not None:
if self.strict_min_leverage:
if not leverage > self.min_leverage:
return False
elif leverage < self.min_leverage:
return False
return True
@dataclass(frozen=True)
class PostWinExecutionFSMConfig:
"""Configuration for the BLUE post-win Execution FSM."""
enabled: bool = True
rules: Sequence[PostWinFlipTrigger] = field(
default_factory=lambda: (
# Order matters: the high-leverage big-win rule must win before the
# generic big-win rule, otherwise it would be capped at one slot.
PostWinFlipTrigger(
name="big_win_high_lev",
slots=2,
min_pnl_abs=397.0,
min_leverage=8.6,
strict_min_pnl_abs=True,
strict_min_leverage=True,
),
PostWinFlipTrigger(
name="big_win",
slots=1,
min_pnl_abs=397.0,
strict_min_pnl_abs=True,
),
PostWinFlipTrigger(
name="small_dollar_high_return",
slots=1,
min_pnl_abs=0.0,
max_pnl_abs=250.0,
min_pnl_pct=0.0075,
strict_min_pnl_abs=True,
strict_max_pnl_abs=True,
),
)
)
max_arm_age_sec: Optional[float] = None
allow_rearm_while_armed: bool = False
allow_triggers_from_overlay_flips: bool = False
@dataclass(frozen=True)
class ActiveFlipArm:
"""Currently armed future LONG flip slots."""
arm_id: int
trigger_name: str
slots_total: int
slots_remaining: int
trigger_trade_id: str = ""
trigger_asset: str = ""
trigger_ts: datetime | None = None
trigger_pnl: float = 0.0
trigger_pnl_pct: float = 0.0
trigger_leverage: float = 0.0
def with_remaining(self, slots_remaining: int) -> "ActiveFlipArm":
return ActiveFlipArm(
arm_id=self.arm_id,
trigger_name=self.trigger_name,
slots_total=self.slots_total,
slots_remaining=max(0, int(slots_remaining)),
trigger_trade_id=self.trigger_trade_id,
trigger_asset=self.trigger_asset,
trigger_ts=self.trigger_ts,
trigger_pnl=self.trigger_pnl,
trigger_pnl_pct=self.trigger_pnl_pct,
trigger_leverage=self.trigger_leverage,
)
def to_dict(self) -> dict[str, Any]:
return {
"arm_id": self.arm_id,
"trigger_name": self.trigger_name,
"slots_total": self.slots_total,
"slots_remaining": self.slots_remaining,
"trigger_trade_id": self.trigger_trade_id,
"trigger_asset": self.trigger_asset,
"trigger_ts": self.trigger_ts.isoformat() if self.trigger_ts else None,
"trigger_pnl": self.trigger_pnl,
"trigger_pnl_pct": self.trigger_pnl_pct,
"trigger_leverage": self.trigger_leverage,
}
@dataclass(frozen=True)
class OverlayDecision:
"""Result returned by observe/entry tagging calls."""
action: str
side: str = "SHORT"
reason: str = ""
arm: ActiveFlipArm | None = None
consumed_slot: int = 0
reset: bool = False
def to_dict(self) -> dict[str, Any]:
return {
"action": self.action,
"side": self.side,
"reason": self.reason,
"arm": self.arm.to_dict() if self.arm else None,
"consumed_slot": self.consumed_slot,
"reset": self.reset,
}
class PostWinExecutionFSM:
"""Multi-slot post-win LONG tag Execution FSM."""
def __init__(self, config: PostWinExecutionFSMConfig | None = None) -> None:
self.config = config or PostWinExecutionFSMConfig()
self._arm: ActiveFlipArm | None = None
self._next_arm_id = 1
self.ignored_rearm_attempts = 0
self.ignored_overlay_flip_triggers = 0
self.expired_arms = 0
self.consumed_arms = 0
@property
def active_arm(self) -> ActiveFlipArm | None:
return self._arm
@property
def pending_slots(self) -> int:
return int(self._arm.slots_remaining) if self._arm else 0
def reset(self, reason: str = "manual") -> OverlayDecision:
old = self._arm
self._arm = None
return OverlayDecision(action="RESET", reason=reason, arm=old, reset=True)
def observe_closed_trade(
self,
*,
trade_id: str = "",
asset: str = "",
side: str = "SHORT",
pnl: float = 0.0,
pnl_pct: float = 0.0,
leverage: float = 0.0,
closed_ts: datetime | None = None,
was_overlay_flip: bool = False,
metadata: Mapping[str, Any] | None = None,
) -> OverlayDecision:
"""Observe a completed trade and possibly arm future LONG slots.
Parameters are intentionally primitive so this can be called from live
code, replay code, or ClickHouse/log readers.
"""
del metadata # reserved for future feature logging without API churn
self._expire_if_needed(_to_utc(closed_ts))
if not self.config.enabled:
return OverlayDecision(action="NOOP", reason="disabled", arm=self._arm)
side_u = str(side or "SHORT").upper()
if was_overlay_flip or side_u == "LONG":
self.ignored_overlay_flip_triggers += 1
return OverlayDecision(action="IGNORED", reason="overlay_flip_outcome", arm=self._arm)
pnl_f = _to_float(pnl)
pnl_pct_f = _to_float(pnl_pct)
lev_f = _to_float(leverage)
rule = self._match_rule(pnl=pnl_f, pnl_pct=pnl_pct_f, leverage=lev_f)
if rule is None:
return OverlayDecision(action="NO_TRIGGER", reason="no_rule_match", arm=self._arm)
if self._arm is not None and not self.config.allow_rearm_while_armed:
self.ignored_rearm_attempts += 1
return OverlayDecision(action="IGNORED", reason="active_arm_no_rearm", arm=self._arm)
arm = ActiveFlipArm(
arm_id=self._next_arm_id,
trigger_name=rule.name,
slots_total=int(rule.slots),
slots_remaining=int(rule.slots),
trigger_trade_id=str(trade_id or ""),
trigger_asset=str(asset or ""),
trigger_ts=_to_utc(closed_ts),
trigger_pnl=pnl_f,
trigger_pnl_pct=pnl_pct_f,
trigger_leverage=lev_f,
)
self._next_arm_id += 1
self._arm = arm
return OverlayDecision(action="ARMED", reason=rule.name, arm=arm)
def tag_next_entry(
self,
*,
asset: str = "",
entry_ts: datetime | None = None,
metadata: Mapping[str, Any] | None = None,
) -> OverlayDecision:
"""Return the side tag for the next engine entry and consume one slot."""
del asset, metadata # reserved for future asset-specific slot routing
self._expire_if_needed(_to_utc(entry_ts))
if self._arm is None or self._arm.slots_remaining <= 0:
return OverlayDecision(action="PASS", side="SHORT", reason="no_active_arm")
arm_before = self._arm
consumed_slot = arm_before.slots_total - arm_before.slots_remaining + 1
remaining = arm_before.slots_remaining - 1
if remaining <= 0:
self._arm = None
self.consumed_arms += 1
return OverlayDecision(
action="TAG",
side="LONG",
reason=arm_before.trigger_name,
arm=arm_before.with_remaining(0),
consumed_slot=consumed_slot,
reset=True,
)
self._arm = arm_before.with_remaining(remaining)
return OverlayDecision(
action="TAG",
side="LONG",
reason=arm_before.trigger_name,
arm=self._arm,
consumed_slot=consumed_slot,
reset=False,
)
def snapshot(self) -> dict[str, Any]:
return {
"enabled": self.config.enabled,
"active_arm": self._arm.to_dict() if self._arm else None,
"pending_slots": self.pending_slots,
"ignored_rearm_attempts": self.ignored_rearm_attempts,
"ignored_overlay_flip_triggers": self.ignored_overlay_flip_triggers,
"expired_arms": self.expired_arms,
"consumed_arms": self.consumed_arms,
}
def _match_rule(self, *, pnl: float, pnl_pct: float, leverage: float) -> PostWinFlipTrigger | None:
for rule in self.config.rules:
if rule.matches(pnl=pnl, pnl_pct=pnl_pct, leverage=leverage):
return rule
return None
def _expire_if_needed(self, now: datetime | None) -> None:
if self._arm is None:
return
if self.config.max_arm_age_sec is None:
return
if now is None or self._arm.trigger_ts is None:
return
age = (now - self._arm.trigger_ts).total_seconds()
if age > float(self.config.max_arm_age_sec):
self._arm = None
self.expired_arms += 1
# Compatibility aliases for earlier research scripts and tests.
PostWinLongOverlayConfig = PostWinExecutionFSMConfig
PostWinLongOverlay = PostWinExecutionFSM
__all__ = [
"ActiveFlipArm",
"OverlayDecision",
"PostWinExecutionFSM",
"PostWinExecutionFSMConfig",
"PostWinFlipTrigger",
"PostWinLongOverlay",
"PostWinLongOverlayConfig",
]