Enforced the max_copies_per_event cap in the auto-trade engine. The field was wired through DB loading and validation but was never checked at order-placement time — every signal for a given event would pass through regardless of how many copies were already live.

Root Cause

max_copies_per_event was present in AutotradeConfig (the DB model) and mapped in engine_config(), but:

  1. It was never a field on EngineConfig or ReserveCaps, so the engine had no way to consult it.
  2. Even if it had been, there was no query to count in-flight live orders for the same event_slug before inserting a new reservation.

Changes — branch autotrade-foundation (2026-06-16)

engine.rs

  • EngineConfig: added pub max_copies_per_event: usize.
  • ReserveCaps: added pub max_copies_per_event: usize.
  • caps_from_cfg(): new const fn that constructs ReserveCaps from EngineConfig. Extracted to keep ingest_entry under the 100-line Clippy limit.
  • pre_lock_cap_skip(): extracted the pre-lock fast-path cap checks (exposure / spend / count) into a private async helper, also for the 100-line limit.
  • Test helper cfg(): updated with max_copies_per_event: 0 (zero = unlimited).

persistence.rs

  • engine_config(): maps atc.max_copies_per_event: i32 to usize via max(0, val) as usize.

Zero-means-unlimited (differs from max_open_positions)

Zero disables the cap (no check is performed). This is intentionally different from max_open_positions where 0 blocks all entries. The divergence guards against a DB zero-default accidentally halting all event copies.

  • try_reserve LIVE branch: added per-event-slug count check under the existing FOR UPDATE config lock, before the INSERT:
SELECT COUNT(*) FROM pmv2_autotrade_orders
WHERE mode = 'live'
  AND status NOT IN ('dry_run', 'closed', 'canceled', 'failed')
  AND event_slug = $1

Rejects (returns Ok(None)) when caps.max_copies_per_event > 0 && event_count >= caps.max_copies_per_event.

  • New persistence tests:
    • engine_config_max_copies_per_event_zero_means_unlimited — asserts that 0 in DB produces max_copies_per_event: 0 (unlimited).
    • engine_config_max_copies_per_event_negative_clamps_to_zero — asserts that a negative DB value clamps to 0 (unlimited, never panics).

No pre-lock fast-path

There is no pre-lock fast-path for the per-event count (unlike the exposure/spend/count checks). The in-tx query under the config FOR UPDATE lock is both sufficient and correct. There is no exposure_rows-style pre-lock source that would carry per-event counts.

Safety semantics

The check runs only in the LIVE reservation branch. Dry-run orders do not count toward the per-event cap (consistent with all other cap logic: only mode='live' rows with non-terminal statuses count).

Status exclusions match the dedup partial index: ('dry_run', 'closed', 'canceled', 'failed').

Gate result

327 tests passed, 0 failed. Clippy + fmt clean. Exit 0.

Deferred

  • is_resolution_only and min_fill_size_usd: left deferred with existing #[allow(dead_code)] attributes untouched.