Тема
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:
- Two-sided emission — when
MAKER_REBATE_PAIRED_ENABLED=true,MakerRebateStrategy::evaluate_periodicemits twoDecision::PlaceOrder(one YES, one NO) per admitted market on the samecondition_id, instead of the Phase 1 single-side cheaper-leg bid. - Explicit
RebatePairStatemachine —Pending → OneFilled(side) → BothFilled → MergedwithCancelled { reason }andManualReconcile { side_filled, first_fill_at }escalation sub-states. - On-chain merge on
BothFilled— strategy emitsDecision::CallMergePositions { neg_risk: false }; executor performs the standard CTF merge on Polygon. - 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 toManualReconcile, and emits atracing::warn!("paired_imbalance_escalation")for the operator to act on. - 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 inevaluate_periodic. Existing.env.maker_rebatepresets keep behaving exactly as before X5.
Implementation phases
| Phase | Date | Commit | Scope | Tests |
|---|---|---|---|---|
| X5.1 — ADR | 2026-05-03 | 4185085 | Doc-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 evaluator | 2026-05-03 | 184257c | evaluate_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 extract | 2026-05-03 | 38d58a3 | Extracted 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 hooks | 2026-05-03 | 32cc2a6 | Wired 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 — Docs | 2026-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:
Readyhappy path — both bids in band, combined withinmax_combined, within per-pair budget. Pre-computedplanned_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 returnsNonefor one or both legs.PriceOutOfBand—best_bidoutside[min_price, max_price]on at least one side.PairedDisabled—paired_enabled = falseearly-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_periodicadmits paired pair → emits twoPlaceOrder+ insertsPending.on_full_fill(one leg) →Pending → OneFilled(side).on_full_fill(other leg) →OneFilled → BothFilled.evaluate_periodicpost-BothFilled→ emitsCallMergePositions, transitions toMerged.evaluate_periodictimeout fromOneFilled→ emitsCancelOrder+paired_imbalance_escalationwarn + transitions toManualReconcile.on_orderbook_updatedrift detection onPending→ emits twoCancelOrder+ transitions toCancelled { Drift }.- RTDS toxic-flow detector cancels
Pendingpair →Cancelled { ToxicFlow }. paired_enabled = false→ nopairsmutation, no paired emission, Phase 1 single-side path runs unchanged.- Per-pair budget enforcement at admission.
Merged/Cancelled/ManualReconcileare all terminal — no re-admission for the samecondition_id.- Race-condition test: both legs fill on the same
on_full_fillevent →Pending → OneFilled → BothFilledin one shot. - Webhook signal handler dedup unchanged (legacy invariant preserved).
pairs_leninvariant test (zero whenpaired_enabled = false).- Merge-qty zero edge case:
BothFilledwithmin(filled_size) ≈ 0→ skipCallMergePositionsemit, advance toMergeddirectly. - 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 var | Type | Default | Semantics |
|---|---|---|---|
MAKER_REBATE_PAIRED_ENABLED | bool | false | Master 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_PAIR | Decimal (USD) | 50.00 | Per-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_SECS | u64 | 60 | Time-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
pairsDashMap 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_periodicfalls through to the legacy single-side cheaper-leg emission unchanged. The cheaper-side selection, thebest_bid + 1 tickprice, thefloor(budget_per_market / price)size, theSide::Buy / OrderType::Gtd / post_only=trueshape — 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
| File | Delta |
|---|---|
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.md | TD-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-orchestratortest target — pre-existingNEG_RISK_ADAPTER_ADDRESSimport 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
- ADR-011 — Paired YES + NO maker-rebate emission — full design, INV-A/B/C, six alternatives considered.
- Paired maker-rebate runbook — operator playbook for activation, ManualReconcile, smoke validation.
- Tech-debt backlog — TD-7 row (Resolved / obsolete after X5.4).
- TD3-B summary — predecessor sprint that landed the
RebatePairskeleton (wiring-first perfeedback_no_fake_migration). crates/pm-bot/src/domain/strategies/maker_rebate.rs— implementation source.crates/pm-bot/src/config/strategy/maker_rebate.rs—MakerRebateConfig::paired_*fields.env/.env.example— paired knobs at lines 287–302.