Тема
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:
docs/adr/adr-011-paired-maker-rebate-emission.md— ACCEPTED ADR with the full design, theRebatePairStatemachine, the pure evaluator surface, and the six alternatives considered.docs/history/sprints/td7-paired-maker-rebate.md— X5.1..X5.4 sprint summary (LOC delta, test counts, configuration knobs, future work).docs/operations/local-dashboard-runbook.md— Local-Bot Mode runbook; thepaired_imbalance_escalationtracing::warn!line is journaled into the sameevent_logsurface and visible on the/localSPA.docs/operations/tech-debt-backlog.md— TD-7 row (Resolved / obsolete after X5.4).crates/pm-bot/src/domain/strategies/maker_rebate.rs— implementation source (X5.2 pure evaluator + X5.3 state machine).crates/pm-bot/src/config/strategy/maker_rebate.rs—MakerRebateConfig::paired_*fields.
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
| Condition | Single-side (default) | Paired (PAIRED_ENABLED=true) |
|---|---|---|
| Operator wants minimum capital exposure per market | yes | NO — paired doubles per-market exposure |
| Market liquidity supports both legs at acceptable bids | yes | yes |
Operator can monitor paired_imbalance_escalation events | n/a | REQUIRED — ManualReconcile is operator-resolved |
BOT_TOTAL_BUDGET is tuned for the doubled per-market exposure | n/a | REQUIRED — see § 3.2 |
| Strategy is running on a neg-risk market set | yes | NO — 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-rebateThat 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=60All 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=200across ~8 markets at $25 each, the paired-mode equivalent is eitherBOT_TOTAL_BUDGET=400(same market count, doubled exposure) orBOT_TOTAL_BUDGET=200withPAIRED_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:
crates/pm-bot/src/domain/strategies/maker_rebate.rs:715—evaluate_periodictimeout branch.
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 cancelledAlongside 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
| Surface | What 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
| Option | When to choose | Outcome |
|---|---|---|
| (a) Sell filled leg back | Liquidity 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 resolution | High 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 manually | Liquidity 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_ADDRThe 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_KEYOnce 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:
Pendingis 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 --libbaseline1014 passedis 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_rebateRun 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=falseAfter restart:
- The X5.3 state machine is no longer driven (admission gate returns
RebatePairDecision::PairedDisabledearly;pairsDashMap stays empty). - Existing
ManualReconcile/Cancelled/Mergedrows in the in-memorypairsmap are dropped on restart (the map is not persisted — it's anArc<DashMap>insideMakerRebateStrategy). - Trading hot path is bit-identical to the pre-X5.2 baseline.
pm-bot.sqliterows from the paired session (event_log, decisions, fills, positions_history) are preserved for audit. Usepm-cli journal gconly 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.
- Check the live orderbook — the maker bids may be too far from
best_bidto take.MakerRebateConfigusesbest_bid + 1 tick, matching legacy single-side behaviour. - Check
MAKER_REBATE_MIN_PRICE_CENTS/MAX_PRICE_CENTS— admission may be allowing prices the market does not actually offer. - 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. - 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_bidfalls outside[min_price, max_price]on at least one side. ConfirmMAKER_REBATE_MIN_PRICE_CENTS/MAX_PRICE_CENTSenvelope the live orderbook.
9. Cross-references
- ADR-011 — Paired YES + NO maker-rebate emission — full design, six alternatives considered, state-machine table, transitions enumerated.
- TD-7 sprint summary — X5.1..X5.4 implementation log + LOC delta + test counts.
- Local-bot dashboard runbook — how
paired_imbalance_escalationsurfaces in the journal / SPA. - Tech-debt backlog — TD-7 row (Resolved / obsolete after X5.4).
crates/pm-bot/src/domain/strategies/maker_rebate.rs— implementation source.env/.env.examplelines 287–302 — paired knobs reference block.