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