Skip to content

Paired Maker-Rebate Runbook (TD-7 / ADR-011) ​

Operator-facing playbook for the paired YES + NO maker-rebate emission mode introduced by ADR-011 / TD-7 (sprint X5.1 → X5.4, 2026-05-03). Covers activation, the new paired_imbalance_escalation journal event, the operator playbook for the ManualReconcile state, smoke-validation procedure, and rollback.

Related:


1. What changed ​

MakerRebateStrategy historically posted one maker BUY on the cheaper side per admitted market (Phase 1 single-side parity inherited from legacy LegacyStrategy). TD-7 / ADR-011 closes the RebatePair skeleton that has been wired-but-not-driven since TD3-B and adds two-sided emission: YES BUY + NO BUY on the same condition_id, driven through an explicit Pending → OneFilled(side) → BothFilled → Merged state machine, with on-chain CTF.merge on BothFilled to capture the $1.00 − yesCost − noCost spread + maker rebate on both legs.

The new behaviour is opt-in behind a single env knob (MAKER_REBATE_PAIRED_ENABLED=false by default). When the flag is unset / false, the Phase 1 single-side codepath is preserved bit-for-bit — no reordering, no extra evaluator calls, no DashMap mutation. Existing .env.maker_rebate presets keep behaving exactly as before X5.


2. When to enable paired emission ​

ConditionSingle-side (default)Paired (PAIRED_ENABLED=true)
Operator wants minimum capital exposure per marketyesNO — paired doubles per-market exposure
Market liquidity supports both legs at acceptable bidsyesyes
Operator can monitor paired_imbalance_escalation eventsn/aREQUIRED — ManualReconcile is operator-resolved
BOT_TOTAL_BUDGET is tuned for the doubled per-market exposuren/aREQUIRED — see § 3.2
Strategy is running on a neg-risk market setyesNO — neg-risk markets are skipped at admission (standard CTF merge only, ADR-011 § 5)

Do NOT enable paired emission if you cannot react to ManualReconcile events within the trading day. The escalation is intentionally operator-resolved (not auto-sold) per ADR-011 § "Risk consequences" / "Imbalance risk".


3. Activation ​

3.1 Minimum opt-in ​

sh
MAKER_REBATE_PAIRED_ENABLED=true cargo run -p pm-bot -- --strategy maker-rebate

That is the entire activation surface. The other two paired knobs apply defaults:

env
MAKER_REBATE_PAIRED_ENABLED=true
# Optional, default 50.00. Both legs combined must fit within this:
#   yes_size * yes_price + no_size * no_price <= paired_budget_per_pair
MAKER_REBATE_PAIRED_BUDGET_PER_PAIR=50.00
# Optional, default 60. Time-window after one leg fills before
# cancelling the unfilled leg + transitioning to ManualReconcile.
MAKER_REBATE_PAIRED_IMBALANCE_TIMEOUT_SECS=60

All three knobs are semantic-Optional — NOT in BotConfig::validate_required_env_or_panic. When PAIRED_ENABLED is unset / false, the other two are still parsed (a typo aborts startup) but ignored by the X5.3 state machine. Pattern matches BOT_LOCAL_API_* (ADR-005).

3.2 Tune BOT_TOTAL_BUDGET for paired mode ​

Two-sided emission doubles notional exposure per admitted market. A market that took $25 single-side now takes up to paired_budget_per_pair = $50 (default). Operator action:

  • If you previously ran single-side with BOT_TOTAL_BUDGET=200 across ~8 markets at $25 each, the paired-mode equivalent is either BOT_TOTAL_BUDGET=400 (same market count, doubled exposure) or BOT_TOTAL_BUDGET=200 with PAIRED_BUDGET_PER_PAIR=25.00 (halved per-pair exposure, same market count).
  • The pre-existing MAKER_REBATE_* admission filters (max_combined, min_price, max_price, slug_filter) are unchanged — paired emission only adds the per-pair budget cap layered on top.

3.3 Pair the activation with --dry-run first ​

ADR-011 § "Implementation plan" X5.4 explicitly calls for dry-run smoke before flipping live. The full smoke procedure is in § 6 below.


4. New journal event: paired_imbalance_escalation ​

4.1 Wire shape ​

When the X5.3 state machine transitions a RebatePair from OneFilled(side) to ManualReconcile, the strategy emits a tracing::warn! line with the canonical event name paired_imbalance_escalation. Source-of-truth emission site:

