Population gate audit: static overnight gap everywhere + full band
The 2026-07-08 audit (owner: "figure out if there is some kind of logic or audit issue") found BOTH entry paths feeding the population gate the WRONG gap. The fast path used the trigger book's dynamic gap (latest price vs prev close), so intraday rallies masqueraded as gappers: TVRD (6.0% true gap), SKIN (2.2%, filled at $0.98), JLHL (4.3%) all traded outside the validated population. The slow path used max(gap, intraday) with "open" taken from the first lookback-window bar, and had no upper band at all — dead-band names SEER (38.6%) and IOTR (40.7%) entered. Fixes: candidates carry gap_open_pct (session open vs prev close); trigger eligibility + submit_breakout_now use it exclusively once the session is open; _population_reject now implements the full validated band (10-35% OR volume-verified >=150% squeeze, $1-20 price band) for both paths; evaluator gap uses the session open; signals carry relative_volume for squeeze verification. Plus momentum-testpg.service so the test-isolation Postgres survives reboots. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
2e5346e035
commit
44ff36a669
8 files changed
+103
-13
No files matched your search
@@ -18,10 +18,11 @@ SCANNER_VERSION = "gapper-v2"
|
||||
class GapperCandidate:
|
||||
symbol: str
|
||||
price: float
|
||||
gap_pct: float
|
||||
gap_pct: float # DYNAMIC: latest close vs prev close (display/rank)
|
||||
cumulative_volume: int
|
||||
relative_volume: float
|
||||
vwap: float | None
|
||||
prev_close: float | None = None # for the STATIC overnight gap (open vs prev close)
|
||||
rank: int = 0
|
||||
|
||||
|
||||
@@ -88,6 +89,7 @@ def scan_gappers(
|
||||
GapperCandidate(
|
||||
symbol=symbol,
|
||||
price=float(close),
|
||||
prev_close=float(prev_close),
|
||||
gap_pct=gap_pct,
|
||||
cumulative_volume=int(cum_volume),
|
||||
relative_volume=rvol,
|
||||
|
||||
+11
-1
@@ -758,9 +758,17 @@ def main(argv: list[str] | None = None) -> int:
|
||||
day_open = float(rth.iloc[0]["open"])
|
||||
except Exception: # noqa: BLE001
|
||||
dollar_vol = 0.0
|
||||
# STATIC overnight gap (session open vs prev close) — the population
|
||||
# definition. The scanner's g.gap_pct is DYNAMIC (latest price vs prev
|
||||
# close): intraday rallies masqueraded as gappers through it (TVRD 6%
|
||||
# true gap entered 2026-07-07; audit 2026-07-08). None until the open.
|
||||
gap_open_pct = None
|
||||
if day_open and g.prev_close and g.prev_close > 0:
|
||||
gap_open_pct = (day_open / g.prev_close - 1.0) * 100.0
|
||||
candidates.append({
|
||||
"symbol": g.symbol,
|
||||
"gap_pct": g.gap_pct,
|
||||
"gap_open_pct": gap_open_pct,
|
||||
"rvol": g.relative_volume,
|
||||
"trigger": hi,
|
||||
"stop": stop,
|
||||
@@ -864,7 +872,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
t.symbol, t.trigger, t.stop, last_price=t.price,
|
||||
cum_volume=t.cum_volume, halted=_recently_halted(t.symbol),
|
||||
day_open=t.day_open, rank=t.rank,
|
||||
gap_pct=(t.gap_pct / 100.0) if t.gap_pct else None, # book stores % units
|
||||
# STATIC overnight gap only — never the dynamic book gap
|
||||
gap_pct=(t.gap_open_pct / 100.0) if t.gap_open_pct is not None else None,
|
||||
rvol=t.rvol, price=t.price,
|
||||
)
|
||||
if res.get("ok"):
|
||||
book.mark_fired(t.symbol)
|
||||
|
||||
+11
-3
@@ -53,7 +53,8 @@ def _synced(method):
|
||||
@dataclass
|
||||
class ArmedTrigger:
|
||||
symbol: str
|
||||
gap_pct: float = 0.0
|
||||
gap_pct: float = 0.0 # DYNAMIC gap (latest vs prev close) — display/rank only
|
||||
gap_open_pct: float | None = None # STATIC overnight gap (session open vs prev close)
|
||||
rvol: float = 0.0
|
||||
trigger: float | None = None # opening-range high (entry breakout level)
|
||||
stop: float | None = None # opening-range low, cushioned (protective stop)
|
||||
@@ -122,10 +123,15 @@ class ArmedTriggerBook:
|
||||
_lock: object = field(default_factory=threading.RLock, repr=False, compare=False)
|
||||
|
||||
def _gap_ok(self, t: ArmedTrigger) -> bool:
|
||||
if self.gap_min <= t.gap_pct <= self.gap_max:
|
||||
# STATIC overnight gap (open vs prev close) defines the validated population.
|
||||
# The dynamic gap let intraday rallies masquerade as gappers (TVRD 6% true
|
||||
# gap traded 2026-07-07 as a "13%+ gapper"; audit 2026-07-08). Before the
|
||||
# open (no session open yet) the dynamic gap is the only available proxy.
|
||||
g = t.gap_open_pct if t.gap_open_pct is not None else t.gap_pct
|
||||
if self.gap_min <= g <= self.gap_max:
|
||||
return True
|
||||
return (self.gap_squeeze_min > 0
|
||||
and t.gap_pct >= self.gap_squeeze_min
|
||||
and g >= self.gap_squeeze_min
|
||||
and t.rvol >= self.gap_squeeze_rvol)
|
||||
|
||||
def _eligible(self, t: ArmedTrigger) -> bool:
|
||||
@@ -163,6 +169,8 @@ class ArmedTriggerBook:
|
||||
rank += 1
|
||||
t = self.triggers.get(sym) or ArmedTrigger(symbol=sym)
|
||||
t.gap_pct = float(c.get("gap_pct") or 0.0)
|
||||
_go = c.get("gap_open_pct")
|
||||
t.gap_open_pct = float(_go) if _go is not None else None
|
||||
t.rvol = float(c.get("rvol") or 0.0)
|
||||
t.trigger = c.get("trigger")
|
||||
t.stop = c.get("stop")
|
||||
|
||||
@@ -232,6 +232,7 @@ def query_ready_signals_snapshot(store, session_id: str | None = None) -> list[d
|
||||
"day_open": signal_data.get("day_open"),
|
||||
"cum_volume": signal_data.get("cum_volume"),
|
||||
"gap_pct": signal_data.get("gap_pct"),
|
||||
"relative_volume": signal_data.get("relative_volume"),
|
||||
"timestamp": event.get("timestamp"),
|
||||
}
|
||||
return sorted(
|
||||
|
||||
@@ -58,7 +58,14 @@ def evaluate_setup(
|
||||
|
||||
bars = bars.reset_index(drop=True)
|
||||
price = float(bars["close"].iloc[-1])
|
||||
# SESSION open, not the first bar of the lookback window: with a 420-min
|
||||
# premarket-inclusive window the first bar is an early-premarket print, which
|
||||
# skewed the "overnight gap" fed to the population gate (2026-07-08 audit).
|
||||
open_price = float(bars["open"].iloc[0])
|
||||
if "is_regular_hours" in bars.columns:
|
||||
_rth = bars[bars["is_regular_hours"].astype(bool)]
|
||||
if len(_rth):
|
||||
open_price = float(_rth["open"].iloc[0])
|
||||
ref_close = previous_close if previous_close and previous_close > 0 else open_price
|
||||
gap_pct = (open_price - ref_close) / ref_close if ref_close > 0 else 0.0
|
||||
intraday_change = (price - open_price) / open_price if open_price > 0 else 0.0
|
||||
@@ -163,6 +170,7 @@ def evaluate_setup(
|
||||
"above_vwap": bool(above_vwap),
|
||||
"day_open": round(float(open_price), 4) if open_price > 0 else None,
|
||||
"cum_volume": float(bars["volume"].sum()),
|
||||
"relative_volume": round(float(relative_volume), 3),
|
||||
# true overnight gap (open vs prev close) — input to the live POPULATION
|
||||
# gate: the year-long gapper replay showed the +EV boundary is the
|
||||
# population (gap>=10% + real volume), not entry timing
|
||||
|
||||
@@ -834,3 +834,24 @@ def test_breakout_now_respects_dilution_veto(store):
|
||||
service = _service_with_catalyst(store, advisory, veto_enabled=True)
|
||||
res = service.submit_breakout_now("DILUT", trigger=14.0, stop=13.45, last_price=14.0)
|
||||
assert res["ok"] is False and res["skipped"] == "dilution_veto"
|
||||
|
||||
|
||||
def test_population_gate_full_band_semantics():
|
||||
"""2026-07-08 audit: the gate must implement the VALIDATED population — static
|
||||
overnight gap in [10%, 35%] OR volume-verified squeeze >=150%; $1-20 price band.
|
||||
TVRD (6%), SKIN (2.2%, $0.98), JLHL (4.3%) traded outside it; SEER (38.6%) and
|
||||
IOTR (40.7%) came from the dead band."""
|
||||
from trading_execution import ExecutionSettings, TradingExecutionService
|
||||
svc = TradingExecutionService.__new__(TradingExecutionService)
|
||||
svc.settings = ExecutionSettings(min_entry_gap_pct=0.10, min_day_volume=300_000)
|
||||
ok = lambda **kw: svc._population_reject(**{"gap_pct": None, "cum_volume": 500_000, **kw})
|
||||
assert ok(gap_pct=0.06) is not None # TVRD: below floor
|
||||
assert ok(gap_pct=0.20) is None # core band trades
|
||||
assert ok(gap_pct=0.40) is not None # SEER/IOTR: dead band
|
||||
assert ok(gap_pct=2.78, rvol=40.0) is None # LHSW: verified squeeze
|
||||
assert ok(gap_pct=2.78, rvol=0.4) is not None # split fake: rejected
|
||||
assert ok(gap_pct=2.78) is not None # unverified squeeze: rejected
|
||||
assert ok(gap_pct=0.20, price=0.98) is not None # SKIN: below $1
|
||||
assert ok(gap_pct=0.20, price=21.5) is not None # above $20
|
||||
assert ok(gap_pct=0.20, price=5.0) is None
|
||||
assert ok(gap_pct=None) is None # fail-open on missing gap
|
||||
@@ -386,3 +386,18 @@ def test_squeeze_exception_off_by_default():
|
||||
b = ArmedTriggerBook(gap_min=10, gap_max=35, rvol_min=2)
|
||||
b.arm([_cand("LHSW", gap=278.0, rvol=40.0)])
|
||||
assert b.triggers["LHSW"].state == WEAK
|
||||
|
||||
|
||||
def test_trigger_eligibility_uses_static_open_gap_not_dynamic():
|
||||
"""2026-07-08 audit: TVRD's true overnight gap was 6% but its dynamic gap crossed
|
||||
10% on an intraday rally and it traded as a 'gapper'. Once the session open is
|
||||
known, eligibility must use the STATIC gap."""
|
||||
b = ArmedTriggerBook(gap_min=10, gap_max=35, rvol_min=2)
|
||||
c = _cand("TVRD", gap=13.2, rvol=6.0) # dynamic says 13.2%...
|
||||
c["gap_open_pct"] = 6.0 # ...but the true overnight gap is 6%
|
||||
b.arm([c])
|
||||
assert b.triggers["TVRD"].state == WEAK
|
||||
c2 = _cand("BABU", gap=25.0, rvol=6.0)
|
||||
c2["gap_open_pct"] = 19.0 # true gapper stays armed
|
||||
b.arm([c2])
|
||||
assert b.triggers["BABU"].state == ARMED
|
||||
+33
-8
@@ -680,13 +680,33 @@ class TradingExecutionService:
|
||||
m = self._confirm_minute_of(ts)
|
||||
return m is not None and m > limit
|
||||
|
||||
def _population_reject(self, gap_pct, cum_volume) -> str | None:
|
||||
"""Reason string when the signal is OUTSIDE the +EV population (gap/volume floors);
|
||||
None when tradeable. Fail-open: missing values never block."""
|
||||
def _population_reject(self, gap_pct, cum_volume, price=None, rvol=None) -> str | None:
|
||||
"""Reason string when the signal is OUTSIDE the +EV population (gap band,
|
||||
volume floor, price band); None when tradeable. gap_pct MUST be the STATIC
|
||||
overnight gap (session open vs prev close) — the 2026-07-08 audit found both
|
||||
paths passing dynamic/intraday gaps, letting non-gappers (TVRD 6%, SKIN 2.2%,
|
||||
JLHL 4.3%) and dead-band names (SEER 38.6%, IOTR 40.7%) into the book.
|
||||
Fail-open: missing values never block (except the dead band, which requires
|
||||
a VERIFIED squeeze to re-admit)."""
|
||||
if self.settings.min_entry_gap_pct > 0 and gap_pct is not None:
|
||||
try:
|
||||
if float(gap_pct) < self.settings.min_entry_gap_pct:
|
||||
return f"gap {float(gap_pct):.1%} < {self.settings.min_entry_gap_pct:.0%}"
|
||||
g = float(gap_pct)
|
||||
if g < self.settings.min_entry_gap_pct:
|
||||
return f"gap {g:.1%} < {self.settings.min_entry_gap_pct:.0%}"
|
||||
# dead band (35-150%): +0.02R at year scale — excluded. Above 150%
|
||||
# only VOLUME-VERIFIED squeezes re-enter (real ones ran +0.63R;
|
||||
# reverse-split fakes show rvol ~0.01).
|
||||
if 0.35 < g < 1.50:
|
||||
return f"gap {g:.0%} in dead band 35-150%"
|
||||
if g >= 1.50 and (rvol is None or float(rvol) < 5.0):
|
||||
return f"gap {g:.0%} squeeze unverified (rvol {rvol})"
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if price is not None:
|
||||
try:
|
||||
px = float(price)
|
||||
if px > 0 and not (1.0 <= px <= 20.0):
|
||||
return f"price {px:.2f} outside $1-20 band"
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if self.settings.min_day_volume > 0 and cum_volume is not None:
|
||||
@@ -769,8 +789,10 @@ class TradingExecutionService:
|
||||
|
||||
# POPULATION gate — the year-long +EV boundary: real gappers with real
|
||||
# volume. Fail-open on missing data; skips are logged and visible.
|
||||
pop_reason = self._population_reject(signal.get("gap_pct"),
|
||||
signal.get("cum_volume"))
|
||||
pop_reason = self._population_reject(
|
||||
signal.get("gap_pct"), signal.get("cum_volume"),
|
||||
price=signal.get("entry_price"),
|
||||
rvol=signal.get("relative_volume"))
|
||||
if pop_reason:
|
||||
self.store.emit(
|
||||
RiskRuleTriggeredEvent(
|
||||
@@ -1052,6 +1074,8 @@ class TradingExecutionService:
|
||||
day_open: float | None = None,
|
||||
rank: int = 0,
|
||||
gap_pct: float | None = None,
|
||||
rvol: float | None = None,
|
||||
price: float | None = None,
|
||||
) -> dict:
|
||||
"""Fire a breakout entry immediately on a LIVE price cross.
|
||||
|
||||
@@ -1074,7 +1098,8 @@ class TradingExecutionService:
|
||||
if self._late_confirmation(self._now()):
|
||||
return {"ok": False, "skipped": "late_confirmation"}
|
||||
# POPULATION gate (same as the auto path): the year-long +EV boundary
|
||||
if self._population_reject(gap_pct, cum_volume or None):
|
||||
if self._population_reject(gap_pct, cum_volume or None,
|
||||
price=price if price else last_price, rvol=rvol):
|
||||
return {"ok": False, "skipped": "population_below"}
|
||||
|
||||
# CONCENTRATE-BY-RANK: only the top-N armed names may enter, and risk is
|
||||
|
||||
Reference in new issue
Block a user