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:
1 parent
5315e99978
commit
8dc97a53f8
3 files changed
+37
-2
No files matched your search
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user