Skip to content

TD-7 — Paired Maker-Rebate Emission Sprint Summary ​

Historical sprint summary — TD-7 was completed 2026-05-03 across four sub-rounds (X5.1 ADR, X5.2 evaluator, X5.3 state machine, X5.4 docs). Archived under docs/history/sprints/ as the closure record for the paired YES + NO maker-rebate emission feature. Not a living doc.

Date: 2026-05-03 ADR reference: docs/adr/adr-011-paired-maker-rebate-emission.mdBacklog row: docs/operations/tech-debt-backlog.md → TD-7 Operator runbook: docs/operations/paired-maker-rebate-runbook-2026-05-03.md


Summary ​

TD-7 finishes a multi-sprint thread that started at TD3-B (docs/history/sprints/td3-b-summary.md, 2026-04-21), where the RebatePair skeleton was landed in MakerRebateStrategy per feedback_no_fake_migration (wiring-first deletion order) but never driven by production logic. With TD-7 the wiring is now driven end-to-end:

  1. Two-sided emission — when MAKER_REBATE_PAIRED_ENABLED=true, MakerRebateStrategy::evaluate_periodic emits twoDecision::PlaceOrder (one YES, one NO) per admitted market on the same condition_id, instead of the Phase 1 single-side cheaper-leg bid.
  2. Explicit RebatePairState machine — Pending → OneFilled(side) → BothFilled → Merged with Cancelled { reason } and ManualReconcile { side_filled, first_fill_at } escalation sub-states.
  3. On-chain merge on BothFilled — strategy emits Decision::CallMergePositions { neg_risk: false }; executor performs the standard CTF merge on Polygon.
  4. Imbalance timeout escalation — when one leg fills and the other stays unfilled past paired_imbalance_timeout_secs, the strategy cancels the unfilled leg, transitions the pair to ManualReconcile, and emits a tracing::warn!("paired_imbalance_escalation") for the operator to act on.
  5. Default-off behind a single env knob — MAKER_REBATE_PAIRED_ENABLED=false (default) preserves the Phase 1 single-side codepath bit-for-bit, including DashMap empty-state and the cheaper-leg-only emission in evaluate_periodic. Existing .env.maker_rebate presets keep behaving exactly as before X5.

Implementation phases ​

PhaseDateCommitScopeTests
X5.1 — ADR2026-05-034185085Doc-only sub-round. Created adr-011-paired-maker-rebate-emission.md (537 LOC, Status: Accepted). Pinned design before code: pure evaluator surface, RebatePair state machine, three new opt-in config knobs, on-chain CTF merge on BothFilled, six alternatives considered. Updated TD-7 row in tech-debt-backlog.md. No code touched.980 lib (baseline preserved)
X5.2 — Pure evaluator2026-05-03184257cevaluate_rebate_pair(market, config, book_yes, book_no) → RebatePairDecision enum (variants: Ready / MarketTooLiquid / BudgetExceeded / BookDataMissing / PriceOutOfBand / PairedDisabled). RebatePairState enum + PairLegSide + PairCancelReason. Three new fields on MakerRebateConfig (paired_enabled, paired_budget_per_pair, paired_imbalance_timeout_secs). Env loader extended; env/.env.example updated with the paired block (lines 287–302). 17 unit tests covering every decision variant + edge cases. No trait hook integration yet — pure functions only.980 → 997 (+17 unit tests)
X5.2.5 — Refactor extract2026-05-0338d58a3Extracted shares_to_usdc_units into shared helper crates/pm-bot/src/shared/usdc.rs. Used by the merge-quantity computation in X5.3. Pure refactor, no behavioural change.997 (no test count change)
X5.3 — State machine + trait hooks2026-05-0332cc2a6Wired the pure evaluator into Strategy::evaluate_periodic / on_full_fill / on_orderbook_update. Drove the pairs DashMap through the full state machine. CTF merge call wired via new Decision::CallMergePositions variant. Imbalance-timeout escalation: cancel unfilled leg + tracing::warn!("paired_imbalance_escalation") + transition to ManualReconcile. RTDS toxic-flow gate inherited verbatim. Drift-cancel gate inherited from on_orderbook_update. 16 new integration-shape tests against MockRepository exercising the state machine end-to-end.997 → 1014 (+16 tests, +1 ignored)
X5.4 — Docs2026-05-03(this commit)Operator runbook paired-maker-rebate-runbook-2026-05-03.md, this sprint summary, TD-7 row moved to "Resolved / obsolete" in tech-debt-backlog.md. No code changes.1014 (preserved)

Net test delta across X5: 980 → 1014 = +34 lib tests (17 in X5.2 pure-evaluator + 16 in X5.3 state-machine + 1 ignored).