text
WARN paired_imbalance_escalation
  cid="0xabc…"                       # condition_id
  filled_side=Yes                    # which leg filled (PairLegSide)
  elapsed_secs=63                    # actual time since first fill
  timeout_secs=60                    # configured window
  unfilled_order_id=Some("0xdef…")   # the order being cancelled

Alongside the warn line, the hook emits a Decision::CancelOrder { order_id, reason: "paired_imbalance_timeout:60s:no" } for the unfilled leg. The pair state advances to RebatePairState::ManualReconcile { side_filled, first_fill_at }.

4.2 Visibility surfaces ​

SurfaceWhat you see
stdout / stderr (RUST_LOG=warn or higher)The paired_imbalance_escalation warn line as plaintext, with key=value pairs.
/local dashboard journal (Local-Bot Mode)Captured into event_log via the EventHub on tracing capture; renders in the Decisions/Signals widget alongside other strategy decisions. Cross-reference local-dashboard-runbook.md.
pm-bot.sqlite event_log table (if BOT_LOCAL_API_ENABLED=true)One row per emit, kind = 'signal' (the warn carries a Decision::CancelOrder and the cancel is journaled too). Replay via pm-cli journal events --kind signal.

4.3 Why a warn (not an error) ​

The escalation is an expected operating mode of paired emission — toxic flow / one-sided liquidity / sudden price move can fill one leg faster than the other. The strategy's job is to detect, cancel the stale leg, escalate to the operator. The position is on-balance and the bot keeps trading other markets. Therefore: WARN, not ERROR.


5. ManualReconcile playbook ​

ManualReconcile is the only state in the RebatePairState machine that does not auto-resolve. The operator decides between three options.

5.1 Decision matrix ​

OptionWhen to chooseOutcome
(a) Sell filled leg backLiquidity has not recovered; staying long the single outcome adds market risk.Loss ≈ spread + slippage on the filled leg's notional. Position closed. Capital recovered modulo slippage.
(b) Hold to resolutionHigh conviction the market resolves favourable to the filled side; days/weeks of capital lockup acceptable.If resolution favours the held side: $1.00 per share minus filled cost. If unfavourable: total loss of the filled leg.
(c) Re-post the missing leg manuallyLiquidity returned (orderbook visibly recovered); willing to re-enter the paired bid manually.Re-establishes the pair; operator transitions back to a normal Pending → BothFilled → Merged flow but off-strategy (the strategy will not re-seed because pairs[cid] is in ManualReconcile, not absent).

Default recommendation: (a) sell the filled leg back. It is the cheapest exit, terminates the position, and removes the imbalance from the operator's mental model.

5.2 Step-by-step — sell filled leg back ​

sh
# 1. Inspect the pair state to confirm the imbalance.
sqlite3 ./pm-bot.sqlite \
  "SELECT id, ts_ms, kind, payload FROM event_log
   WHERE kind = 'signal' AND payload LIKE '%paired_imbalance_escalation%'
   ORDER BY id DESC LIMIT 5;"

# 2. Identify the filled leg's token_id from the warn line (cid + filled_side).
#    `filled_side=Yes` ⇒ YES token, `filled_side=No` ⇒ NO token.

# 3. Sell the filled leg back at the current best bid via pm-cli.
cargo run -p pm-cli -- sell-position \
  --token-id <token_id> \
  --private-key $WALLET_PRIVATE_KEY

# 4. Confirm the position is closed.
cargo run -p pm-cli -- positions --wallet $WALLET_ADDR

The strategy will not automatically re-seed the pair — once a pair enters ManualReconcile, the X5.3 admission gate skips that condition_id until the entry is removed from the pairs map (next idle sweep, or pm-bot restart). This is intentional — re-seeding into the same imbalanced market would just repeat the failure.

5.3 Step-by-step — hold to resolution ​

No action needed for the filled leg. Cancel the unfilled leg — already done by the strategy's Decision::CancelOrder emission. The operator's only job is to track the position to settlement.

5.4 Step-by-step — re-post missing leg ​

sh
# 1. Confirm the orderbook has recovered (best_ask on the missing side
#    is back to acceptable levels).
cargo run -p pm-cli -- orderbook --token-id <missing_token_id>

