From 8dc97a53f8c25e671970e5c8508a47ce5dcf3631 Mon Sep 17 00:00:00 2001 From: momentum dev Date: Thu, 1 Oct 2026 07:40:24 -0500 Subject: [PATCH] feat: judge paper trader runs a software stop alongside the broker stop It sells as soon as any trade prints at or through the stop, or below the 6% trail once +1R, walking the new prints in order exactly as the validated simulator does. Whichever acts first wins: the broker stop or this one. Why: Alpaca's paper engine filled triggered stops 6-60 s late on 2026-09-30 (CMCT, PWCM; judge.review), which makes the trial look worse than the simulation for reasons that are not the strategy's. Brokers also run no stop orders outside regular hours, so any premarket trading needs this. JUDGE_SOFT_STOP=0 turns it off. judge.review treats soft_stop/soft_trail as stop/trail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86 --- judge/live.py | 24 ++++++++++++++++++++++++ judge/review.py | 4 ++-- tests/unit/test_judge_live_exits.py | 11 +++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/judge/live.py b/judge/live.py index c82c39e..8cbebf6 100644 --- a/judge/live.py +++ b/judge/live.py @@ -47,6 +47,9 @@ MAX_OPEN = int(os.environ.get("JUDGE_MAX_OPEN", "4")) MAX_TRADES_SYM = int(os.environ.get("JUDGE_MAX_TRADES_PER_SYMBOL", "6")) SCAN_S = float(os.environ.get("JUDGE_SCAN_INTERVAL_S", "30")) DRY = os.environ.get("JUDGE_DRY_RUN", "0") == "1" +# Software stop: sell when any trade prints at/through the stop (or the trail level), as the validated simulator does. +# Backs up the broker stop, which Alpaca's paper engine fills late in fast markets and no broker runs outside RTH. +SOFT_STOP = os.environ.get("JUDGE_SOFT_STOP", "1") == "1" def now_et() -> pd.Timestamp: @@ -112,6 +115,7 @@ class Pos: setup: str p: float next_try: pd.Timestamp | None = None # throttles exit retries after a broker error + seen_n: int = -1 # index into the book's trade list already checked by the soft stop class Trader: @@ -505,6 +509,20 @@ class Trader: return self._closed(pz, reason, last) + @staticmethod + def _soft_stop_hit(pz: Pos, prices) -> str | None: + """Walk the new trades in order like the simulator: update the high-water, then exit if a print is at or + below the stop, or below the 6% trail once +1R. Returns 'soft_stop' / 'soft_trail' or None.""" + P = engine.EXEC + for px in prices: + pz.hw = max(pz.hw, px) + level = pz.stop + if (pz.hw - pz.entry) / pz.risk >= P["trail_after_r"]: + level = max(level, pz.hw * (1 - P["trail_pct"]), pz.entry) + if px <= level: + return "soft_stop" if level <= pz.stop else "soft_trail" + return None + async def manage_loop(self): tick = 0 while True: @@ -519,6 +537,12 @@ class Trader: k = np.searchsorted(np.array(bk.tt[-5000:], dtype="datetime64[ns]"), np.datetime64(pz.fill_time), side="left") recent = bk.tp[-5000:][k:] last = recent[-1] if recent else pz.entry + if SOFT_STOP: + start = max(pz.seen_n, len(bk.tp) - len(bk.tp[-5000:]) + int(k)) + hit = self._soft_stop_hit(pz, bk.tp[start:]) + pz.seen_n = len(bk.tp) + if hit is not None: + await self.exit_pos(pz, hit, last); continue if recent: pz.hw = max(pz.hw, max(recent)) if n.time() >= pd.Timestamp("15:55").time(): diff --git a/judge/review.py b/judge/review.py index 23524ef..6b32ed2 100644 --- a/judge/review.py +++ b/judge/review.py @@ -115,8 +115,8 @@ def _row(day, o, f, x) -> dict: elif s and paper_r is not None: if abs(paper_r - s["r"]) > FLAG_R: flags.append(f"R gap {paper_r - s['r']:+.2f}") - if (x.get("reason") or "") not in (s["reason"], "flat_at_broker") and not ( - x.get("reason") == "stop" and s["reason"] == "trail"): + why = (x.get("reason") or "").replace("soft_", "") + if why not in (s["reason"], "flat_at_broker") and not (why == "stop" and s["reason"] == "trail"): flags.append(f"reason {x.get('reason')} vs sim {s['reason']}") if abs(row["entry_slip"]) > 0.005: flags.append(f"entry slip {row['entry_slip']:+.3f}") diff --git a/tests/unit/test_judge_live_exits.py b/tests/unit/test_judge_live_exits.py index 8518901..2a89d34 100644 --- a/tests/unit/test_judge_live_exits.py +++ b/tests/unit/test_judge_live_exits.py @@ -111,3 +111,14 @@ def test_earlier_today_counts_orders_and_last_fill(tmp_path, monkeypatch): e = t._earlier_today() assert e["orders"] == {"X": 2} and e["fills"]["X"]["stop"] == 1.2 assert pd.Timestamp(e["fills"]["X"]["at"]).hour == 9 + + +def test_soft_stop_walks_trades_in_order(): + pz = Pos("X", 100, 10.0, 9.5, 0.5, live.now_et(), "o", "leg", 10.0, "burst", 0.4) + assert Trader._soft_stop_hit(pz, [10.1, 9.8, 9.6]) is None # above the stop + assert Trader._soft_stop_hit(pz, [9.7, 9.5]) == "soft_stop" # a print at the stop + pz = Pos("X", 100, 10.0, 9.5, 0.5, live.now_et(), "o", "leg", 10.0, "burst", 0.4) + assert Trader._soft_stop_hit(pz, [10.6, 12.0, 11.5]) is None # +1R reached; trail 6% under 12.0 = 11.28 + assert Trader._soft_stop_hit(pz, [11.25]) == "soft_trail" + pz = Pos("X", 100, 10.0, 9.5, 0.5, live.now_et(), "o", "leg", 10.0, "burst", 0.4) + assert Trader._soft_stop_hit(pz, [9.4, 12.0]) == "soft_stop" # order matters: the dip came first