ADR-011 § "Implementation plan" projected ~995 after X5.2 and ~1010 after X5.3; we landed 997 and 1014 — coverage exceeded the projection.


Tests added ​

X5.2 pure-evaluator unit tests (17) ​

Coverage matrix from crates/pm-bot/src/domain/strategies/maker_rebate.rs test module — every RebatePairDecision variant exercised:

  • Ready happy path — both bids in band, combined within max_combined, within per-pair budget. Pre-computed planned_edge = 1.00 - yes_price - no_price.
  • MarketTooLiquid — combined best-bid sum >= 1.00.
  • BudgetExceeded — yes_price * yes_size + no_price * no_size > paired_budget_per_pair.
  • BookDataMissing — orderbook handle returns None for one or both legs.
  • PriceOutOfBand — best_bid outside [min_price, max_price] on at least one side.
  • PairedDisabled — paired_enabled = false early-return path.
  • Per-side floor / ceiling clamping at the boundary.
  • Tie-break to YES on equal bids (preserves Phase 1 cheaper-side semantics).
  • Idempotency under repeated invocation.
  • Decimal rounding edge cases at $0.30 / $0.69 / $0.99 boundaries.
  • Slug filter passthrough (re-uses Phase 1 admission filter).
  • Neg-risk skip preserved (the pure evaluator is invoked only on non-neg-risk admitted markets).

X5.3 state-machine integration tests (16) ​

Coverage matrix exercising the Strategy trait hooks against MockRepository:

  • evaluate_periodic admits paired pair → emits two PlaceOrder + inserts Pending.
  • on_full_fill (one leg) → Pending → OneFilled(side).
  • on_full_fill (other leg) → OneFilled → BothFilled.
  • evaluate_periodic post-BothFilled → emits CallMergePositions, transitions to Merged.
  • evaluate_periodic timeout from OneFilled → emits CancelOrder + paired_imbalance_escalation warn + transitions to ManualReconcile.
  • on_orderbook_update drift detection on Pending → emits two CancelOrder + transitions to Cancelled { Drift }.
  • RTDS toxic-flow detector cancels Pending pair → Cancelled { ToxicFlow }.
  • paired_enabled = false → no pairs mutation, no paired emission, Phase 1 single-side path runs unchanged.
  • Per-pair budget enforcement at admission.
  • Merged / Cancelled / ManualReconcile are all terminal — no re-admission for the same condition_id.
  • Race-condition test: both legs fill on the same on_full_fill event → Pending → OneFilled → BothFilled in one shot.
  • Webhook signal handler dedup unchanged (legacy invariant preserved).
  • pairs_len invariant test (zero when paired_enabled = false).
  • Merge-qty zero edge case: BothFilled with min(filled_size) ≈ 0 → skip CallMergePositions emit, advance to Merged directly.
  • Operator-cancel reason path (reserved variant, no production trigger yet — kept for symmetry with external reconcilers).
  • Cross-strategy isolation: enabling paired on maker-rebate does not affect bucket-arb / directional / merge-arb test fixtures.

Configuration knobs ​

Three new fields on MakerRebateConfig (all opt-in / semantic-Optional, NOT in BotConfig::validate_required_env_or_panic):

Env varTypeDefaultSemantics
MAKER_REBATE_PAIRED_ENABLEDboolfalseMaster switch. false ⇒ Phase 1 single-side parity preserved bit-for-bit. true ⇒ X5.3 state machine drives paired emission on every admitted market.
MAKER_REBATE_PAIRED_BUDGET_PER_PAIRDecimal (USD)50.00Per-pair budget cap. Both legs combined must fit within: yes_size * yes_price + no_size * no_price <= paired_budget_per_pair. Ignored when paired_enabled = false.
MAKER_REBATE_PAIRED_IMBALANCE_TIMEOUT_SECSu6460Time-window after one leg fills before cancelling the unfilled leg + transitioning to ManualReconcile. Ignored when paired_enabled = false.

Pattern matches BOT_LOCAL_API_* (ADR-005) — all three knobs are still parsed when the master switch is off (a typo in the value still aborts startup) but ignored by the X5.3 state machine.

env/.env.example reference block: lines 287–302.


Default behaviour ​

paired_enabled = false (default) is bit-for-bit equivalent to the pre-X5.2 Phase 1 single-side codepath. Verification:

  • The pure evaluator is only invoked when paired_enabled = true (early-return at function entry).
  • The pairs DashMap is only populated by the X5.3 admission gate; with paired off, the map stays empty (only the legacy webhook signal handler dedup check still uses it, identical to TD3-B baseline).
  • evaluate_periodic falls through to the legacy single-side cheaper-leg emission unchanged. The cheaper-side selection, the best_bid + 1 tick price, the floor(budget_per_market / price) size, the Side::Buy / OrderType::Gtd / post_only=true shape — all preserved verbatim.