# 2. Manually post a maker BUY on the missing leg.
cargo run -p pm-cli -- place-order \
  --token-id <missing_token_id> \
  --side BUY \
  --price <maker_safe_price> \
  --size <size_to_match_filled_leg> \
  --order-type GTD \
  --gtd-seconds 300 \
  --post-only \
  --private-key $WALLET_PRIVATE_KEY

Once both legs are filled, manually trigger the merge (the strategy state machine is stuck at ManualReconcile and will not auto-merge):

sh
cargo run -p pm-cli -- redeem --condition-id <cid> --private-key $WALLET_PRIVATE_KEY --rpc-url $RPC
# Or, if pre-resolution merge is what you want (rare):
# Use a CTF.mergePositions transaction directly via cast or pm-sdk.

6. Smoke validation procedure ​

Recommended before flipping PAIRED_ENABLED=true on a live wallet. Mirrors ADR-011 § "Implementation plan" X5.4.

6.1 Single-side parity smoke (paired_enabled=false) ​

Goal: prove the X5.2 / X5.3 code did not perturb the Phase 1 codepath.

sh
# 1. Run pm-bot in dry-run with paired explicitly disabled.
MAKER_REBATE_PAIRED_ENABLED=false \
  cargo run -p pm-bot -- --strategy maker-rebate --dry-run --env env/.env.maker_rebate

# 2. Let it run for one full discovery cycle (60–120 s).

# 3. Inspect the log: every admitted market should produce exactly one
#    `Decision::PlaceOrder` (cheaper-side leg only).
grep "MK-REBATE" ./logs/pm-bot.log | head -20

# 4. Confirm `pairs` DashMap stays empty.
grep "pairs_len" ./logs/pm-bot.log
# Expected: pairs_len=0 throughout the run.

Pass criterion: behaviour identical to a baseline pre-X5.2 run on the same market set.

6.2 Paired-mode smoke (paired_enabled=true) ​

Goal: exercise the X5.3 state machine end-to-end against the live RTDS feed without committing capital.

sh
# 1. Enable paired mode + dry-run.
MAKER_REBATE_PAIRED_ENABLED=true \
MAKER_REBATE_PAIRED_BUDGET_PER_PAIR=50.00 \
MAKER_REBATE_PAIRED_IMBALANCE_TIMEOUT_SECS=60 \
  cargo run -p pm-bot -- --strategy maker-rebate --dry-run --env env/.env.maker_rebate

# 2. Watch the log for paired admission lines.
grep -E "MK-REBATE|paired" ./logs/pm-bot.log

# Expected first 60 s:
#   MK-REBATE: admitted paired pair cid=… yes_price=… no_price=… size=…
#   Two `Decision::PlaceOrder` per admitted market (one YES, one NO).
#   `pairs[cid].state = Pending { posted_at: … }`.

# 3. If the simulator fills one leg first, watch for state transition.
#    Expected:
#   MK-REBATE: paired Pending → OneFilled(side=Yes) cid=…

# 4. If the other leg fills within `imbalance_timeout_secs`:
#   MK-REBATE: paired OneFilled → BothFilled cid=…
#   MK-REBATE: paired BothFilled ⇒ emitting CallMergePositions cid=… amount=…
#   (In dry-run the merge is a no-op; the state advances to Merged.)

# 5. If the second leg does NOT fill within the timeout:
#   WARN paired_imbalance_escalation cid=… filled_side=Yes elapsed_secs=63 timeout_secs=60
#   `Decision::CancelOrder` emitted for the unfilled leg.
#   `pairs[cid].state = ManualReconcile { side_filled: Yes, first_fill_at: … }`.

# 6. Tail the strategy decision JSONL hook (if BUCKET_ARB_DECISION_LOG-equivalent
#    knob exists for maker-rebate; today maker-rebate uses tracing only,
#    so step (2) covers the full visibility).

Pass criteria:

  • Pending is reached for every admitted market.
  • At least one transition exercises the state machine (any of OneFilled / BothFilled / Cancelled / ManualReconcile).
  • No panic! / unwrap() / expect() failures in the strategy hooks.
  • cargo test -p pm-bot --lib baseline 1014 passed is still the count after the smoke run terminates.

6.3 Promotion to live ​

Only after § 6.1 + § 6.2 pass on the same market set:

sh
# Drop --dry-run; otherwise identical command.
MAKER_REBATE_PAIRED_ENABLED=true \
  cargo run -p pm-bot -- --strategy maker-rebate --env env/.env.maker_rebate

