fix: marketable entries price from live last + time-of-day rvol arms fast path

Day-22 forensic (BIYA session): 6 entry submissions, 0 fills — IREX missed
+1.7R, IRE +1.65R by one cent. Two root causes fixed:

1. Slow-path limit rested AT the trigger while price ran 2% past it, and
   retries reused the same stale price. Marketable model now prices from
   max(trigger, live last) via price_provider; the anti-chase extension
   gate gets the live ref too (was an entry_ref==trigger no-op on this
   path). TRADING_ENTRY_FILL_MODEL flipped to marketable.

2. Fast trigger path armed nothing all day: legacy rvol (cum-so-far /
   full-day MEAN) is ~0.1-0.3 minutes after the open for a normal heavy
   name and its mean is poisoned by reverse-split/prior-squeeze days
   (BIYA legacy 1.27 vs real activity 481x median). New rvol_tod =
   cum / (MEDIAN(20d) * elapsed-session fraction) drives arm eligibility
   and the scanner keep-filter; legacy rvol untouched for validated
   boundaries (gap-squeeze rvol>=5).

Also: stagnation time-stop (TRADING_EXIT_STAGNATION_MIN/_R, default OFF)
in simulate_exit + manage_live with sweep_exits.py variants for
validation before any live enable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-20 15:40:15 -05:00
1 parent f10a8033a1
commit 5597250ff1
11 files changed
+894 -8

No files matched your search

+37 -4
View File
@@ -24,6 +24,28 @@ class GapperCandidate:
vwap: float | None
prev_close: float | None = None # for the STATIC overnight gap (open vs prev close)
rank: int = 0
# Time-of-day rvol: cum volume vs MEDIAN(20d) prorated by session elapsed time.
# The legacy relative_volume divides cum-so-far by the full-day MEAN, which is
# (a) ~0.1-0.3 for a NORMAL heavy name minutes after the open (cum can't reach
# a full-day average yet) and (b) poisoned by reverse-split / prior-squeeze
# days in the mean (BIYA 07-20: legacy 1.27 vs tod 481 — median 1.5M, mean
# 29.4M). None when no baseline. Legacy relative_volume is kept untouched —
# validated boundaries (gap-squeeze rvol>=5) are calibrated in its units.
relative_volume_tod: float | None = None
def _session_elapsed_fraction(now: datetime | None = None) -> float:
"""Fraction of the 390-min regular session elapsed, floored at 0.05 so a
premarket/at-open scan doesn't divide by ~zero expected volume."""
try:
from zoneinfo import ZoneInfo
et = (now or datetime.now(ZoneInfo("America/New_York")))
if et.tzinfo is not None:
et = et.astimezone(ZoneInfo("America/New_York"))
elapsed = (et.hour - 9) * 60 + (et.minute - 30)
except Exception: # noqa: BLE001 — fall back to full-day (legacy behaviour)
return 1.0
return min(1.0, max(0.05, elapsed / 390.0))
def scan_gappers(
@@ -34,6 +56,7 @@ def scan_gappers(
price_min: float = 1.0,
price_max: float = 20.0,
limit: int = 25,
now: datetime | None = None,
) -> list[GapperCandidate]:
"""Find symbols gapping up on elevated volume for a session."""
rows = con.execute(
@@ -56,7 +79,8 @@ def scan_gappers(
WHERE trade_date < ?
),
avg_vol AS (
SELECT symbol, AVG(volume) AS adv
SELECT symbol, AVG(volume) AS adv,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY volume) AS med_vol
FROM (
SELECT symbol, volume,
ROW_NUMBER() OVER (
@@ -69,7 +93,7 @@ def scan_gappers(
GROUP BY symbol
)
SELECT l.symbol, l.close, l.vwap, l.cum_volume,
p.prev_close, a.adv
p.prev_close, a.adv, a.med_vol
FROM latest l
JOIN prev p ON p.symbol = l.symbol AND p.rn = 1
LEFT JOIN avg_vol a ON a.symbol = l.symbol
@@ -78,13 +102,21 @@ def scan_gappers(
[session_date, session_date, session_date, price_min, price_max],
).fetchall()
frac = _session_elapsed_fraction(now)
candidates: list[GapperCandidate] = []
for symbol, close, vwap, cum_volume, prev_close, adv in rows:
for symbol, close, vwap, cum_volume, prev_close, adv, med_vol in rows:
if not prev_close or prev_close <= 0:
continue
gap_pct = (float(close) - float(prev_close)) / float(prev_close) * 100.0
rvol = (float(cum_volume) / float(adv)) if adv and adv > 0 else 0.0
if gap_pct >= min_gap_pct and rvol >= min_relative_volume:
rvol_tod = (float(cum_volume) / (float(med_vol) * frac)
if med_vol and float(med_vol) > 0 else None)
# either rvol flavour clears the bar: legacy is ~0.1-0.3 early in the
# session even for a heavy name (cum-so-far vs full-day mean), so the
# tod variant keeps real early movers from being silently dropped
if gap_pct >= min_gap_pct and (
rvol >= min_relative_volume
or (rvol_tod or 0.0) >= min_relative_volume):
candidates.append(
GapperCandidate(
symbol=symbol,
@@ -94,6 +126,7 @@ def scan_gappers(
cumulative_volume=int(cum_volume),
relative_volume=rvol,
vwap=float(vwap) if vwap is not None else None,
relative_volume_tod=rvol_tod,
)
)
candidates.sort(key=lambda c: (c.gap_pct * max(c.relative_volume, 0.1)), reverse=True)