The 7 pre-existing periodic_tests from TD3-B continue green unmodified. The 3 webhook tests + 4 RTDS toxic-flow tests preserved verbatim.


LOC delta ​

FileDelta
crates/pm-bot/src/domain/strategies/maker_rebate.rs~+1 200 (X5.2 pure evaluator + X5.3 state machine + tests)
crates/pm-bot/src/config/strategy/maker_rebate.rs+20 (3 new fields + docstrings)
crates/pm-bot/src/config/loader.rs~+30 (3 new env parses)
crates/pm-bot/src/shared/usdc.rs+30 (extracted shares_to_usdc_units helper)
crates/pm-bot/src/domain/decision.rs+15 (new Decision::CallMergePositions variant)
env/.env.example+16 (paired knobs block, lines 287–302)
docs/adr/adr-011-paired-maker-rebate-emission.md+537 (ADR, X5.1)
docs/operations/paired-maker-rebate-runbook-2026-05-03.md+~430 (operator runbook, X5.4)
docs/history/sprints/td7-paired-maker-rebate.md+~250 (this file, X5.4)
docs/operations/tech-debt-backlog.mdTD-7 row updated (X5.1) + moved to Resolved (X5.4)

Net pm-bot LOC: ~ +1 295 LOC (production + tests). Net docs LOC: ~ +1 217 LOC (ADR + runbook + sprint summary).


Build / test status ​

  • cargo check --workspace — GREEN.
  • cargo test -p pm-bot --lib — GREEN, 1014 passed; 0 failed; 1 ignored (X5.4 baseline preserved).
  • cargo clippy -p pm-bot --lib — 0 warnings (pre-existing TD-23 baseline preserved).
  • pm-orchestrator test target — pre-existing NEG_RISK_ADAPTER_ADDRESS import error unrelated to TD-7 (TD-1 OBSOLETE noted in tech-debt-backlog.md; the orchestrator crate has been removed from the workspace, so the error is now historical).

Phase 1 invariants preserved ​

INV-5/6/7/20/21/22/23/25/26/27 (bucket-arb basket placement invariants) are unaffected — TD-7 only touches maker_rebate.rs and its config, not bucket-arb.

The TD3-B Phase 1 single-side parity invariant (one cheaper-leg Decision::PlaceOrder per admitted market when paired is off) is preserved bit-for-bit. The X5 changes are strictly additive.


Future work ​

The following items emerged from X5 but were intentionally deferred out of scope:

paired_drift_cancel_margin knob (potential FE candidate) ​

The X5.3 drift-cancel gate inherits the Phase 1 spread-drift threshold, which was tuned for single-side maker-rebate. Paired emission has a different drift signature (the pair's planned edge depends on both bids moving in lockstep). A separate paired_drift_cancel_margin knob could decouple the two cases and tighten the cancel decision for paired pairs. Defer until live data shows whether the inherited threshold is too tight or too loose.

Status: backlog candidate, not yet a tech-debt item. Open as FE-N if live performance warrants.

Persistent pairs DashMap across restarts ​

Currently the pairs DashMap is lifecycle-bound to the MakerRebateStrategy instance — restart drops all Pending / OneFilled / BothFilled / Cancelled / ManualReconcile entries. For paired emission this means a restart in the middle of a paired flow leaves orphan orders / positions on CLOB that the strategy will not recognize on next admission.

Mitigation today: the pre-existing reconcile_open_orders REST fallback discovers and cancels orphan orders. Operator action: re-validate positions after every restart during paired emission (pm-cli positions --wallet $ADDR).

Persisting pairs across restarts would require a new SQLite table (maker_rebate_pairs) and a recovery path on startup. Out of scope for X5; potential FE candidate after FE-2 (SQLite-based strategy config storage) lands.

Auto-resolve ManualReconcile ​

ADR-011 § "Alternatives considered" F. explicitly rejected auto-resolve for the first cycle: the strategy does not own market-risk decisions on unbalanced positions. Operator escalation is the correct posture. An auto-sell-filled-leg rule is a future option once live data justifies it (e.g. operator data shows option (a) "sell back" is chosen >90% of the time and the sell-back math is well-defined).

Neg-risk paired emission ​

Out of scope per ADR-011 § 5 — neg-risk markets require NegRiskAdapter.mergePositions (not standard CTF), and the cost basis math is different (see project_neg_risk_merge_path memory). Maker-rebate Phase 1 invariant skips neg-risk markets at admission; TD-7 preserves that invariant. Adding neg-risk paired emission is a separate ADR + sprint with its own merge-path validation.


Cross-references ​