Run for one trading day with BOT_TOTAL_BUDGET halved relative to your single-side baseline. Watch for the first BothFilled → Merged transition — that confirms the on-chain merge path. Watch for the first paired_imbalance_escalation and walk the § 5 playbook end-to-end.


7. Rollback ​

The activation gate is the only rollback surface needed.

diff
# Disable paired emission (revert to Phase 1 single-side parity):
-MAKER_REBATE_PAIRED_ENABLED=true
+MAKER_REBATE_PAIRED_ENABLED=false

After restart:

  • The X5.3 state machine is no longer driven (admission gate returns RebatePairDecision::PairedDisabled early; pairs DashMap stays empty).
  • Existing ManualReconcile / Cancelled / Merged rows in the in-memory pairs map are dropped on restart (the map is not persisted — it's an Arc<DashMap> inside MakerRebateStrategy).
  • Trading hot path is bit-identical to the pre-X5.2 baseline.
  • pm-bot.sqlite rows from the paired session (event_log, decisions, fills, positions_history) are preserved for audit. Use pm-cli journal gc only if you want to prune them.

There is no DB migration to roll back — TD-7 added zero schema columns. The state machine is purely in-memory, lifecycle-bound to the MakerRebateStrategy instance.


8. Common issues + diagnostics ​

"Pair never advances past Pending" ​

The strategy is admitting both legs but neither fills.

  1. Check the live orderbook — the maker bids may be too far from best_bid to take. MakerRebateConfig uses best_bid + 1 tick, matching legacy single-side behaviour.
  2. Check MAKER_REBATE_MIN_PRICE_CENTS / MAX_PRICE_CENTS — admission may be allowing prices the market does not actually offer.
  3. Check for RTDS toxic-flow cancels: grep "PairCancelReason::ToxicFlow" ./logs/pm-bot.log — if both legs are being cancelled before fill, the toxic-flow gate is firing.
  4. Check Decision::CancelOrder { reason: "drift_cancel" } — drift gate may be cancelling before fill.

"BothFilled → Merged transition does not emit CallMergePositions" ​

Look for BothFilled with zero merge qty ⇒ skip emit in the log (source linemaker_rebate.rs:751). This means pair.yes.filled_size.min(pair.no.filled_size) rounded to zero USDC units. The pair is still advanced to Merged to avoid a stuck state. Investigate fill-size accounting if this happens unexpectedly — it should only fire on degenerate fills (~0 shares).

"paired_imbalance_escalation fires repeatedly on the same cid" ​

The state machine cannot regress out of ManualReconcile. If you see two warns for the same cid, check that pm-bot did not restart between them (a fresh process resets the in-memory map and may re-admit the pair if the orderbook still satisfies admission). Persisting pairs across restarts is not in scope for X5 — restart equivalence is the operator's responsibility.

"Capital exhausted faster than expected" ​

Two-sided emission consumes per-pair budget. Reduce MAKER_REBATE_PAIRED_BUDGET_PER_PAIR and / or BOT_TOTAL_BUDGET. The total notional exposure on N admitted pairs is up to N * paired_budget_per_pair.

"Neg-risk markets in the slug filter never get admitted" ​

By design — paired emission skips neg-risk markets at admission per ADR-011 § 5 (standard CTF merge does not work on neg-risk; their merge path is NegRiskAdapter.mergePositions, see project_neg_risk_merge_path memory). Filter your slugs to standard binary markets only.

"paired_enabled=true but no admissions on a non-empty market set" ​

The pure evaluator returns one of MarketTooLiquid / BudgetExceeded / BookDataMissing / PriceOutOfBand. Add RUST_LOG=debug and grep for RebatePairDecision:: to see which variant triggered. Common causes:

  • MarketTooLiquid — (1.00 - best_bid_yes - best_bid_no) < min_edge. The market's two sides sum to >= 1.00 (or close enough), there is no spread to capture.
  • BudgetExceeded — yes_price * yes_size + no_size * no_size > paired_budget_per_pair. Lower the per-side floor or raise the cap.
  • PriceOutOfBand — best_bid falls outside [min_price, max_price] on at least one side. Confirm MAKER_REBATE_MIN_PRICE_CENTS / MAX_PRICE_CENTS envelope the live orderbook.

9. Cross-references ​