Phase 2 refactor that hoists the first-mover NATS publish logic out of positions.rs and into a shared firstmover.rs function, so that the cron and any future watcher both use a single canonical emit path — identical id generation, gating, and retry semantics by construction.
Branch: autotrade-phase2-firstmover (uncommitted). Gate: GATE_EXIT=0.
For Agents
Files changed:
autotrade/nats.rs,autotrade/firstmover.rs,pmv2/positions.rs,autotrade/sources.rs. Sits on top of cohort-firstmover-nats-migration (which migrated the cohort entry to NATS and deletedrunner::run_entry). That note’sPUBLISH_RETRIES=3,PUBLISH_RETRY_BACKOFF=200ms, andCOHORT_SUBJECTconstants were previously private literals insidepositions.rs; they are nowpub consts innats.rs. The call siteprocess_entered_entryinpositions.rsis untouched —run_autotrade_entrystill has the same signature; only its body changed from inline publish logic to a single delegation call.
What changed
1. Publish consts hoisted to nats.rs
Three literals formerly defined inline at positions.rs:411-413 are now pub consts with a single canonical home:
PUBLISH_RETRIES: u32 = 3
PUBLISH_RETRY_BACKOFF: Duration = 200ms
COHORT_SUBJECT: &str = "cohort.signals"
Both positions.rs and the new firstmover.rs import them from nats. Any future producer automatically inherits the same retry policy and subject name.
2. PublishOutcome enum + build_and_publish_first_mover_entry in firstmover.rs
PublishOutcome is a typed return value replacing the former unit return:
PublishOutcome::Published — signal was serialized and the JetStream PubAck was received
PublishOutcome::SkippedTier — trader tier != Core (or tier field absent)
PublishOutcome::SkippedBreaker — breaker is tripped; no publish attempted
PublishOutcome::PublisherNone — no NATS publisher configured (dormant lane)
build_and_publish_first_mover_entry(atc, trader, position, conviction, publisher, notify) is the shared async entry point. It:
- Short-circuits on
PublisherNonewhen the optional publisher arg isNone. - Checks
atc.breaker_tripped(returnsSkippedBreaker). - Checks
trader.tier == Some(Core)(returnsSkippedTier). - Calls
sources::enter_signalto build theTradeSignal. - Serializes via
nats::serialize_cohortand publishes vianats::publish_cohortwith the bounded retry (PUBLISH_RETRIES/PUBLISH_RETRY_BACKOFF). - On retry exhaustion: logs a WARN and fires the
notifyclosure with a “first-mover entry LOST” alarm — preserving the operator-visibility behaviour from the post-cutover hardening (39c01ddin cohort-firstmover-nats-migration).
3. run_autotrade_entry in positions.rs — 1-call delegation
The former inline body (breaker check → tier check → serialize → publish loop → retry exhaustion warn) is replaced by:
firstmover::build_and_publish_first_mover_entry(atc, trader, position, conviction, publisher, notify).awaitprocess_entered_entry (the only call site) is untouched.
4. Tests added to firstmover.rs (3 async tokio tests)
| Test | What it pins |
|---|---|
producer_skips_non_core | build_and_publish_first_mover_entry returns SkippedTier for Tier::Proven and Tier::Emerging traders |
producer_skips_when_breaker_tripped | returns SkippedBreaker when atc.breaker_tripped=true, regardless of tier |
producer_skips_when_publisher_none | returns PublisherNone when publisher arg is None, without inspecting tier or breaker |
5. Regression pin in sources.rs
cron_run_autotrade_entry_emits_unchanged_signal_id pins the exact signal_id string that run_autotrade_entry emits for a known fixture:
"first_mover_core|0xtrader|0xcond|1|enter|0xcond"
This guards against any future change to cohort_signal_id or enter_signal accidentally shifting the dedup key used by the DB ON CONFLICT(dedup_key) DO NOTHING backstop — which would cause two live orders for the same logical entry.
Behavioral equivalence note
The original cron combined breaker and tier into a single guard:
if atc.breaker_tripped || t.tier != Some(Core) { return; }The new build_and_publish_first_mover_entry checks them sequentially (breaker first, then tier). These are semantically equivalent: || short-circuits and checks breaker first anyway. The only observable difference is the typed PublishOutcome return instead of unit. Retry semantics are faithfully replicated (3 retries, 200ms backoff, warn + notify on drop).
Dedup identity still load-bearing
The
signal_idproduced bysources::enter_signal(viacohort_signal_id) must remain byte-identical to the legacyorders::dedup_key(..,"enter"..)so that theON CONFLICT(dedup_key)DB backstop fires correctly between any two producers that observe the same position. The regression pin insources.rs(cron_run_autotrade_entry_emits_unchanged_signal_id) is the guard for this invariant. Do not changecohort_signal_idorenter_signal’s field ordering without updating the pin.
Why this matters for Phase 2
Phase 2 will add a watcher-triggered publish path: when the on-chain OrderFilled detector sees a cohort entry, the watcher should publish to cohort.signals immediately, cutting the ~30-min REST-poll lag. Both the cron and the watcher must produce identical signal_id values for the same logical position — otherwise the DB dedup doesn’t fire and a double live order is placed.
By having both paths call build_and_publish_first_mover_entry, id generation, gating, and retry are identical by construction rather than by convention.
Related
- cohort-firstmover-nats-migration — migrated cohort entry to NATS and deleted
runner::run_entry; this hoist sits directly on top - autotrade-sources-canonical-signal-id — Phase 2 B1, the canonical
signal_idfix (lowercase canonicalization) that preceded this hoist - autotrade-sources-signal-emitter — original
sources.rsfirst-mover emitter (Task 6) - pmv2-position-manager-nats-consumer — the NATS consumer that ingests
cohort.signals - pmv2-autotrade-engine-design — overall autotrade design and dedup contract