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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
This commit is contained in:
momentum devandClaude Opus 5.5 committed 2026-10-01 07:40:24 -05:00
1 parent 5315e99978
commit 8dc97a53f8
3 files changed
+37 -2

No files matched your search

+24
View File
@@ -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():
+2 -2
View File
@@ -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}")
+11
View File
@@ -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