Two durable facts about the pmv2 autotrade engine: (1) adding a new signal source that fits the “Directive” shape is generic — a parse branch plus a DB row, zero engine/runner/sizing changes; and (2) all autotrade caps/sizing are GLOBAL, not per-source — per-source granularity is only mode + enabled, which is the direct safety surface for the planned crypto latency test and a known operational gap once Phase 2 first-mover live arms.

For Agents — state at a glance

Branch autotrade-foundation. Sources route by the payload source field in wire.rs::parse_signal. Per-source mode is resolved generically in runner.rs::per_message_mode via repo::autotrade_sourceconfig::effective_mode. Caps live in a single global row pmv2_autotrade_config WHERE id = 1. Per-source rows (pmv2_autotrade_sources) carry ONLY mode + enabled. DRY parse helper parse_directive(p, action, source_id) is shared by weather and crypto_shortterm_latency_test as of 2026-06-19. The crypto_shortterm_latency_test source is seeded mode='off' at rest but is LIVE-capable, NOT dry-pinned — measuring fill latency requires a real submitted order (dry runs nothing).

Fact 1 — Adding a source is generic (parse branch + DB row)

A new source that fits the existing Directive shape (BUY a token up to max_price, valid until valid_until_ts) needs only two changes, no engine work:

  1. A parse branch in crates/polymarket-fetch/src/pmv2/autotrade/wire.rs::parse_signal producing a TradeSignal with the new source_id.
  2. A row in pmv2_autotrade_sources(source_id, enabled, mode).

No changes to engine / runner / sizing / execution / reconcile / exits.

Routing — wire.rs::parse_signal

parse_signal dispatches on the payload source field. Directive-shaped sources all funnel through one shared helper:

if p.source == SOURCE_WEATHER {
    return parse_directive(p, action, SOURCE_WEATHER);
}
if p.source == SOURCE_CRYPTO_LATENCY {
    return parse_directive(p, action, SOURCE_CRYPTO_LATENCY);
}
if p.source == SOURCE_ID {              // first_mover_core (cohort, Context shape)
    return parse_cohort(p, action);
}
ParseOutcome::Skip(format!("unknown source {:?}", p.source))

DRY pattern (established 2026-06-19)

weather and crypto_shortterm_latency_test both share the single parse_directive(p, action, source_id) helper (wire.rs:89), generalized from the old parse_weather. A new Directive-shaped source is one more if p.source == … { parse_directive(…) } line — it does NOT need its own parser.

Per-source mode is resolved GENERICALLY — runner.rs::per_message_mode

The engine never special-cases a source for mode. per_message_mode (runner.rs:173) resolves the effective mode for any signal:

let global_mode = Mode::from_str_loose(&atc.mode).unwrap_or(Mode::Off);
let (source_mode, _) = repo::autotrade_source(pool, signal.source_id)
    .await
    .unwrap_or((Mode::Off, false));
PerMessageMode {
    eff: config::effective_mode(global_mode, source_mode),
    breaker_tripped: atc.breaker_tripped,
}
  • repo::autotrade_source(pool, source_id) (pmv2/repo.rs:1337) returns (Mode, bool) for the row, defaulting to (Mode::Off, false).
  • config::effective_mode(global, source) (config.rs:43, a const fn) returns the MORE CONSERVATIVE of the two by rank off(0) < dry_run(1) < live(2).
  • A missing source row is FAIL-CLOSED to Mode::Off (the .unwrap_or((Mode::Off, false))), and a failed config load also fails closed (eff: Mode::Off, breaker_tripped: true).

Dry-pin — a generic capability (mode='dry_run' cannot reach live)

Because effective_mode is a floor, a source row pinned at mode='dry_run' cannot reach live regardless of the global mode — the most a live global can do is leave that source at dry_run. This is the safe “dry-pin,” available to any source as an opt-in choice.

Live flip is a deliberate row change

A dry-pinned source only reaches live when its pmv2_autotrade_sources.mode row is explicitly changed (AND the global mode is liveeffective_mode = min(global, source)). The live flip is Andras’s call, never an automatic consequence of arming the global engine.

crypto_shortterm_latency_test is NOT dry-pinned

The crypto latency test is a deliberate exception: it is seeded mode='off' at rest (a no-op until armed) and is LIVE-capable, NOT dry-pinned. Dry-run cannot measure fill latency because no order is submitted in dry mode, so there is no round-trip to time. The test runs LIVE — see Fact 2 for how it is armed and contained.

Fact 2 — Caps/sizing are GLOBAL, not per-source (operational gap)

AutotradeConfig is a single global row loaded via SELECT … FROM pmv2_autotrade_config WHERE id = 1 (config.rs:109). Every cap/sizing knob is global:

  • base_usd, min_size_usd, max_size_usd
  • per_event_cap_usd, total_exposure_cap_usd, daily_spend_cap_usd
  • max_open_positions, max_copies_per_event
  • slippage / premium bps, etc.

Per-source granularity is ONLY mode + enabled (the two columns in pmv2_autotrade_sources). There are no per-source cap columns.

Implication for the crypto latency test (the test’s safety surface)

The crypto latency test runs LIVE (dry can’t time fills — no order is submitted). Arming it is a deliberate three-part flip for the test window:

  1. Flip the crypto_shortterm_latency_test source row to mode='live'.
  2. Set the global mode to live.
  3. Set tight global caps (small base_usd / size / exposure) for the window.

Because caps are global (Fact 2), those tight caps are the only thing bounding the live test — there are no per-source caps to scope them to just this source. Andras gates the live flip.

Currently low-impact, becomes a real conflict at Phase 2

  • Today: LOW-IMPACT. Global mode is off and the first-mover live-trade trigger is Phase 2 (not built / not armed), so no cohort trade signals flow. During the test window the tight global caps apply to the crypto source with nothing else producing live trades to collide with.
  • Becomes a real conflict once Phase 2 arms first-mover live. A tiny-caps latency-test window and the real first-mover strategy cannot have different caps simultaneously — they share the one global config row.
  • Future fix: per-source cap columns, needed only if both the latency test and the first-mover strategy must run live concurrently.