Files
momentum-system/tests/unit/test_trading_execution.py
T
momentum devandClaude Opus 4.8 44ff36a669 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>
2026-07-08 10:34:46 -04:00

858 lines
37 KiB
Python

"""Trading execution service tests: signal -> approval -> order -> exit."""
import json
from datetime import datetime, timedelta
import pytest
from alpaca_paper.execution import ExecutionRequest, ExecutionResult
from storage.event_schema import (
AccountPositionsUpdatedEvent,
EventMode,
OrderFilledEvent,
SignalReadyEvent,
)
from storage.event_store import EventStore
from storage.projections import query_approval_queue
from trading_execution import ExecutionSettings, TradingExecutionService
from trading_mode import TradingModeSettings
T0 = datetime(2026, 6, 11, 9, 45)
class FakeExecutor:
broker_name = "alpaca_paper"
def __init__(self, status: str = "accepted"):
self.executed: list[ExecutionRequest] = []
self.cancelled: list[dict] = []
self.ok = True
self.status = status # "accepted" = resting/unfilled; "filled" = instant
def execute(self, request: ExecutionRequest) -> ExecutionResult:
self.executed.append(request)
return ExecutionResult(
ok=self.ok,
order_id=request.order_id,
broker_order_id="broker-1" if self.ok else None,
status=self.status if self.ok else "error",
error=None if self.ok else "broker down",
)
def cancel_entry(self, order_id, broker_order_id, symbol, reason):
self.cancelled.append(
{"order_id": order_id, "symbol": symbol, "reason": reason}
)
return ExecutionResult(ok=True, order_id=order_id, status="cancelled")
@pytest.fixture
def store():
s = EventStore(":memory:")
yield s
s.close()
class Clock:
"""A controllable clock for deterministic wall-clock timeout tests."""
def __init__(self, start: datetime | None = None):
self.t = start or datetime(2026, 6, 11, 9, 45)
def __call__(self) -> datetime:
return self.t
def advance(self, minutes: float) -> None:
self.t += timedelta(minutes=minutes)
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, gap_pct=None):
signal_data = {
"entry_price": entry,
"stop_loss_price": stop,
"quality_score": quality_score,
}
if gap_pct is not None:
signal_data["gap_pct"] = gap_pct
if above_vwap is not None:
signal_data["above_vwap"] = above_vwap
signal_data["vwap"] = vwap if vwap is not None else (
round(entry - 1.0, 2) if above_vwap else round(entry + 1.0, 2))
if day_open is not None:
signal_data["day_open"] = day_open
if cum_volume is not None:
signal_data["cum_volume"] = cum_volume
store.emit(
SignalReadyEvent(
timestamp=T0,
mode=EventMode.PAPER,
correlation_id="t",
message=f"{symbol} ready",
symbol=symbol,
signal_type="bull_flag",
confidence=0.9,
signal_data=signal_data,
)
)
def emit_positions(store, positions):
store.emit(
AccountPositionsUpdatedEvent(
timestamp=T0,
mode=EventMode.PAPER,
message="pos",
broker_name="alpaca_paper",
account_id="paper",
positions=positions,
)
)
def make_service(store, executor=None, price_provider=None, now_fn=None, **settings_kw):
defaults = dict(
enabled=True, auto_approve=False, max_orders_per_tick=2,
max_concurrent_positions=3, risk_per_trade_pct=0.01,
default_equity=100_000.0,
)
defaults.update(settings_kw)
return TradingExecutionService(
store,
executor=executor or FakeExecutor(),
settings=ExecutionSettings(**defaults),
trading_mode=TradingModeSettings(execution_mode="alpaca_paper"),
session_id="t",
price_provider=price_provider,
now_fn=now_fn,
)
def test_ready_signal_creates_approval_request(store):
emit_signal(store)
service = make_service(store)
requested = service.request_approvals_for_ready_signals()
assert len(requested) == 1 # returns order ids
queue = query_approval_queue(store)
assert len(queue) == 1
request = queue[0]["execution_request"]
# risk sizing: 1% of 100k = $1000 risk / $0.55 per share ≈ 1818 shares
assert request["quantity"] == int(1000 / 0.55)
assert request["stop_loss_price"] == 13.45
# take profit at entry + 2R
assert request["take_profit_price"] == pytest.approx(14.0 + 2 * 0.55)
def test_manual_approval_executes_order(store):
emit_signal(store)
executor = FakeExecutor()
service = make_service(store, executor)
service.request_approvals_for_ready_signals()
order_id = query_approval_queue(store)[0]["order_id"]
result = service.approve_order(order_id, approved_by="dashboard")
assert result["ok"]
assert executor.executed[0].symbol == "GOOD"
assert store.query_events(event_type="order_approved")
assert query_approval_queue(store) == [] # no longer pending
def test_rejection_clears_queue_without_executing(store):
emit_signal(store)
executor = FakeExecutor()
service = make_service(store, executor)
service.request_approvals_for_ready_signals()
order_id = query_approval_queue(store)[0]["order_id"]
result = service.reject_order(order_id, reason="too extended")
assert result["ok"]
assert executor.executed == []
assert query_approval_queue(store) == []
assert store.query_events(event_type="order_rejected")
def test_approving_unknown_order_fails_cleanly(store):
service = make_service(store)
assert not service.approve_order("nope")["ok"]
assert not service.reject_order("nope")["ok"]
def test_auto_approve_tick_executes(store):
emit_signal(store)
executor = FakeExecutor()
service = make_service(store, executor, auto_approve=True)
out = service.tick()
assert len(out["approvals_requested"]) == 1
assert len(out["auto_executed"]) == 1
assert executor.executed[0].symbol == "GOOD"
def test_daily_entry_cap_enforced_on_auto_path(store):
# Regression: live entries flow through the AUTO approval path, not the fast
# submit_breakout_now path, so the per-day cap must bind HERE. cap=2 over 3
# distinct signals => exactly 2 execute; the 3rd is rejected (not left pending).
for sym, e, s in [("AAA", 14.0, 13.45), ("BBB", 8.0, 7.60), ("CCC", 5.0, 4.70)]:
emit_signal(store, symbol=sym, entry=e, stop=s)
executor = FakeExecutor()
service = make_service(store, executor, auto_approve=True,
max_fresh_entries_per_day=2,
max_concurrent_positions=10, max_orders_per_tick=10)
service.request_approvals_for_ready_signals()
ids = [q["order_id"] for q in query_approval_queue(store)]
[service.approve_order(oid, approved_by="auto") for oid in ids]
assert len(executor.executed) == 2 # cap binds at 2
assert query_approval_queue(store) == [] # capped order rejected, not left pending
rejects = store.query_events(event_type="order_rejected")
assert len(rejects) == 1 # exactly the 3rd
assert json.loads(rejects[0]["payload_json"])["rejection_reason"] == "daily_entry_cap"
def test_cap_rejection_distinguishable_from_execution(store):
# Log-visibility fix: a cap-rejected auto approval must be tagged rejected=True with
# the reason and carry NO broker_order_id, so the loop log counts it as auto_rejected
# instead of masquerading as auto_executed=1 with no matching broker order.
for sym, e, s in [("AAA", 14.0, 13.45), ("BBB", 8.0, 7.60), ("CCC", 5.0, 4.70)]:
emit_signal(store, symbol=sym, entry=e, stop=s)
service = make_service(store, FakeExecutor(), auto_approve=True,
max_fresh_entries_per_day=2,
max_concurrent_positions=10, max_orders_per_tick=10)
service.request_approvals_for_ready_signals()
ids = [q["order_id"] for q in query_approval_queue(store)]
results = [service.approve_order(oid, approved_by="auto") for oid in ids]
# the exact split run_live_paper.step_execute() uses for the log line
executed = [r for r in results if r.get("broker_order_id")]
rejected = [r for r in results if r.get("rejected")]
assert len(executed) == 2 and len(rejected) == 1 # 2 real submissions, 1 cap-reject
assert all(not r.get("rejected") for r in executed)
assert rejected[0]["reason"] == "daily_entry_cap"
assert "broker_order_id" not in rejected[0] # no phantom broker order counted
def test_daily_cap_never_blocks_manual_approval(store):
# A human override (approved_by="dashboard") is never capped.
for sym, e, s in [("AAA", 14.0, 13.45), ("BBB", 8.0, 7.60)]:
emit_signal(store, symbol=sym, entry=e, stop=s)
executor = FakeExecutor()
service = make_service(store, executor, max_fresh_entries_per_day=1,
max_concurrent_positions=10, max_orders_per_tick=10)
service.request_approvals_for_ready_signals()
ids = [q["order_id"] for q in query_approval_queue(store)]
for oid in ids:
assert service.approve_order(oid, approved_by="dashboard")["ok"]
assert len(executor.executed) == 2 # cap ignored for manual override
def test_vwap_gate_skips_below_vwap_when_enforced(store):
emit_signal(store, symbol="LOWV", entry=14.0, stop=13.45, above_vwap=False, vwap=15.0)
service = make_service(store, require_above_vwap=True)
created = service.request_approvals_for_ready_signals()
assert created == [] # entry skipped
assert query_approval_queue(store) == []
vb = [json.loads(e["payload_json"]) for e in store.query_events(event_type="risk_rule_triggered")
if json.loads(e["payload_json"])["rule_type"] == "vwap_below"]
assert len(vb) == 1 and vb[0]["action_taken"] == "skipped_entry"
def test_vwap_gate_shadow_logs_but_allows_when_off(store):
emit_signal(store, symbol="LOWV", entry=14.0, stop=13.45, above_vwap=False, vwap=15.0)
service = make_service(store, require_above_vwap=False)
created = service.request_approvals_for_ready_signals()
assert len(created) == 1 # still entered (shadow mode)
vb = [json.loads(e["payload_json"]) for e in store.query_events(event_type="risk_rule_triggered")
if json.loads(e["payload_json"])["rule_type"] == "vwap_below"]
assert len(vb) == 1 and vb[0]["action_taken"] == "shadow_logged"
def test_quality_gate_skips_low_grade_when_enforced(store):
emit_signal(store, symbol="CHOP", quality_score=0.30) # F-grade chop
service = make_service(store, min_quality_score=0.50)
created = service.request_approvals_for_ready_signals()
assert created == [] # entry skipped
assert query_approval_queue(store) == []
qb = [json.loads(e["payload_json"]) for e in store.query_events(event_type="risk_rule_triggered")
if json.loads(e["payload_json"])["rule_type"] == "quality_below"]
assert len(qb) == 1 and qb[0]["action_taken"] == "skipped_entry"
def test_quality_gate_off_allows_low_grade(store):
emit_signal(store, symbol="CHOP", quality_score=0.30)
created = make_service(store, min_quality_score=0.0).request_approvals_for_ready_signals()
assert len(created) == 1 # gate off -> still enters
def test_quality_gate_allows_at_or_above_threshold(store):
emit_signal(store, symbol="GOOD", quality_score=0.72) # B-grade
created = make_service(store, min_quality_score=0.50).request_approvals_for_ready_signals()
assert len(created) == 1
def test_vwap_gate_allows_above_vwap(store):
emit_signal(store, symbol="HIV", entry=14.0, stop=13.45, above_vwap=True, vwap=13.0)
service = make_service(store, require_above_vwap=True)
created = service.request_approvals_for_ready_signals()
assert len(created) == 1
assert not [e for e in store.query_events(event_type="risk_rule_triggered")
if json.loads(e["payload_json"])["rule_type"] == "vwap_below"]
def test_vwap_gate_fail_open_on_missing_field(store):
# signals lacking the above_vwap field (old signals) must NEVER be blocked
emit_signal(store, symbol="OLDV", entry=14.0, stop=13.45) # no above_vwap
service = make_service(store, require_above_vwap=True)
assert len(service.request_approvals_for_ready_signals()) == 1
def test_anti_chase_skip_logic(store):
svc = make_service(store) # defaults: ext 0.15, day-ext 0.30, halt-guard on
assert svc._anti_chase_skip(12.0, 10.0, None, False) == "over_extended" # +20% > 15%
assert svc._anti_chase_skip(14.0, 14.0, 10.0, False) == "over_extended_day" # +40% > 30%
assert svc._anti_chase_skip(10.0, 10.0, 10.0, True) == "halted_symbol"
assert svc._anti_chase_skip(10.0, 10.0, 9.0, False) is None # clean
assert svc._anti_chase_skip(14.0, 14.0, None, False) is None # fail-open day_open
def test_unified_entry_blocks_parabolic_day_on_auto_path(store):
# entry 14 is +40% above the day open 10 -> day-extension gate blocks it
emit_signal(store, symbol="PARA", entry=14.0, stop=13.45, above_vwap=True, day_open=10.0)
created = make_service(store, unified_entry=True).request_approvals_for_ready_signals()
assert created == []
rr = [json.loads(e["payload_json"]) for e in store.query_events(event_type="risk_rule_triggered")
if json.loads(e["payload_json"])["rule_type"] == "over_extended_day"]
assert len(rr) == 1 and rr[0]["action_taken"] == "skipped_entry"
def test_unified_entry_off_allows_parabolic_day(store):
emit_signal(store, symbol="PARA", entry=14.0, stop=13.45, above_vwap=True, day_open=10.0)
assert len(make_service(store, unified_entry=False).request_approvals_for_ready_signals()) == 1
def test_unified_entry_fail_open_missing_day_open(store):
emit_signal(store, symbol="NODO", entry=14.0, stop=13.45, above_vwap=True) # no day_open
assert len(make_service(store, unified_entry=True).request_approvals_for_ready_signals()) == 1
def test_unified_entry_allows_normal_day(store):
# entry 14 is +7.7% above day open 13 -> under the 30% ceiling -> allowed
emit_signal(store, symbol="OKDAY", entry=14.0, stop=13.45, above_vwap=True, day_open=13.0)
assert len(make_service(store, unified_entry=True).request_approvals_for_ready_signals()) == 1
def test_liquidity_cap_sizes_down_thin_name_on_auto_path(store):
# thin name (cum_volume 10k) -> shares capped to liq*cum_volume, not risk-sized
emit_signal(store, symbol="THIN", entry=2.0, stop=1.9, above_vwap=True,
day_open=1.95, cum_volume=10_000)
svc = make_service(store, unified_entry=True, liquidity_max_volume_pct=0.01,
max_concurrent_positions=10)
svc.request_approvals_for_ready_signals()
q = query_approval_queue(store)[0]["execution_request"]["quantity"]
assert q <= int(0.01 * 10_000) # liquidity-capped at 100 shares
def test_liquidity_cap_off_when_unified_disabled(store):
emit_signal(store, symbol="THIN", entry=2.0, stop=1.9, above_vwap=True,
day_open=1.95, cum_volume=10_000)
svc = make_service(store, unified_entry=False, liquidity_max_volume_pct=0.01,
max_concurrent_positions=10)
svc.request_approvals_for_ready_signals()
q = query_approval_queue(store)[0]["execution_request"]["quantity"]
assert q > int(0.01 * 10_000) # not capped -> risk-based size is larger
def test_fill_model_resting_rests_at_level(store):
emit_signal(store, symbol="REST", entry=14.0, stop=13.45, above_vwap=True)
make_service(store).request_approvals_for_ready_signals() # default resting
req = query_approval_queue(store)[0]["execution_request"]
assert req["entry_price"] == 14.0 # rests AT the level
def test_fill_model_marketable_lifts_above_trigger(store):
emit_signal(store, symbol="MKT", entry=14.0, stop=13.45, above_vwap=True)
make_service(store, entry_fill_model="marketable").request_approvals_for_ready_signals()
req = query_approval_queue(store)[0]["execution_request"]
assert req["entry_price"] == round(14.0 * 1.004, 2) # a hair above (slippage 0.4%)
assert req["entry_price"] > 14.0
assert req["stop_loss_price"] == 13.45 # R still measured from the level
def test_no_duplicate_requests_for_same_symbol(store):
emit_signal(store)
service = make_service(store)
assert len(service.request_approvals_for_ready_signals()) == 1
assert service.request_approvals_for_ready_signals() == []
assert len(query_approval_queue(store)) == 1
def test_held_symbols_are_skipped(store):
emit_signal(store)
emit_positions(store, [{"symbol": "GOOD", "quantity": 10}])
service = make_service(store)
assert service.request_approvals_for_ready_signals() == []
def test_max_concurrent_positions_guard(store):
emit_signal(store, symbol="NEWS")
emit_positions(
store,
[{"symbol": s, "quantity": 1} for s in ("AAA", "BBB", "CCC")],
)
service = make_service(store, max_concurrent_positions=3)
assert service.request_approvals_for_ready_signals() == []
assert store.query_events(event_type="risk_rule_triggered")
def test_exit_order_closes_position(store):
emit_positions(store, [{"symbol": "GOOD", "quantity": 10}])
executor = FakeExecutor()
service = make_service(store, executor)
result = service.submit_exit_order("GOOD")
assert result["ok"]
exit_request = executor.executed[0]
assert exit_request.side == "sell"
assert exit_request.quantity == 10
def test_exit_order_without_position_fails(store):
service = make_service(store)
result = service.submit_exit_order("NOPE")
assert not result["ok"]
# ---------------------------------------------------------------------------
# Auto-arm entry mechanism: tunable reward, entry order type, and back-out
# ---------------------------------------------------------------------------
def test_reward_multiple_is_configurable(store):
emit_signal(store) # entry 14.0 stop 13.45 -> risk 0.55
service = make_service(store, reward_multiple=3.0)
service.request_approvals_for_ready_signals()
req = query_approval_queue(store)[0]["execution_request"]
assert req["take_profit_price"] == pytest.approx(14.0 + 3.0 * 0.55)
def test_entry_order_type_flows_into_request(store):
emit_signal(store)
service = make_service(store, entry_order_type="limit")
service.request_approvals_for_ready_signals()
req = query_approval_queue(store)[0]["execution_request"]
assert req["order_type"] == "limit"
def test_unfilled_entry_is_armed_then_times_out(store):
emit_signal(store)
executor = FakeExecutor(status="accepted") # resting limit, never fills
clock = Clock()
service = make_service(
store, executor, auto_approve=True, now_fn=clock,
entry_timeout_bars=2, entry_invalidate_pct=-1.0, # disable price-break
)
# request + auto-approve -> order is armed, not yet expired
service.tick()
assert len(service._armed) == 1
assert executor.cancelled == []
# 1 minute later: under the 2-minute window -> still armed
clock.advance(1)
assert service.expire_stale_entries() == []
assert len(service._armed) == 1
# 2 minutes after arming -> timed out and backed out
clock.advance(1)
out = service.expire_stale_entries()
assert len(out) == 1
assert len(executor.cancelled) == 1
assert "timed out" in executor.cancelled[0]["reason"]
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):
# 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,
# never executed. This is the backtested +EV filter (early confirms +0.42R,
# late confirms dead).
emit_signal(store) # ready at T0 = minute 15
executor = FakeExecutor()
service = make_service(store, executor, auto_approve=True,
entry_confirm_by_minute=10)
service.tick()
assert executor.executed == [] # never reached the broker
rejects = store.query_events(event_type="risk_rule_triggered")
assert any(json.loads(r["payload_json"]).get("rule_type") == "late_confirmation"
for r in rejects)
def test_early_confirmation_passes(store):
# same signal, gate at 20 minutes -> minute-15 confirmation is fine
emit_signal(store)
executor = FakeExecutor()
service = make_service(store, executor, auto_approve=True,
entry_confirm_by_minute=20)
service.tick()
assert len(executor.executed) == 1
def test_confirmation_gate_off_by_default(store):
emit_signal(store)
executor = FakeExecutor()
service = make_service(store, executor, auto_approve=True) # gate default 0 = off
service.tick()
assert len(executor.executed) == 1
def test_fast_path_blocks_late_breakout(store):
# the live trigger path uses now() as the confirmation time
executor = FakeExecutor()
clock = Clock(datetime(2026, 6, 11, 11, 0)) # 11:00 = minute 90
service = make_service(store, executor, now_fn=clock, entry_confirm_by_minute=15)
out = service.submit_breakout_now("GOOD", trigger=14.0, stop=13.45, last_price=14.01)
assert out.get("skipped") == "late_confirmation"
clock.t = datetime(2026, 6, 11, 9, 40) # minute 10 -> inside the window
out2 = service.submit_breakout_now("GOOD", trigger=14.0, stop=13.45, last_price=14.01)
assert out2.get("skipped") != "late_confirmation"
def test_backed_out_unfilled_entry_frees_its_cap_slot(store):
# Behavior fix: the daily cap counts FILLS, not submissions. Two resting entries consume
# the cap, but when they back out UNFILLED their slots are freed (the 2026-07-01/02 bug
# where phantom unfilled limits capped the day after only a few real trades).
for sym, e, s in [("AAA", 14.0, 13.45), ("BBB", 8.0, 7.60), ("CCC", 5.0, 4.70)]:
emit_signal(store, symbol=sym, entry=e, stop=s)
executor = FakeExecutor(status="accepted") # resting limits, never fill
clock = Clock()
service = make_service(store, executor, auto_approve=True, now_fn=clock,
max_fresh_entries_per_day=2, max_concurrent_positions=10,
max_orders_per_tick=10, entry_timeout_bars=2, entry_invalidate_pct=-1.0)
service.request_approvals_for_ready_signals()
ids = [q["order_id"] for q in query_approval_queue(store)]
results = [service.approve_order(oid, approved_by="auto") for oid in ids]
assert service._fresh_entries["n"] == 2 # 2 armed+counted, cap hit
assert sum(1 for r in results if r.get("rejected")) == 1 # 3rd was cap-rejected
assert len(service._armed) == 2
clock.advance(3) # both time out unfilled
service.expire_stale_entries()
assert service._armed == {}
assert service._fresh_entries["n"] == 0 # <-- the fix: slots freed
def test_fast_guard_does_not_shorten_timeout(store):
"""Checking many times a second must not trip the wall-clock timeout early."""
emit_signal(store)
executor = FakeExecutor(status="accepted")
clock = Clock()
service = make_service(
store, executor, auto_approve=True, now_fn=clock,
entry_timeout_bars=2, entry_invalidate_pct=-1.0,
)
service.tick()
# hammer the guard 50 times with no time passing -> no premature back-out
for _ in range(50):
assert service.expire_stale_entries() == []
assert len(service._armed) == 1
assert executor.cancelled == []
def test_unfilled_entry_invalidated_by_price_break(store):
emit_signal(store, entry=14.0, stop=13.45)
executor = FakeExecutor(status="accepted")
price = {"GOOD": 14.10} # starts above the trigger
service = make_service(
store, executor, auto_approve=True,
entry_timeout_bars=0, # disable timeout; isolate price-break
entry_invalidate_pct=0.0, # any trade below entry invalidates
price_provider=lambda sym: price.get(sym),
)
service.tick()
assert len(service._armed) == 1
# backdate past the fill-grace window so the price-break logic can act
from datetime import datetime, timedelta
for a in service._armed.values():
a["armed_at"] = datetime.now() - timedelta(seconds=30)
# price holds above entry -> no back-out
assert service.expire_stale_entries() == []
# price breaks back below the entry trigger -> cancel
price["GOOD"] = 13.90
out = service.expire_stale_entries()
assert len(out) == 1
assert "invalidated" in executor.cancelled[0]["reason"]
assert service._armed == {}
def test_armed_entry_cleared_on_fill_without_cancel(store):
emit_signal(store)
executor = FakeExecutor(status="accepted")
service = make_service(
store, executor, auto_approve=True, entry_timeout_bars=1,
)
service.tick()
assert len(service._armed) == 1
order_id = next(iter(service._armed))
# broker reports the fill via the event store
store.emit(
OrderFilledEvent(
timestamp=T0, mode=EventMode.PAPER, message="fill",
order_id=order_id, symbol="GOOD", fill_price=14.01, fill_quantity=10,
)
)
out = service.expire_stale_entries()
# filled -> tracking stops, nothing cancelled even though timeout=1
assert out == []
assert executor.cancelled == []
assert service._armed == {}
def test_backout_frees_symbol_to_resignal(store):
emit_signal(store)
executor = FakeExecutor(status="accepted")
clock = Clock()
service = make_service(
store, executor, auto_approve=True, now_fn=clock,
entry_timeout_bars=1, entry_invalidate_pct=-1.0,
)
service.tick()
symbol = service._armed[next(iter(service._armed))]["symbol"]
assert symbol in service._requested_symbols
clock.advance(1) # reach the 1-minute timeout
service.expire_stale_entries()
# after backing out, the symbol is no longer marked as requested
assert symbol not in service._requested_symbols
# ---------------------------------------------------------------------------
# Backout cooldown (anti-thrash): a name that won't fill is benched, not
# re-armed every pass (NEOV armed+backed-out 68x in one live session).
# ---------------------------------------------------------------------------
def test_backout_benches_symbol_until_cooldown_elapses(store):
emit_signal(store)
executor = FakeExecutor(status="accepted")
clock = Clock()
service = make_service(
store, executor, auto_approve=True, now_fn=clock,
entry_timeout_bars=1, entry_invalidate_pct=-1.0,
backout_cooldown_seconds=120, max_backouts_per_symbol=3,
)
service.tick()
sym = service._armed[next(iter(service._armed))]["symbol"]
clock.advance(1) # hit the timeout -> backs out
out = service.expire_stale_entries()
assert len(out) == 1 and "cooldown" in out[0]
# benched right after the backout; a fresh ready signal must NOT re-arm it
assert service._in_cooldown(sym)
emit_signal(store)
assert service.request_approvals_for_ready_signals() == []
# still benched just before the window closes...
clock.advance(1.9) # 114s elapsed (< 120)
assert service._in_cooldown(sym)
# ...and eligible once the cooldown elapses
clock.advance(0.2) # 126s elapsed (> 120)
assert not service._in_cooldown(sym)
assert len(service.request_approvals_for_ready_signals()) == 1
def test_reentry_blocked_after_position_closes(store):
"""Once a name's position closes (stop-out / trail), it must not be re-entered
this session — both the fast trigger and the slow approval path skip it."""
from alpaca_paper.execution import AlpacaPaperExecutor
class _Cli:
def __init__(self):
self.pos = []
def get_positions(self, fresh=False):
return list(self.pos)
def get_account(self):
return {"equity": "100000", "last_equity": "100000"}
def submit_order(self, **kw):
return {"id": "o", "status": "new", "filled_qty": "0"}
def cancel_order(self, order_id):
pass
cli = _Cli()
svc = TradingExecutionService(
store, executor=AlpacaPaperExecutor(store, client=cli),
settings=ExecutionSettings(reentry_block_after_exit=True, auto_approve=True,
reentry_min_loss_pct=0.01, max_daily_loss_pct=0.5),
session_id="t",
)
cli.pos = [{"symbol": "AAA", "qty": "100", "unrealized_plpc": "-0.05"}] # down 5%
svc.expire_stale_entries() # records AAA as held (losing)
assert "AAA" not in svc._exited_today
cli.pos = [] # AAA stops out
svc.expire_stale_entries() # detects the losing departure
assert "AAA" in svc._exited_today
# fast path blocked
res = svc.submit_breakout_now("AAA", trigger=10.0, stop=9.5, last_price=10.0)
assert res["ok"] is False and res["skipped"] == "reentry_blocked"
# slow path blocked too
emit_signal(store, symbol="AAA", entry=10.0, stop=9.5)
assert svc.request_approvals_for_ready_signals() == []
def test_reentry_allowed_after_winning_exit(store):
"""A name that scratched or WON is not benched — a quick re-entry can be the
right call (the GRAB +513 case the blunt guard would have killed)."""
from alpaca_paper.execution import AlpacaPaperExecutor
class _Cli:
def __init__(self):
self.pos = []
def get_positions(self, fresh=False):
return list(self.pos)
def get_account(self):
return {"equity": "100000", "last_equity": "100000"}
def submit_order(self, **kw):
return {"id": "o", "status": "new", "filled_qty": "0"}
def cancel_order(self, order_id):
pass
cli = _Cli()
svc = TradingExecutionService(
store, executor=AlpacaPaperExecutor(store, client=cli),
settings=ExecutionSettings(reentry_block_after_exit=True, auto_approve=True,
reentry_min_loss_pct=0.01, max_daily_loss_pct=0.5),
session_id="t",
)
cli.pos = [{"symbol": "WIN", "qty": "100", "unrealized_plpc": "0.04"}] # up 4%
svc.expire_stale_entries()
cli.pos = [] # WIN exits in profit
svc.expire_stale_entries()
assert "WIN" not in svc._exited_today # not benched -> re-entry allowed
res = svc.submit_breakout_now("WIN", trigger=10.0, stop=9.5, last_price=10.0)
assert res["ok"] is True
def test_repeated_backouts_bench_symbol_for_session(store):
clock = Clock()
service = make_service(
store, now_fn=clock,
backout_cooldown_seconds=60, max_backouts_per_symbol=3,
)
# first two backouts only cool the name temporarily
for _ in range(2):
service._register_backout("THRASH")
assert service._in_cooldown("THRASH")
clock.advance(2) # 120s > 60s -> cooldown elapses
assert not service._in_cooldown("THRASH")
# the third hits the cap -> benched for the rest of the session
service._register_backout("THRASH")
clock.advance(10_000) # hours later, still benched
assert service._in_cooldown("THRASH")
# --- catalyst dilution veto (Phase 2; gated OFF by default) -----------------
def _service_with_catalyst(store, advisory, *, veto_enabled, executor=None):
settings = ExecutionSettings(
enabled=True, auto_approve=False, max_orders_per_tick=2,
max_concurrent_positions=3, risk_per_trade_pct=0.01,
default_equity=100_000.0,
catalyst_veto_enabled=veto_enabled,
catalyst_veto_min_conviction=0.6,
)
return TradingExecutionService(
store,
executor=executor or FakeExecutor(),
settings=settings,
trading_mode=TradingModeSettings(execution_mode="alpaca_paper"),
session_id="t",
catalyst_provider=lambda sym: advisory,
)
def test_dilution_veto_blocks_entry_and_emits_event(store):
emit_signal(store, symbol="DILUT")
advisory = {"is_dilutive": True, "conviction": 0.9,
"catalyst_type": "offering_dilution", "sentiment": -0.8,
"rationale": "registered direct offering"}
service = _service_with_catalyst(store, advisory, veto_enabled=True)
requested = service.request_approvals_for_ready_signals()
assert requested == [] # no approval created
assert query_approval_queue(store) == []
risk_events = store.query_events(event_type="risk_rule_triggered")
assert any(
__import__("json").loads(e["payload_json"]).get("rule_type")
== "catalyst_dilution_veto"
for e in risk_events
)
def test_dilution_veto_disabled_allows_entry(store):
emit_signal(store, symbol="DILUT")
advisory = {"is_dilutive": True, "conviction": 0.9}
service = _service_with_catalyst(store, advisory, veto_enabled=False)
assert len(service.request_approvals_for_ready_signals()) == 1
def test_dilution_veto_below_conviction_allows_entry(store):
emit_signal(store, symbol="MAYBE")
advisory = {"is_dilutive": True, "conviction": 0.4} # under the 0.6 floor
service = _service_with_catalyst(store, advisory, veto_enabled=True)
assert len(service.request_approvals_for_ready_signals()) == 1
def test_non_dilutive_catalyst_allows_entry(store):
emit_signal(store, symbol="FDA")
advisory = {"is_dilutive": False, "conviction": 0.95,
"catalyst_type": "fda_approval"}
service = _service_with_catalyst(store, advisory, veto_enabled=True)
assert len(service.request_approvals_for_ready_signals()) == 1
def test_breakout_now_respects_dilution_veto(store):
advisory = {"is_dilutive": True, "conviction": 0.9}
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