engine.rs is the Plan 2 capstone that wires the full entry decision pipeline together via injected traits. It is the single point that accepts a TradeSignal from any source and decides whether to dry-run, live-reserve, or skip — by running all gates in sequence, then committing a reservation atomically.

For Agents

File: crates/polymarket-fetch/src/pmv2/autotrade/engine.rs Registered in mod.rs as pub mod engine; Branch: autotrade-foundation (as of 2026-06-14) All 8 unit tests pass. Gate exits 0.

Key design decisions

1. EngineConfig drops Clone

CategoryFilter holds a HashSet<String> and does not derive Clone. The spec’s #[derive(Clone, Debug)] was therefore narrowed to #[derive(Debug)] only on EngineConfig. No other files were changed to accommodate this.

2. min_round_trip_edge_bps must exceed 200 in tests

The ev_floor_gate check is size * bps / 10_000 > size * 0.02 (the cost rate). With bps=1 this condition can never be true. The test helper uses 300 bps to pass the gate cleanly.

3. ingest_entry takes &(dyn Trait + Send + Sync)

The future returned by ingest_entry must be Send to run on tokio’s multi-thread runtime. This requires every trait object passed in to also be Send + Sync.

4. Dedup / reserve is the single mutation point (step 9)

All gate checks are pure and run before try_reserve. A None result from the reserve store means “already in flight” → Skipped("dedup"). This is the only place state is mutated.

5. category_gate always passes None for category

Plan 3 will wire real category resolution. For now None is always passed so eq: filters fail-closed (skip) and neq:/no-filter proceeds normally. This is intentional and documented.

6. Async tests use #[tokio::test]

tokio is a normal (non-dev) dependency with the macros feature, so #[tokio::test] works in unit tests without a separate dev-dependency entry.

Gate execution order

All gates run in a fixed sequence; the first Skip short-circuits the rest.

1. mode gate          — OFF → Skipped("off")
2. liveness_gate      — resolved/closed/unknown-end-time → Skipped("not open" / similar)
3. price_bounds_gate  — outside [min_entry, max_entry] → Skipped("price out of bounds")
4. ev_floor_gate      — size × bps/10_000 ≤ est_fee → Skipped("ev floor")
5. min_conviction     — conviction < floor → Skipped("sized out")
6. category_gate      — eq: filter fails-closed; neq:/no-filter proceeds
7. chase_gate         — ask > reference_price × (1 + bps/10_000) → Skipped("edge decayed")
8. exposure_cap       — current exposure ≥ cap → Skipped("exposure …")
9. try_reserve        — None = already in flight → Skipped("dedup"); Some(id) → proceed
10. outcome:
    - DRY_RUN → DryRunPlanned { token_id, maker_amount }
    - LIVE    → LiveReserved { reserve_id }

Test matrix (all 8 pass)

TestExpected outcome
off_mode_skips_immediatelySkipped("off")
dedup_returns_skippedSkipped("dedup")
resolved_market_skips_not_openSkipped containing "not open"
below_min_conviction_sizes_outSkipped("sized out")
chase_decayed_skipsSkipped containing "edge decayed"
exposure_cap_skipsSkipped containing "exposure"
happy_path_dry_runDryRunPlanned { token_id="tokYes", maker_amount>0 }
happy_path_liveLiveReserved { reserve_id: 7 }

Files changed

FileChange
crates/polymarket-fetch/src/pmv2/autotrade/engine.rsCreated — full engine
crates/polymarket-fetch/src/pmv2/autotrade/mod.rsAdded pub mod engine;