Population gate: trade only real gappers — the year-long +EV boundary
The gapper-history reconstruction (n=3,008 symbol-days / 14 months) REFUTED the 15-min
confirmation gate (late crosses beat early 12/14 months incl June 2026 — the 19-day
"+0.42R early" was a definition artifact) and found the first at-scale +EV configuration:
top-20 ranked gappers (gap >= 10%, $1-20, day volume >= 300k) are +0.05..+0.15R under the
live BE@+5% ratchet exits in EVERY rank x timing segment — including rank 6-20 ("D tier",
owner-approved: +0.153R early, the best cell). The live book bled by trading OUTSIDE that
population (low-gap, thin names), not by mistiming.
- ExecutionSettings.min_entry_gap_pct / min_day_volume (TRADING_MIN_ENTRY_GAP_PCT=0.10,
TRADING_MIN_DAY_VOLUME=300000): POPULATION gate on BOTH entry paths — auto path skips +
logs rule_type=population_below; fast path returns skipped=population_below. Fail-open
on missing data (house rule: never block on unknowns).
- evaluate_setup now surfaces the true overnight gap_pct in signal_data; ready-signal
snapshot passes it through.
- fast path: submit_breakout_now takes gap_pct (trigger book stores % units; call site
normalizes to fraction); TRIGGER_GAP_MIN raised 3 -> 10 to align the arm floor.
- confirmation gate flipped OFF via env (code stays, 0=disabled).
- rank concentration stays OFF (owner call, data-consistent: rank 6-20 converts fine).
Tests: population skip+log, real gapper passes, fail-open on missing data. 52 pass.
Monday config: population gate + cap 10 fills + quality C+ + VWAP + BE@+5% ratchet + SIP.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
05508e6345
commit
c72bb4e43b
5 files changed
+89
-1
No files matched your search
@@ -862,6 +862,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
t.symbol, t.trigger, t.stop, last_price=t.price,
|
t.symbol, t.trigger, t.stop, last_price=t.price,
|
||||||
cum_volume=t.cum_volume, halted=_recently_halted(t.symbol),
|
cum_volume=t.cum_volume, halted=_recently_halted(t.symbol),
|
||||||
day_open=t.day_open, rank=t.rank,
|
day_open=t.day_open, rank=t.rank,
|
||||||
|
gap_pct=(t.gap_pct / 100.0) if t.gap_pct else None, # book stores % units
|
||||||
)
|
)
|
||||||
if res.get("ok"):
|
if res.get("ok"):
|
||||||
book.mark_fired(t.symbol)
|
book.mark_fired(t.symbol)
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ def query_ready_signals_snapshot(store, session_id: str | None = None) -> list[d
|
|||||||
"above_vwap": signal_data.get("above_vwap"),
|
"above_vwap": signal_data.get("above_vwap"),
|
||||||
"day_open": signal_data.get("day_open"),
|
"day_open": signal_data.get("day_open"),
|
||||||
"cum_volume": signal_data.get("cum_volume"),
|
"cum_volume": signal_data.get("cum_volume"),
|
||||||
|
"gap_pct": signal_data.get("gap_pct"),
|
||||||
"timestamp": event.get("timestamp"),
|
"timestamp": event.get("timestamp"),
|
||||||
}
|
}
|
||||||
return sorted(
|
return sorted(
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ def evaluate_setup(
|
|||||||
"above_vwap": bool(above_vwap),
|
"above_vwap": bool(above_vwap),
|
||||||
"day_open": round(float(open_price), 4) if open_price > 0 else None,
|
"day_open": round(float(open_price), 4) if open_price > 0 else None,
|
||||||
"cum_volume": float(bars["volume"].sum()),
|
"cum_volume": float(bars["volume"].sum()),
|
||||||
|
# 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
|
||||||
|
"gap_pct": round(float(gap_pct), 6),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -67,12 +67,15 @@ class Clock:
|
|||||||
|
|
||||||
|
|
||||||
def emit_signal(store, symbol="GOOD", entry=14.0, stop=13.45,
|
def emit_signal(store, symbol="GOOD", entry=14.0, stop=13.45,
|
||||||
above_vwap=None, vwap=None, day_open=None, cum_volume=None, quality_score=0.7):
|
above_vwap=None, vwap=None, day_open=None, cum_volume=None,
|
||||||
|
quality_score=0.7, gap_pct=None):
|
||||||
signal_data = {
|
signal_data = {
|
||||||
"entry_price": entry,
|
"entry_price": entry,
|
||||||
"stop_loss_price": stop,
|
"stop_loss_price": stop,
|
||||||
"quality_score": quality_score,
|
"quality_score": quality_score,
|
||||||
}
|
}
|
||||||
|
if gap_pct is not None:
|
||||||
|
signal_data["gap_pct"] = gap_pct
|
||||||
if above_vwap is not None:
|
if above_vwap is not None:
|
||||||
signal_data["above_vwap"] = above_vwap
|
signal_data["above_vwap"] = above_vwap
|
||||||
signal_data["vwap"] = vwap if vwap is not None else (
|
signal_data["vwap"] = vwap if vwap is not None else (
|
||||||
@@ -460,6 +463,31 @@ def test_unfilled_entry_is_armed_then_times_out(store):
|
|||||||
assert service._armed == {}
|
assert service._armed == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_population_gate_skips_low_gap_and_thin(store):
|
||||||
|
# POPULATION gate (year-long +EV boundary): a ready signal from a non-gapper
|
||||||
|
# (gap 2%) or a thin name is skipped + logged; a real gapper passes.
|
||||||
|
emit_signal(store, symbol="THIN", gap_pct=0.02, cum_volume=1_000_000)
|
||||||
|
emit_signal(store, symbol="GAPR", gap_pct=0.25, cum_volume=1_000_000)
|
||||||
|
executor = FakeExecutor()
|
||||||
|
service = make_service(store, executor, auto_approve=True,
|
||||||
|
min_entry_gap_pct=0.10, min_day_volume=300_000,
|
||||||
|
max_orders_per_tick=10)
|
||||||
|
service.tick()
|
||||||
|
assert [r.symbol for r in executor.executed] == ["GAPR"]
|
||||||
|
rejects = [json.loads(r["payload_json"]) for r in
|
||||||
|
store.query_events(event_type="risk_rule_triggered")]
|
||||||
|
assert any(r.get("rule_type") == "population_below" for r in rejects)
|
||||||
|
|
||||||
|
|
||||||
|
def test_population_gate_fails_open_on_missing_data(store):
|
||||||
|
emit_signal(store, symbol="NODATA") # no gap_pct/cum_volume fields
|
||||||
|
executor = FakeExecutor()
|
||||||
|
service = make_service(store, executor, auto_approve=True,
|
||||||
|
min_entry_gap_pct=0.10, min_day_volume=300_000)
|
||||||
|
service.tick()
|
||||||
|
assert len(executor.executed) == 1 # never block on missing data
|
||||||
|
|
||||||
|
|
||||||
def test_late_confirmation_skips_signal(store):
|
def test_late_confirmation_skips_signal(store):
|
||||||
# Confirmation-speed gate: the signal went ready at T0=09:45 (minute 15 of the
|
# Confirmation-speed gate: the signal went ready at T0=09:45 (minute 15 of the
|
||||||
# session); with the gate at 10 minutes it must be SKIPPED and shadow-logged,
|
# session); with the gate at 10 minutes it must be SKIPPED and shadow-logged,
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ class ExecutionSettings:
|
|||||||
# gone READY to be tradeable; later signals (incl. midday re-triggers) are
|
# gone READY to be tradeable; later signals (incl. midday re-triggers) are
|
||||||
# skipped + shadow-logged (rule_type=late_confirmation). 0 = disabled.
|
# skipped + shadow-logged (rule_type=late_confirmation). 0 = disabled.
|
||||||
entry_confirm_by_minute: int = 0
|
entry_confirm_by_minute: int = 0
|
||||||
|
# POPULATION GATE — the year-long gapper replay (research/gapper_history.py, n=3,008
|
||||||
|
# symbol-days / 14 months) found the first at-scale +EV boundary: top-ranked gappers
|
||||||
|
# with gap >= 10% and real day volume are +0.05..+0.15R under the live ratchet exits,
|
||||||
|
# in EVERY rank x timing segment — while the live book bled by trading OUTSIDE that
|
||||||
|
# population (low-gap / thin names). Floors are FRACTION (0.10 = 10%) and shares.
|
||||||
|
# 0 = disabled. Fail-open: a signal missing gap/volume data is never blocked.
|
||||||
|
min_entry_gap_pct: float = 0.0
|
||||||
|
min_day_volume: float = 0.0
|
||||||
# UNIFIED ENTRY: run the fast path's shared anti-chase gates (over_extended,
|
# UNIFIED ENTRY: run the fast path's shared anti-chase gates (over_extended,
|
||||||
# day-extension, halt) on the LIVE auto path too. These previously ran ONLY on
|
# day-extension, halt) on the LIVE auto path too. These previously ran ONLY on
|
||||||
# the fast trigger path, leaving live auto entries unprotected (see the
|
# the fast trigger path, leaving live auto entries unprotected (see the
|
||||||
@@ -266,6 +274,8 @@ class ExecutionSettings:
|
|||||||
require_above_vwap=flag("TRADING_REQUIRE_ABOVE_VWAP", "1"),
|
require_above_vwap=flag("TRADING_REQUIRE_ABOVE_VWAP", "1"),
|
||||||
min_quality_score=_qfloat(values.get("TRADING_MIN_QUALITY_SCORE", "0.0")),
|
min_quality_score=_qfloat(values.get("TRADING_MIN_QUALITY_SCORE", "0.0")),
|
||||||
entry_confirm_by_minute=int(values.get("TRADING_ENTRY_CONFIRM_BY_MINUTE", "0")),
|
entry_confirm_by_minute=int(values.get("TRADING_ENTRY_CONFIRM_BY_MINUTE", "0")),
|
||||||
|
min_entry_gap_pct=_qfloat(values.get("TRADING_MIN_ENTRY_GAP_PCT", "0.0")),
|
||||||
|
min_day_volume=float(values.get("TRADING_MIN_DAY_VOLUME", "0.0")),
|
||||||
unified_entry=flag("TRADING_UNIFIED_ENTRY", "1"),
|
unified_entry=flag("TRADING_UNIFIED_ENTRY", "1"),
|
||||||
entry_fill_model=values.get("TRADING_ENTRY_FILL_MODEL", "resting"),
|
entry_fill_model=values.get("TRADING_ENTRY_FILL_MODEL", "resting"),
|
||||||
liquidity_max_volume_pct=float(
|
liquidity_max_volume_pct=float(
|
||||||
@@ -670,6 +680,24 @@ class TradingExecutionService:
|
|||||||
m = self._confirm_minute_of(ts)
|
m = self._confirm_minute_of(ts)
|
||||||
return m is not None and m > limit
|
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."""
|
||||||
|
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%}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if self.settings.min_day_volume > 0 and cum_volume is not None:
|
||||||
|
try:
|
||||||
|
if float(cum_volume) < self.settings.min_day_volume:
|
||||||
|
return (f"day volume {float(cum_volume):,.0f} < "
|
||||||
|
f"{self.settings.min_day_volume:,.0f}")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
def request_approvals_for_ready_signals(self) -> list[str]:
|
def request_approvals_for_ready_signals(self) -> list[str]:
|
||||||
"""Turn fresh ready signals into approval requests. Returns order ids."""
|
"""Turn fresh ready signals into approval requests. Returns order ids."""
|
||||||
if not self.settings.enabled:
|
if not self.settings.enabled:
|
||||||
@@ -739,6 +767,28 @@ class TradingExecutionService:
|
|||||||
self._requested_symbols.add(symbol) # don't re-log every tick
|
self._requested_symbols.add(symbol) # don't re-log every tick
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 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"))
|
||||||
|
if pop_reason:
|
||||||
|
self.store.emit(
|
||||||
|
RiskRuleTriggeredEvent(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
mode=self.mode,
|
||||||
|
correlation_id=self.session_id,
|
||||||
|
message=f"{symbol} outside tradeable population ({pop_reason}) — skipped",
|
||||||
|
rule_type="population_below",
|
||||||
|
rule_value=float(self.settings.min_entry_gap_pct),
|
||||||
|
current_state={"symbol": symbol,
|
||||||
|
"gap_pct": signal.get("gap_pct"),
|
||||||
|
"cum_volume": signal.get("cum_volume")},
|
||||||
|
action_taken="skipped_entry",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._requested_symbols.add(symbol) # don't re-log every tick
|
||||||
|
continue
|
||||||
|
|
||||||
# VWAP selection gate — the one validated entry-quality signal (above-
|
# VWAP selection gate — the one validated entry-quality signal (above-
|
||||||
# VWAP breakouts reach +1R ~1.5x as often, n=3,110). Every below-VWAP
|
# VWAP breakouts reach +1R ~1.5x as often, n=3,110). Every below-VWAP
|
||||||
# ready signal is shadow-logged for measurement; the entry is only
|
# ready signal is shadow-logged for measurement; the entry is only
|
||||||
@@ -1001,6 +1051,7 @@ class TradingExecutionService:
|
|||||||
halted: bool = False,
|
halted: bool = False,
|
||||||
day_open: float | None = None,
|
day_open: float | None = None,
|
||||||
rank: int = 0,
|
rank: int = 0,
|
||||||
|
gap_pct: float | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Fire a breakout entry immediately on a LIVE price cross.
|
"""Fire a breakout entry immediately on a LIVE price cross.
|
||||||
|
|
||||||
@@ -1022,6 +1073,9 @@ class TradingExecutionService:
|
|||||||
# near-real-time, so now() IS the confirmation time. Late breaks are dead.
|
# near-real-time, so now() IS the confirmation time. Late breaks are dead.
|
||||||
if self._late_confirmation(self._now()):
|
if self._late_confirmation(self._now()):
|
||||||
return {"ok": False, "skipped": "late_confirmation"}
|
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):
|
||||||
|
return {"ok": False, "skipped": "population_below"}
|
||||||
|
|
||||||
# CONCENTRATE-BY-RANK: only the top-N armed names may enter, and risk is
|
# CONCENTRATE-BY-RANK: only the top-N armed names may enter, and risk is
|
||||||
# scaled down by rank. Stops the equal-size spray across mediocre gappers
|
# scaled down by rank. Stops the equal-size spray across mediocre gappers
|
||||||
|
|||||||
Reference in new issue
Block a user