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",
|
||||
]
|
||||
Reference in New Issue
Block a user