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>
975 lines
43 KiB
Python
975 lines
43 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_fill_model_marketable_prices_from_live_last(store):
|
|
# price already ran to 15.10 by the time the slow path submits: the limit
|
|
# must chase from the LIVE price, not rest 2% behind the market at the level
|
|
emit_signal(store, symbol="RUN", entry=14.0, stop=13.45, above_vwap=True)
|
|
svc = make_service(store, entry_fill_model="marketable",
|
|
price_provider=lambda s: 15.10)
|
|
svc.request_approvals_for_ready_signals()
|
|
req = query_approval_queue(store)[0]["execution_request"]
|
|
assert req["entry_price"] == round(15.10 * 1.004, 2)
|
|
assert req["stop_loss_price"] == 13.45 # R still from the level
|
|
|
|
|
|
def test_fill_model_marketable_price_provider_error_falls_back(store):
|
|
def boom(sym):
|
|
raise RuntimeError("feed down")
|
|
emit_signal(store, symbol="ERR", entry=14.0, stop=13.45, above_vwap=True)
|
|
make_service(store, entry_fill_model="marketable",
|
|
price_provider=boom).request_approvals_for_ready_signals()
|
|
req = query_approval_queue(store)[0]["execution_request"]
|
|
assert req["entry_price"] == round(14.0 * 1.004, 2) # trigger-based fallback
|
|
|
|
|
|
def test_fill_model_marketable_live_below_trigger_uses_trigger(store):
|
|
# live last dipped under the level -> never price the limit BELOW the trigger
|
|
emit_signal(store, symbol="DIP", entry=14.0, stop=13.45, above_vwap=True)
|
|
make_service(store, entry_fill_model="marketable",
|
|
price_provider=lambda s: 13.80).request_approvals_for_ready_signals()
|
|
req = query_approval_queue(store)[0]["execution_request"]
|
|
assert req["entry_price"] == round(14.0 * 1.004, 2)
|
|
|
|
|
|
def test_fill_model_marketable_over_extended_live_price_skipped(store):
|
|
# price ran >15% past the trigger before we could submit -> anti-chase gate
|
|
# (previously inert on the slow path because entry_ref == trigger)
|
|
emit_signal(store, symbol="PARA", entry=14.0, stop=13.45, above_vwap=True)
|
|
svc = make_service(store, entry_fill_model="marketable", unified_entry=True,
|
|
price_provider=lambda s: 16.80)
|
|
assert svc.request_approvals_for_ready_signals() == []
|
|
assert query_approval_queue(store) == []
|
|
|
|
|
|
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 the floor
|
|
assert ok(gap_pct=0.20, price=1.50) is not None # $1-2 bucket: -0.031R/609 unbiased entries
|
|
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
|
|
|
|
|
|
def test_session_gate_bars_after_hours_entries():
|
|
"""2026-07-09: SIP prints after-hours trades, and with no session gate the
|
|
loop submitted real resting entries at 18:53-20:09 ET (TDTH x2, SUNE).
|
|
Fresh entries must be barred outside 09:30-15:55 ET and on weekends."""
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
from trading_execution import ExecutionSettings, TradingExecutionService
|
|
svc = TradingExecutionService.__new__(TradingExecutionService)
|
|
svc.settings = ExecutionSettings(session_only=True)
|
|
ET = ZoneInfo("America/New_York")
|
|
assert svc._outside_session(datetime(2026, 7, 9, 20, 9, tzinfo=ET)) is not None # SUNE moment
|
|
assert svc._outside_session(datetime(2026, 7, 9, 8, 45, tzinfo=ET)) is not None # premarket
|
|
assert svc._outside_session(datetime(2026, 7, 11, 10, 0, tzinfo=ET)) is not None # Saturday
|
|
assert svc._outside_session(datetime(2026, 7, 9, 10, 30, tzinfo=ET)) is None # mid-session
|
|
assert svc._outside_session(datetime(2026, 7, 9, 9, 30, tzinfo=ET)) is None # the open
|
|
svc.settings = ExecutionSettings(session_only=False)
|
|
assert svc._outside_session(datetime(2026, 7, 9, 20, 9, tzinfo=ET)) is None # opt-out works
|
|
|
|
|
|
def test_flatten_all_closes_broker_position_missed_by_snapshot(store):
|
|
"""Naked-through-breaker fix: a position that filled in the sync gap isn't in
|
|
the event-store held set, so _flatten_all's step-2 skips it. Step 3 (moved out
|
|
of close_session so the daily-loss breaker gets it too) closes it from fresh
|
|
broker positions."""
|
|
from alpaca_paper.execution import AlpacaPaperExecutor
|
|
|
|
class _Cli:
|
|
def __init__(self):
|
|
self.closed = []
|
|
self.pos = [{"symbol": "GAP", "qty": "100"}] # filled, snapshot missed it
|
|
|
|
def get_positions(self, fresh=False):
|
|
return list(self.pos)
|
|
|
|
def close_position(self, symbol, qty=None, percentage=None):
|
|
self.closed.append(symbol)
|
|
self.pos = [p for p in self.pos if p["symbol"] != symbol]
|
|
return {"id": "c"}
|
|
|
|
def get_orders(self, **kw):
|
|
return []
|
|
|
|
def cancel_order(self, oid):
|
|
pass
|
|
|
|
cli = _Cli()
|
|
svc = TradingExecutionService(
|
|
store, executor=AlpacaPaperExecutor(store, client=cli),
|
|
settings=ExecutionSettings(auto_approve=True), session_id="t")
|
|
result = svc._flatten_all("daily_loss_circuit_breaker")
|
|
assert "GAP" in cli.closed, "breaker flatten must close broker positions the snapshot missed"
|
|
assert "GAP" in result["closed_positions"]
|
|
|
|
|
|
def test_daily_state_rebuilds_from_events_on_restart(store):
|
|
"""Mid-session restart must not reset the entry cap to 0 or forget resting
|
|
orders (re-fire => duplicate order, 2026-07-06). A new service instance
|
|
rebuilds fresh-fill count, requested symbols, and losing benches from today."""
|
|
from storage.event_schema import OrderSubmittedEvent, OrderFilledEvent, PositionClosedEvent
|
|
now = datetime.now()
|
|
store.emit(OrderSubmittedEvent(timestamp=now, mode=EventMode.PAPER, correlation_id="t",
|
|
message="sub", order_id="o1", symbol="AAA", side="buy", quantity=10, price=5.0,
|
|
broker_order_id="b1"))
|
|
store.emit(OrderFilledEvent(timestamp=now, mode=EventMode.PAPER, correlation_id="t",
|
|
message="fill", order_id="o1", symbol="AAA", fill_price=5.0, fill_quantity=10,
|
|
broker_order_id="b1"))
|
|
store.emit(PositionClosedEvent(timestamp=now, mode=EventMode.PAPER, correlation_id="t",
|
|
message="closed", position_id="p1", symbol="AAA", side="buy", quantity=10,
|
|
entry_price=5.0, exit_price=4.5, realized_pnl=-5.0, exit_reason="stop"))
|
|
svc = make_service(store) # boot AFTER the events land
|
|
assert svc._fresh_entries["n"] == 1, "cap count must survive restart"
|
|
assert "AAA" in svc._requested_symbols, "resting-order dedup must survive restart"
|
|
assert "AAA" in svc._exited_today, "losing bench must survive restart"
|