Checkpoint BLUE V7 long overlay work
This commit is contained in:
315
adaptive_exit/calibrate_v7_long_from_journal.py
Normal file
315
adaptive_exit/calibrate_v7_long_from_journal.py
Normal 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()
|
||||
351
adaptive_exit/post_win_long_overlay.py
Normal file
351
adaptive_exit/post_win_long_overlay.py
Normal 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",
|
||||
]
|
||||
1305
prod/docs/LONG_DETERMINISTIC_RULE_RESEARCH.md
Normal file
1305
prod/docs/LONG_DETERMINISTIC_RULE_RESEARCH.md
Normal file
File diff suppressed because it is too large
Load Diff
3389
prod/docs/SYSTEM_BIBLE_v7.md
Normal file
3389
prod/docs/SYSTEM_BIBLE_v7.md
Normal file
File diff suppressed because it is too large
Load Diff
195
prod/tests/test_post_win_long_overlay.py
Normal file
195
prod/tests/test_post_win_long_overlay.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from adaptive_exit.post_win_long_overlay import (
|
||||
PostWinExecutionFSM,
|
||||
PostWinExecutionFSMConfig,
|
||||
PostWinFlipTrigger,
|
||||
)
|
||||
|
||||
|
||||
def _ts(seconds: int = 0) -> datetime:
|
||||
return datetime(2026, 5, 8, 12, 0, tzinfo=timezone.utc) + timedelta(seconds=seconds)
|
||||
|
||||
|
||||
def test_big_win_arms_one_slot_and_resets_after_consumption():
|
||||
overlay = PostWinExecutionFSM()
|
||||
|
||||
armed = overlay.observe_closed_trade(
|
||||
trade_id="t1",
|
||||
asset="ALGOUSDT",
|
||||
side="SHORT",
|
||||
pnl=398.0,
|
||||
pnl_pct=0.004,
|
||||
leverage=2.0,
|
||||
closed_ts=_ts(),
|
||||
)
|
||||
|
||||
assert armed.action == "ARMED"
|
||||
assert armed.reason == "big_win"
|
||||
assert overlay.pending_slots == 1
|
||||
|
||||
tag = overlay.tag_next_entry(asset="DASHUSDT", entry_ts=_ts(30))
|
||||
assert tag.action == "TAG"
|
||||
assert tag.side == "LONG"
|
||||
assert tag.consumed_slot == 1
|
||||
assert tag.reset is True
|
||||
assert overlay.pending_slots == 0
|
||||
|
||||
after = overlay.tag_next_entry(asset="TRXUSDT", entry_ts=_ts(60))
|
||||
assert after.action == "PASS"
|
||||
assert after.side == "SHORT"
|
||||
|
||||
|
||||
def test_big_win_high_lev_arms_two_slots_then_resets():
|
||||
overlay = PostWinExecutionFSM()
|
||||
|
||||
armed = overlay.observe_closed_trade(
|
||||
trade_id="t2",
|
||||
asset="VETUSDT",
|
||||
side="SHORT",
|
||||
pnl=573.0,
|
||||
pnl_pct=0.0148,
|
||||
leverage=9.0,
|
||||
closed_ts=_ts(),
|
||||
)
|
||||
|
||||
assert armed.action == "ARMED"
|
||||
assert armed.reason == "big_win_high_lev"
|
||||
assert overlay.pending_slots == 2
|
||||
|
||||
first = overlay.tag_next_entry(asset="STXUSDT", entry_ts=_ts(10))
|
||||
assert first.side == "LONG"
|
||||
assert first.consumed_slot == 1
|
||||
assert first.reset is False
|
||||
assert overlay.pending_slots == 1
|
||||
|
||||
second = overlay.tag_next_entry(asset="TRXUSDT", entry_ts=_ts(20))
|
||||
assert second.side == "LONG"
|
||||
assert second.consumed_slot == 2
|
||||
assert second.reset is True
|
||||
assert overlay.pending_slots == 0
|
||||
|
||||
third = overlay.tag_next_entry(asset="ATOMUSDT", entry_ts=_ts(30))
|
||||
assert third.side == "SHORT"
|
||||
|
||||
|
||||
def test_small_dollar_high_return_arms_one_slot():
|
||||
overlay = PostWinExecutionFSM()
|
||||
|
||||
armed = overlay.observe_closed_trade(
|
||||
trade_id="t3",
|
||||
asset="ETCUSDT",
|
||||
side="SHORT",
|
||||
pnl=149.0,
|
||||
pnl_pct=0.0075,
|
||||
leverage=0.8,
|
||||
closed_ts=_ts(),
|
||||
)
|
||||
|
||||
assert armed.action == "ARMED"
|
||||
assert armed.reason == "small_dollar_high_return"
|
||||
assert overlay.tag_next_entry(asset="LTCUSDT", entry_ts=_ts(10)).side == "LONG"
|
||||
assert overlay.tag_next_entry(asset="BNBUSDT", entry_ts=_ts(20)).side == "SHORT"
|
||||
|
||||
|
||||
def test_rearm_attempt_while_slots_active_is_ignored_and_does_not_extend_counter():
|
||||
overlay = PostWinExecutionFSM()
|
||||
|
||||
overlay.observe_closed_trade(
|
||||
trade_id="first",
|
||||
asset="ALGOUSDT",
|
||||
side="SHORT",
|
||||
pnl=500.0,
|
||||
pnl_pct=0.010,
|
||||
leverage=9.0,
|
||||
closed_ts=_ts(),
|
||||
)
|
||||
ignored = overlay.observe_closed_trade(
|
||||
trade_id="second",
|
||||
asset="VETUSDT",
|
||||
side="SHORT",
|
||||
pnl=900.0,
|
||||
pnl_pct=0.020,
|
||||
leverage=9.0,
|
||||
closed_ts=_ts(5),
|
||||
)
|
||||
|
||||
assert ignored.action == "IGNORED"
|
||||
assert ignored.reason == "active_arm_no_rearm"
|
||||
assert overlay.ignored_rearm_attempts == 1
|
||||
assert overlay.pending_slots == 2
|
||||
|
||||
assert overlay.tag_next_entry(asset="A", entry_ts=_ts(10)).side == "LONG"
|
||||
assert overlay.tag_next_entry(asset="B", entry_ts=_ts(20)).side == "LONG"
|
||||
assert overlay.tag_next_entry(asset="C", entry_ts=_ts(30)).side == "SHORT"
|
||||
|
||||
|
||||
def test_overlay_flipped_trade_outcome_cannot_rearm():
|
||||
overlay = PostWinExecutionFSM()
|
||||
|
||||
ignored = overlay.observe_closed_trade(
|
||||
trade_id="long-flip",
|
||||
asset="DASHUSDT",
|
||||
side="LONG",
|
||||
pnl=1000.0,
|
||||
pnl_pct=0.03,
|
||||
leverage=9.0,
|
||||
closed_ts=_ts(),
|
||||
was_overlay_flip=True,
|
||||
)
|
||||
|
||||
assert ignored.action == "IGNORED"
|
||||
assert ignored.reason == "overlay_flip_outcome"
|
||||
assert overlay.pending_slots == 0
|
||||
|
||||
|
||||
def test_arm_expires_by_optional_ttl_without_consuming_slot():
|
||||
overlay = PostWinExecutionFSM(PostWinExecutionFSMConfig(max_arm_age_sec=60.0))
|
||||
|
||||
overlay.observe_closed_trade(
|
||||
trade_id="ttl",
|
||||
asset="VETUSDT",
|
||||
side="SHORT",
|
||||
pnl=500.0,
|
||||
pnl_pct=0.01,
|
||||
leverage=9.0,
|
||||
closed_ts=_ts(),
|
||||
)
|
||||
|
||||
tag = overlay.tag_next_entry(asset="LATEUSDT", entry_ts=_ts(61))
|
||||
assert tag.action == "PASS"
|
||||
assert tag.side == "SHORT"
|
||||
assert overlay.pending_slots == 0
|
||||
assert overlay.expired_arms == 1
|
||||
|
||||
|
||||
def test_future_expansion_supports_more_than_two_slots():
|
||||
overlay = PostWinExecutionFSM(
|
||||
PostWinExecutionFSMConfig(
|
||||
rules=(
|
||||
PostWinFlipTrigger(
|
||||
name="future_three_slot_rule",
|
||||
slots=3,
|
||||
min_pnl_abs=100.0,
|
||||
strict_min_pnl_abs=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
overlay.observe_closed_trade(
|
||||
trade_id="three",
|
||||
asset="XRPUSDT",
|
||||
side="SHORT",
|
||||
pnl=101.0,
|
||||
pnl_pct=0.001,
|
||||
leverage=1.0,
|
||||
closed_ts=_ts(),
|
||||
)
|
||||
|
||||
assert [overlay.tag_next_entry(asset=str(i), entry_ts=_ts(i)).side for i in range(1, 5)] == [
|
||||
"LONG",
|
||||
"LONG",
|
||||
"LONG",
|
||||
"SHORT",
|
||||
]
|
||||
295
prod/tests/test_v7_live_exit_wiring.py
Normal file
295
prod/tests/test_v7_live_exit_wiring.py
Normal file
@@ -0,0 +1,295 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path("/mnt/dolphinng5_predict")
|
||||
sys.path.insert(0, str(ROOT / "nautilus_dolphin"))
|
||||
sys.path.insert(1, str(ROOT))
|
||||
if "nautilus_dolphin" in sys.modules:
|
||||
pkg = sys.modules["nautilus_dolphin"]
|
||||
pkg_file = str(getattr(pkg, "__file__", "") or "")
|
||||
if not pkg_file.endswith("nautilus_dolphin/nautilus_dolphin/__init__.py"):
|
||||
del sys.modules["nautilus_dolphin"]
|
||||
|
||||
from nautilus_dolphin.nautilus.esf_alpha_orchestrator import NDAlphaEngine, NDPosition
|
||||
from nautilus_dolphin.nautilus.alpha_exit_v7_engine import AlphaExitEngineV7, AlphaExitV7Config
|
||||
from prod.nautilus_event_trader import DolphinLiveTrader
|
||||
|
||||
|
||||
class _DummyCtx:
|
||||
def __init__(self, entry_price: float, entry_bar: int, side: int) -> None:
|
||||
self.entry_price = entry_price
|
||||
self.entry_bar = entry_bar
|
||||
self.side = side
|
||||
self.exf = None
|
||||
|
||||
def set_exf(self, funding: float = 0.0, dvol: float = 0.0, fear_greed: float = 0.0, taker: float = 0.0) -> None:
|
||||
self.exf = {
|
||||
"funding": funding,
|
||||
"dvol": dvol,
|
||||
"fear_greed": fear_greed,
|
||||
"taker": taker,
|
||||
}
|
||||
|
||||
|
||||
class _DummyV7Engine:
|
||||
def __init__(self) -> None:
|
||||
self.make_calls = []
|
||||
self.evaluate_calls = []
|
||||
|
||||
def make_context(self, entry_price: float, entry_bar: int, side: int) -> _DummyCtx:
|
||||
self.make_calls.append((entry_price, entry_bar, side))
|
||||
return _DummyCtx(entry_price=entry_price, entry_bar=entry_bar, side=side)
|
||||
|
||||
def evaluate(self, ctx, current_price: float, current_bar: int, ob_imbalance: float, asset: str = "default") -> dict:
|
||||
self.evaluate_calls.append(
|
||||
{
|
||||
"ctx": ctx,
|
||||
"current_price": current_price,
|
||||
"current_bar": current_bar,
|
||||
"ob_imbalance": ob_imbalance,
|
||||
"asset": asset,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"action": "EXIT",
|
||||
"reason": "V7_COMPOSITE_PRESSURE",
|
||||
"pnl_pct": 1.25,
|
||||
"bars_held": current_bar - ctx.entry_bar,
|
||||
"mfe": 0.02,
|
||||
"mae": 0.01,
|
||||
"mfe_risk": 0.0,
|
||||
"mae_risk": 0.0,
|
||||
"exit_pressure": 2.81,
|
||||
"rv_comp": 0.001,
|
||||
"mae_thresh1": 0.002,
|
||||
"bounce_score": 0.1,
|
||||
"bounce_risk": 0.2,
|
||||
}
|
||||
|
||||
|
||||
class _DummyOBSignal:
|
||||
def __init__(self, imbalance_ma5: float) -> None:
|
||||
self.imbalance_ma5 = imbalance_ma5
|
||||
|
||||
|
||||
class _DummyOBEngine:
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
|
||||
def get_signal(self, asset: str, bar_idx: float):
|
||||
self.calls.append((asset, bar_idx))
|
||||
return _DummyOBSignal(0.42)
|
||||
|
||||
|
||||
def test_ndalphaengine_prefers_exit_decision_provider_before_base_manager():
|
||||
engine = NDAlphaEngine(
|
||||
initial_capital=1000.0,
|
||||
use_sp_fees=False,
|
||||
use_sp_slippage=False,
|
||||
use_ob_edge=False,
|
||||
use_asset_selection=False,
|
||||
use_direction_confirm=False,
|
||||
use_alpha_layers=False,
|
||||
use_dynamic_leverage=False,
|
||||
)
|
||||
pos = NDPosition(
|
||||
trade_id="tid-1",
|
||||
asset="DASHUSDT",
|
||||
direction=-1,
|
||||
entry_price=100.0,
|
||||
entry_bar=0,
|
||||
notional=100.0,
|
||||
leverage=1.0,
|
||||
fraction=0.2,
|
||||
entry_vel_div=-0.03,
|
||||
bucket_idx=4,
|
||||
current_price=90.0,
|
||||
)
|
||||
engine.position = pos
|
||||
engine._day_posture = "APEX"
|
||||
engine.regime_dd_halt = False
|
||||
provider_called = {}
|
||||
|
||||
def provider(**kwargs):
|
||||
provider_called.update(kwargs)
|
||||
return {
|
||||
"action": "EXIT",
|
||||
"reason": "V7_COMPOSITE_PRESSURE",
|
||||
"pnl_pct": 1.25,
|
||||
"bars_held": 7,
|
||||
}
|
||||
|
||||
engine.exit_decision_provider = provider
|
||||
|
||||
def _should_not_run(*args, **kwargs):
|
||||
raise AssertionError("base exit_manager should not be consulted when provider returns a decision")
|
||||
|
||||
engine.exit_manager.evaluate = _should_not_run
|
||||
executed = {}
|
||||
|
||||
def _fake_execute_exit(reason: str, bar_idx: int, pnl_pct_raw: float = 0.0, bars_held: int = 0):
|
||||
executed.update(
|
||||
{
|
||||
"reason": reason,
|
||||
"bar_idx": bar_idx,
|
||||
"pnl_pct_raw": pnl_pct_raw,
|
||||
"bars_held": bars_held,
|
||||
}
|
||||
)
|
||||
engine.position = None
|
||||
return executed
|
||||
|
||||
engine._execute_exit = _fake_execute_exit
|
||||
|
||||
out = engine._manage_position(
|
||||
bar_idx=17,
|
||||
prices={"DASHUSDT": 89.0},
|
||||
vel_div=-0.12,
|
||||
v50_vel=0.03,
|
||||
v750_vel=0.01,
|
||||
)
|
||||
|
||||
assert provider_called["pos"] is pos
|
||||
assert provider_called["bar_idx"] == 17
|
||||
assert out["reason"] == "V7_COMPOSITE_PRESSURE"
|
||||
assert executed["reason"] == "V7_COMPOSITE_PRESSURE"
|
||||
assert executed["bar_idx"] == 17
|
||||
|
||||
|
||||
def test_blue_live_v7_provider_records_journal_and_uses_ob_signal():
|
||||
trader = DolphinLiveTrader.__new__(DolphinLiveTrader)
|
||||
trader._v7_exit_engine = _DummyV7Engine()
|
||||
trader._pending_entries = {
|
||||
"tid-2": {
|
||||
"entry_price": 100.0,
|
||||
"entry_bar": 4,
|
||||
"side": "SHORT",
|
||||
"quantity": 2.0,
|
||||
"leverage": 3.0,
|
||||
"notional": 200.0,
|
||||
}
|
||||
}
|
||||
trader._v7_contexts = {}
|
||||
trader._v7_decision_seq = {}
|
||||
trader._v7_decisions = {}
|
||||
trader._last_exf = {
|
||||
"funding": 1.0,
|
||||
"dvol": 2.0,
|
||||
"fear_greed": 3.0,
|
||||
"taker": 4.0,
|
||||
}
|
||||
trader.ob_eng = _DummyOBEngine()
|
||||
captured = {}
|
||||
|
||||
def _capture_record(**kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
trader._record_v7_decision = _capture_record
|
||||
|
||||
pos = SimpleNamespace(trade_id="tid-2", asset="DASHUSDT", current_price=97.0)
|
||||
decision = trader._v7_live_exit_decision(
|
||||
pos=pos,
|
||||
bar_idx=10,
|
||||
prices={"DASHUSDT": 97.5},
|
||||
vel_div=-0.3,
|
||||
v50_vel=0.1,
|
||||
v750_vel=0.2,
|
||||
)
|
||||
|
||||
assert decision["action"] == "EXIT"
|
||||
assert trader._v7_contexts["tid-2"].exf == {
|
||||
"funding": 1.0,
|
||||
"dvol": 2.0,
|
||||
"fear_greed": 3.0,
|
||||
"taker": 4.0,
|
||||
}
|
||||
assert trader.ob_eng.calls == [("DASHUSDT", 9.0)]
|
||||
assert trader._v7_exit_engine.evaluate_calls[0]["current_bar"] == 9
|
||||
assert trader._v7_exit_engine.evaluate_calls[0]["ob_imbalance"] == pytest.approx(0.42)
|
||||
assert captured["source"] == "live_exit"
|
||||
assert captured["bar_idx"] == 9
|
||||
assert captured["trade_id"] == "tid-2"
|
||||
assert captured["asset"] == "DASHUSDT"
|
||||
|
||||
|
||||
def test_alpha_exit_v7_is_mechanically_side_aware_for_long_and_short():
|
||||
engine = AlphaExitEngineV7(bar_duration_sec=11.0, bounce_model_path="/tmp/nonexistent-bounce-model.pkl")
|
||||
|
||||
long_ctx = engine.make_context(entry_price=100.0, entry_bar=0, side=0)
|
||||
long_favorable = engine.evaluate(long_ctx, current_price=101.0, current_bar=1, ob_imbalance=0.0)
|
||||
assert long_favorable["pnl_pct"] == pytest.approx(1.0)
|
||||
assert long_favorable["mfe"] == pytest.approx(0.01)
|
||||
assert long_favorable["mae"] == pytest.approx(0.0)
|
||||
|
||||
long_adverse = engine.make_context(entry_price=100.0, entry_bar=0, side=0)
|
||||
long_adverse_out = engine.evaluate(long_adverse, current_price=99.0, current_bar=1, ob_imbalance=0.0)
|
||||
assert long_adverse_out["pnl_pct"] == pytest.approx(-1.0)
|
||||
assert long_adverse_out["mfe"] == pytest.approx(0.0)
|
||||
assert long_adverse_out["mae"] == pytest.approx(0.01)
|
||||
|
||||
short_ctx = engine.make_context(entry_price=100.0, entry_bar=0, side=1)
|
||||
short_favorable = engine.evaluate(short_ctx, current_price=99.0, current_bar=1, ob_imbalance=0.0)
|
||||
assert short_favorable["pnl_pct"] == pytest.approx(1.0)
|
||||
assert short_favorable["mfe"] == pytest.approx(0.01)
|
||||
assert short_favorable["mae"] == pytest.approx(0.0)
|
||||
|
||||
short_adverse = engine.make_context(entry_price=100.0, entry_bar=0, side=1)
|
||||
short_adverse_out = engine.evaluate(short_adverse, current_price=101.0, current_bar=1, ob_imbalance=0.0)
|
||||
assert short_adverse_out["pnl_pct"] == pytest.approx(-1.0)
|
||||
assert short_adverse_out["mfe"] == pytest.approx(0.0)
|
||||
assert short_adverse_out["mae"] == pytest.approx(0.01)
|
||||
|
||||
|
||||
def test_alpha_exit_v7_default_config_matches_legacy_threshold_surface():
|
||||
engine = AlphaExitEngineV7(bar_duration_sec=11.0, bounce_model_path="/tmp/nonexistent-bounce-model.pkl")
|
||||
cfg = engine.config
|
||||
|
||||
assert cfg.rvol_w15 == pytest.approx(0.50)
|
||||
assert cfg.rvol_w30 == pytest.approx(0.30)
|
||||
assert cfg.rvol_w50 == pytest.approx(0.20)
|
||||
assert cfg.mae_tier1_k == pytest.approx(3.5)
|
||||
assert cfg.mae_tier2_k == pytest.approx(7.0)
|
||||
assert cfg.mae_tier3_k == pytest.approx(12.0)
|
||||
assert cfg.mae_tier1_floor == pytest.approx(0.005)
|
||||
assert cfg.mae_tier2_floor == pytest.approx(0.012)
|
||||
assert cfg.mae_tier3_floor == pytest.approx(0.025)
|
||||
assert cfg.mae_tier1_risk == pytest.approx(0.5)
|
||||
assert cfg.mae_tier2_risk == pytest.approx(0.8)
|
||||
assert cfg.mae_tier3_risk == pytest.approx(1.2)
|
||||
assert cfg.mae_accel_min_bars == 3
|
||||
assert cfg.mae_accel_peak_floor == pytest.approx(0.003)
|
||||
assert cfg.mae_recovery_peak_floor == pytest.approx(0.004)
|
||||
assert cfg.mae_recovery_prev_min == pytest.approx(0.25)
|
||||
assert cfg.mae_recovery_snapback_max == pytest.approx(0.10)
|
||||
assert cfg.mae_late_floor == pytest.approx(0.003)
|
||||
assert cfg.mae_late_start_frac == pytest.approx(0.60)
|
||||
assert cfg.mae_late_risk_max == pytest.approx(0.4)
|
||||
assert cfg.mfe_convexity_decay_exit == pytest.approx(0.35)
|
||||
assert cfg.mfe_convexity_decay_soft == pytest.approx(0.20)
|
||||
assert cfg.bounce_dir_w == pytest.approx(0.15)
|
||||
assert cfg.bounce_risk_w == pytest.approx(0.35)
|
||||
assert cfg.exit_pressure_threshold == pytest.approx(2.69)
|
||||
assert cfg.retract_pressure_threshold == pytest.approx(1.0)
|
||||
assert cfg.extend_pressure_threshold == pytest.approx(-0.5)
|
||||
|
||||
|
||||
def test_alpha_exit_v7_custom_threshold_config_is_per_instance():
|
||||
strict = AlphaExitEngineV7(
|
||||
bar_duration_sec=11.0,
|
||||
bounce_model_path="/tmp/nonexistent-bounce-model.pkl",
|
||||
config=AlphaExitV7Config(exit_pressure_threshold=0.1, retract_pressure_threshold=0.05),
|
||||
)
|
||||
default = AlphaExitEngineV7(bar_duration_sec=11.0, bounce_model_path="/tmp/nonexistent-bounce-model.pkl")
|
||||
|
||||
strict_ctx = strict.make_context(entry_price=100.0, entry_bar=0, side=0)
|
||||
default_ctx = default.make_context(entry_price=100.0, entry_bar=0, side=0)
|
||||
strict_decision = strict.evaluate(strict_ctx, current_price=100.0, current_bar=1, ob_imbalance=0.0)
|
||||
default_decision = default.evaluate(default_ctx, current_price=100.0, current_bar=1, ob_imbalance=0.0)
|
||||
|
||||
assert strict_decision["action"] == "EXIT"
|
||||
assert default_decision["action"] == "HOLD"
|
||||
assert strict.config.exit_pressure_threshold == pytest.approx(0.1)
|
||||
assert default.config.exit_pressure_threshold == pytest.approx(2.69)
|
||||
Reference in New Issue
Block a user