Task 6 of autotrade-foundation adds the first concrete signal source: sources.rs in pmv2/autotrade/, plus the Tier::from_label parser and the tier field on CohortTrader that feeds it.

Files Changed

FileChange
crates/polymarket-fetch/src/pmv2/model.rsAdded Tier::from_label(s: &str) -> Option<Tier>
crates/polymarket-fetch/src/pmv2/positions.rsAdded tier: Option<crate::pmv2::model::Tier> to CohortTrader
crates/polymarket-fetch/src/pmv2/repo.rslatest_scored_cohort_with_scores now SELECTs t.tier, maps via from_label
crates/polymarket-fetch/src/pmv2/autotrade/sources.rsNew file — the first_mover_core signal source
crates/polymarket-fetch/src/pmv2/autotrade/mod.rsAdded pub mod sources;

Tier::from_label

Added to crates/polymarket-fetch/src/pmv2/model.rs. Case-insensitive string parser over the Tier enum variants:

"core"     -> Some(Tier::Core)
"proven"   -> Some(Tier::Proven)
"emerging" -> Some(Tier::Emerging)
_          -> None

This is a pure parsing utility — it does NOT change how tiers are assigned or how classification logic works. Calling sites can treat None as “unclassified” without altering the scoring or ranking behavior.

tier on CohortTrader (positions.rs)

pub tier: Option<crate::pmv2::model::Tier>

Added as a new field on the CohortTrader struct. Sources and the engine can inspect this to determine whether a trader is in the copy-eligible cohort (Tier::Core).

latest_scored_cohort_with_scores (repo.rs)

The existing cohort-load query gained a t.tier projection:

  • The SQL now SELECTs t.tier (a TEXT column from pmv2_traders, may be NULL).
  • The CohortTraderRow tuple type gained Option<String> for tier, inserted between avg_lost_bet_size and rank.
  • The mapping code calls Tier::from_label(s) on the raw string and stores the result in CohortTrader::tier.
  • NULL in the DB → None in the struct. No NULL handling is needed beyond Option.

For Agents

The column position of tier in the CohortTraderRow tuple matters for sqlx row mapping. It sits between avg_lost_bet_size and rank. If the query ever gains another column before rank, the tuple index must shift accordingly.

sources.rs — the first_mover_core emitter

Path: crates/polymarket-fetch/src/pmv2/autotrade/sources.rs

Constants

pub const SOURCE_ID: &str = "first_mover_core";

Stable identifier threaded into every TradeSignal::source_id this emitter produces. Consumed by the engine’s dedup key, order rows, and Telegram status output.

autotrade_active(mode, enabled) -> bool

pub const fn autotrade_active(mode: AutotradeMode, enabled: bool) -> bool

A const fn guard:

  • Returns false if mode == AutotradeMode::Off.
  • Returns false if !enabled.
  • Returns true otherwise.

The engine calls this before bothering to scan the cohort. Nothing executes in Off mode.

enter_signal(trader, position, conviction) -> TradeSignal

Constructs a TradeSignal with action = SignalAction::Enter { reference_price }.

reference_price MUST be p.avg_price, NOT p.cur_price

reference_price is frozen at the moment of first detection and is used by chase_gate to reject entries where the current ask has drifted too far above what the signal source observed. Using cur_price (the live snapshot price) defeats this guard: by the time the engine evaluates the signal cur_price might already reflect the drift, so the gate can never fire. Using avg_price (the trader’s entry price) correctly captures the price at which the informed trader first entered the position.

The signal populates all TradeSignal fields from the provided trader and position:

  • source_id: SOURCE_ID ("first_mover_core")
  • conviction: passed in from the caller (computed from bet_confidence)
  • source_score: Some(trader.score)
  • tier: trader.tier
  • instance: derived from a stable combination of discriminators

format_dry_run_order(signal, outcome) -> String

Pure formatter for DRY_RUN mode. Produces a human-readable summary of what the engine would execute, without touching the CLOB or the DB. Used in Telegram status replies.

Tests (3 passing)

All three tests live in sources.rs and pass through scripts/gate.sh:

TestWhat it verifies
active_false_when_offautotrade_active(Off, true)false
active_false_when_disabledautotrade_active(DryRun, false)false
enter_signal_freezes_avg_priceThe constructed TradeSignal’s reference_price equals position.avg_price, NOT cur_price

The third test is the critical correctness guard: it builds a position with avg_price ≠ cur_price, calls enter_signal, and asserts the enter variant’s reference_price matches avg_price. A regression here (accidentally using cur_price) would silently break the chase gate.

Gate Result

253 + 87 + 3 + 2 + 2 + 3 + 2 = 352 tests across the workspace, 0 failures. Clippy clean after fixing a missing semicolon in a test match arm.

Design Notes

For Agents

sources.rs is the first concrete signal emitter in the autotrade system. The pattern for future sources: define a SOURCE_ID, implement autotrade_active (or reuse this one), write an enter_signal factory, and hand the TradeSignal to engine::ingest_entry. The engine is fully source-agnostic — all gate/sizing/execution/reconcile logic lives there.

Tier gating is done in the ENGINE, not in sources.rs

enter_signal does not filter by tier. The engine consumes TradeSignal::tier and applies the Tier::Core gate there. sources.rs is responsible only for constructing an accurate signal; the engine decides whether to act.

Task Sequence on autotrade-foundation

TaskDescription
Task 1Vendor + pin rs-clob-client-v2 into crates/polymarket-clob/
Task 2authz.rs — owner-only command authorization
P2.1signal.rsTradeSignal + SignalAction types
P2.3market.rsMarketData trait + ResolvedMarket
P2.4gates.rs — 5 entry gate functions
Plan 2 capstoneengine.rsingest_entry orchestration
Plan 3a T3category.rs — on-demand category resolver
T4market_data.rsClobMarketData impl
T5persistence.rsPgPersistence + engine_config
Task 6sources.rsfirst_mover_core emitter (this note)