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:
1 parent
f10a8033a1
commit
5597250ff1
11 files changed
+894
-8
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user