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 payloadsourcefield inwire.rs::parse_signal. Per-source mode is resolved generically inrunner.rs::per_message_modeviarepo::autotrade_source→config::effective_mode. Caps live in a single global rowpmv2_autotrade_config WHERE id = 1. Per-source rows (pmv2_autotrade_sources) carry ONLYmode+enabled. DRY parse helperparse_directive(p, action, source_id)is shared byweatherandcrypto_shortterm_latency_testas of 2026-06-19. Thecrypto_shortterm_latency_testsource is seededmode='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:
- A parse branch in
crates/polymarket-fetch/src/pmv2/autotrade/wire.rs::parse_signalproducing aTradeSignalwith the newsource_id. - 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)
weatherandcrypto_shortterm_latency_testboth share the singleparse_directive(p, action, source_id)helper (wire.rs:89), generalized from the oldparse_weather. A new Directive-shaped source is one moreif 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, aconst fn) returns the MORE CONSERVATIVE of the two by rankoff(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.moderow is explicitly changed (AND the global mode islive—effective_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_usdper_event_cap_usd,total_exposure_cap_usd,daily_spend_cap_usdmax_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:
- Flip the
crypto_shortterm_latency_testsource row tomode='live'. - Set the global mode to
live. - 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
offand 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.
Related
- pmv2-position-manager-nats-consumer — defines
wire.rs::parse_signal, theDirectivevsContextsplit,effective_mode, and the per-sourcemodecolumn; this note records the extensibility and caps-granularity consequences of that design - autotrade-config-repo — the
AutotradeConfigsingleton (id=1) and thepmv2_autotrade_sourcesmode/enabled mutators - cohort-firstmover-nats-migration — the cohort (
first_mover_core, Context shape) lane that shares this routing; first-mover live trigger is the Phase 2 referenced above - autotrade-sources-signal-emitter —
SOURCE_CRYPTO_LATENCY/SOURCE_IDconstants and the source-agnostic engine pattern - autotrade-dry-refactors-2026-06-16 — prior DRY pass in the same modules
- pmv2-autotrade-engine-design — full engine design
- polymarket-fetch — project overview