Daily-entry cap: enforce on the auto-approval path (the one live entries use)
The per-day fresh-entry cap (TRADING_MAX_FRESH_ENTRIES_PER_DAY) was only checked in the fast submit_breakout_now path. But live entries flow through the SLOW auto-approval path (process_auto_approvals -> approve_order), which had no cap — so the limit was silently dead. Observed 2026-06-23: 44 entries taken against a configured cap of 5 (all approved_by="auto", zero via the fast orb_trigger path). Enforce the cap in approve_order for approved_by="auto", sharing the same self._fresh_entries counter the fast path uses (so the cap is global across both paths). Capped orders are REJECTED (reason=daily_entry_cap) so they leave the pending queue instead of re-skipping every tick. Manual dashboard approvals are a human override and are never capped. Two regression tests: auto path binds at the cap + rejects the overflow; manual approval ignores the cap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
62540bab8c
commit
ac1ac50c9e
2 files changed
+56
No files matched your search
@@ -1,5 +1,6 @@
|
||||
"""Trading execution service tests: signal -> approval -> order -> exit."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
@@ -174,6 +175,40 @@ def test_auto_approve_tick_executes(store):
|
||||
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_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_no_duplicate_requests_for_same_symbol(store):
|
||||
emit_signal(store)
|
||||
service = make_service(store)
|
||||
|
||||
@@ -710,6 +710,20 @@ class TradingExecutionService:
|
||||
if entry is None:
|
||||
return {"ok": False, "error": f"order {order_id} not pending"}
|
||||
request = ExecutionRequest.from_payload(entry["execution_request"])
|
||||
# Per-day fresh-entry cap. Live entries flow through THIS auto-approval
|
||||
# path, so the cap must be enforced here (the fast submit_breakout_now
|
||||
# path shares the same self._fresh_entries counter). Manual dashboard
|
||||
# approvals are a human override and are never capped. Reject (not skip)
|
||||
# so a capped order leaves the pending queue instead of re-skipping every
|
||||
# tick.
|
||||
if approved_by == "auto" and self.settings.max_fresh_entries_per_day > 0:
|
||||
today = self._now().date().isoformat()
|
||||
if self._fresh_entries.get("date") != today:
|
||||
self._fresh_entries = {"date": today, "n": 0}
|
||||
if self._fresh_entries["n"] >= self.settings.max_fresh_entries_per_day:
|
||||
return self.reject_order(
|
||||
order_id, rejected_by="auto", reason="daily_entry_cap"
|
||||
)
|
||||
self.store.emit(
|
||||
OrderApprovedEvent(
|
||||
timestamp=datetime.now(),
|
||||
@@ -741,6 +755,13 @@ class TradingExecutionService:
|
||||
"armed_at": self._now(),
|
||||
"checks": 0,
|
||||
}
|
||||
if (
|
||||
approved_by == "auto"
|
||||
and result.ok
|
||||
and self.settings.max_fresh_entries_per_day > 0
|
||||
and isinstance(self._fresh_entries.get("n"), int)
|
||||
):
|
||||
self._fresh_entries["n"] += 1 # count toward the per-day cap
|
||||
return {
|
||||
"ok": result.ok,
|
||||
"order_id": order_id,
|
||||
|
||||
Reference in new issue
Block a user