Entry liquidity gate + liquidity sizing + breakeven-at-1R

Today's losses were an ENTRY-quality problem: every loser reversed before +1R
(max favorable excursion 0.1-0.7R) — we filled at the top of one-bar spikes on
names too thin to hold the move (WKSP/LNKS/UWMC entered on 100-400 share bars).
rvol is RELATIVE, so 2x of near-nothing still qualified.

- Trigger $-volume floor: ArmedTriggerBook gains min_dollar_vol; step_arm
  computes recent 5-bar $-volume per candidate. Thin names go WEAK instead of
  arming (env TRIGGER_MIN_DOLLAR_VOL, .env=8000). Skips the untradeable
  (APWC $3.8k, WPRT $2.7k).
- Liquidity-aware sizing turned on (.env TRADING_LIQUIDITY_MAX_VOLUME_PCT=0.025):
  size caps to a fraction of volume, so thin names get sized down 90%+ while
  liquid names are untouched — exactly the asymmetry a small real account needs.
- Breakeven-at-1R enabled (.env): forward give-back safeguard. NB it changes 0
  of today's trades (no loser reached +1R) and was a prior OOS 'conservative
  backup' — kept as protection for bigger-winner days, not a today-fixer.

Back-comparison (one day, caveated): the two liquidity filters take the day from
~-1,215 to ~+202 excluding the APWC overlap already covered by the naked-stop
fix + re-entry guard. Needs forward validation, not just this back-fit.

Tests: $-vol gate (thin -> WEAK); 50 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-06-18 15:37:30 -04:00
1 parent 03eb8bef7c
commit 0da1ba733b
3 files changed
+30

No files matched your search

+11
View File
@@ -266,6 +266,7 @@ def build_runtime(args: argparse.Namespace) -> dict:
gap_max=float(os.environ.get("TRIGGER_GAP_MAX", "1e9")),
rvol_min=float(os.environ.get("TRIGGER_RVOL_MIN", "2.0")),
min_range_pct=float(os.environ.get("TRIGGER_MIN_RANGE_PCT", "0.004")),
min_dollar_vol=float(os.environ.get("TRIGGER_MIN_DOLLAR_VOL", "0")),
)
return {
@@ -494,6 +495,15 @@ def main(argv: list[str] | None = None) -> int:
hi, lo, complete = opening_range(bars, orb_bars=orb_bars_v)
stop = lo * (1.0 - stop_cushion_v) if lo else None
catalyst = news_map.get(g.symbol, "")
# recent $-volume (last 5 regular-hours bars) — the liquidity gate.
# rvol is relative; this is the absolute "can it absorb an order" check.
dollar_vol = 0.0
try:
rth = bars[bars["is_regular_hours"]] if "is_regular_hours" in bars else bars
tail = rth.tail(5)
dollar_vol = float((tail["close"] * tail["volume"]).sum())
except Exception: # noqa: BLE001
dollar_vol = 0.0
candidates.append({
"symbol": g.symbol,
"gap_pct": g.gap_pct,
@@ -502,6 +512,7 @@ def main(argv: list[str] | None = None) -> int:
"stop": stop,
"range_pct": ((hi - lo) / hi) if (hi and lo and hi > 0) else 0.0,
"cum_volume": g.cumulative_volume,
"dollar_vol": dollar_vol,
"catalyst": catalyst,
# a fresh catalyst is WHY a small-cap runs — boost it up the rank;
# times an optional manual per-symbol weight (default 1.0)
+7
View File
@@ -59,6 +59,7 @@ class ArmedTrigger:
range_pct: float = 0.0 # (high-low)/high — how wide the opening range is
price: float | None = None # latest live trade price
cum_volume: float = 0.0 # session cumulative volume (for liquidity sizing)
dollar_vol: float = 0.0 # recent $-volume (liquidity gate: skip thin spikes)
catalyst: str = "" # fresh news headline (if any) — why it's hot
state: str = WAITING
rank: int = 0
@@ -99,6 +100,10 @@ class ArmedTriggerBook:
gap_max: float = 1e9 # max gap % — skip blow-off gappers (they fade)
rvol_min: float = 2.0 # min relative volume to be eligible to fire
min_range_pct: float = 0.004 # opening range must be at least this wide
# min recent $-volume to fire. rvol is RELATIVE, so 2x of near-nothing still
# qualifies — thin names then fill at the top of a one-bar spike and reverse
# (observed live: WKSP/LNKS/UWMC entered on 100-400 share bars). 0 = off.
min_dollar_vol: float = 0.0
triggers: dict[str, ArmedTrigger] = field(default_factory=dict)
_lock: object = field(default_factory=threading.RLock, repr=False, compare=False)
@@ -110,6 +115,7 @@ class ArmedTriggerBook:
and self.gap_min <= t.gap_pct <= self.gap_max
and t.rvol >= self.rvol_min
and t.range_pct >= self.min_range_pct
and t.dollar_vol >= self.min_dollar_vol
)
@_synced
@@ -141,6 +147,7 @@ class ArmedTriggerBook:
t.stop = c.get("stop")
t.range_pct = float(c.get("range_pct") or 0.0)
t.cum_volume = float(c.get("cum_volume") or 0.0)
t.dollar_vol = float(c.get("dollar_vol") or 0.0)
t.catalyst = c.get("catalyst") or ""
t.rank = rank
if not c.get("complete") or t.trigger is None:
+12
View File
@@ -75,6 +75,18 @@ def test_book_weak_below_thresholds():
assert b.triggers["AAA"].state == WEAK
def test_book_weak_below_min_dollar_vol():
"""A thin name (low recent $-volume) is too illiquid to fire even if gap/rvol
qualify — it would fill at the top of a one-bar spike and reverse."""
b = ArmedTriggerBook(gap_min=3, rvol_min=2, min_dollar_vol=10_000)
thin = _cand("AAA"); thin["dollar_vol"] = 4_000
b.arm([thin])
assert b.triggers["AAA"].state == WEAK
liquid = _cand("BBB"); liquid["dollar_vol"] = 80_000
b.arm([liquid])
assert b.triggers["BBB"].state == ARMED
def test_book_caps_at_max_armed():
b = ArmedTriggerBook(max_armed=2)
b.arm([_cand(f"S{i}") for i in range(5)])