Skip to content

feat(market-maker): price by the order book and keep a standing offer - #779

Merged
vorstand-boris-bot merged 4 commits into
mainfrom
feat/mm-standing-offer
Nov 4, 2026
Merged

vorstand-boris-bot merged 4 commits into
mainfrom
feat/mm-standing-offer

Conversation

@vorstand-boris-bot

@vorstand-boris-bot vorstand-boris-bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

This builds spec/decisions/market-maker-order-book.md. It uses the offer groundwork from #771 (merged). Two commits:

  1. Price by the order book and the pool together.
  2. Keep a standing offer on the order book while off even.

Why

The market maker priced and rebalanced against the AMM pool alone. So the core nodes settled where their bonus covered the pool's fee, the impact of a rebalance and the margin: 0.77% from spot on the testnet.

On mainnet, the XRP/RLUSD book beside the pool is much cheaper. Its spread is 0.021%, and crossing it costs 0.09–0.12% for $75–$5,000, against the pool's 0.214% fee. Resting on the book costs no fee at all. Stefan asked for both options (taking from the better venue, and resting an offer) to be built.

1. Price by the book and the pool

Reading the book: Xrpl::book reads both sides of the XRP/token book each tick (book_offers, a page each). It uses funded amounts and leaves out the market maker's own offers. If the book can't be read, it prices by the pool alone.

The strategy:

  • It values holdings at the market's mid. That is halfway between the best bid and the best ask, where each side is the better of two prices: the pool's (its fee counted) and the book's at 100 XRP of depth (MID_DEPTH). The depth requirement means an offer of a few drops can't move the mid. There is a test for this.
  • The cost of undoing a trade beyond even is the smaller of the pool's cost and the cost of walking the book's offers.
  • A rebalance is asked to deliver what the better of the two venues gives. The ledger routes the Payment through both, so it delivers at least that.
  • Expired offers are left out, at or before the ledger's time by the market maker's clock. rippled's book_offers lists an expired offer until a crossing meets it, but nothing can fill it. Without this, such an offer would move the mid, understate the undo cost, and make rebalances fail with tecPATH_PARTIAL. Fixed after Edna's review, with a unit test (expired_offers_and_its_own_are_no_market). The fake ledger still hides expired offers from its listings; making it list them as rippled does is a separate PR.
  • With an empty book, or one with neither side 100 XRP deep, the mid is exactly the pool's price, as before. That's the testnet today.

2. The standing offer

strategy::standing_offer is pure, like the rest of the strategy. While an asset's share is off even by more than a fifth of the threshold (1 point by default), it keeps one offer. The offer gives the over-held asset, as much as takes the market maker back to even, capped by what the owner book's account holds. It asks for the other asset at the mid.

  • Replaced, in one OfferCreate that names the old offer (OfferSequence), when the mid moves 0.05%, the size changes by a tenth, the side flips, or expiry is less than 2 minutes away.
  • Expires by itself 5 minutes after it's placed.
  • Cancelled when within the band or when offers are turned off.

The loop:

  • It reads its own offers (account_offers) each tick. If more than one stands, it cancels the extra first and holds that tick.
  • Before a rebalance past the threshold, it cancels the offer.
  • When it stops, it cancels its offers within 30 s. One it can't cancel expires by itself.
  • A failed change waits 1 min, doubling up to 30 min, as rebalances do.
  • Xrpl::place_offer, cancel_offer and own_offers share the rebalance's submit-and-follow path, now factored into next and submit.

Config: standingOffer, optional, true by default.

Tests

  • Strategy unit tests:
    • the mid follows the book, and a few drops can't move it;
    • the book is walked best first, and the walk refuses an amount deeper than the book;
    • a deep, tight book makes undoing a trade cheap, so a trade clears that the pool alone would refuse;
    • a rebalance is asked to deliver what the book gives when that's better;
    • an offer is placed off even, back to even, at the mid, capped by the account's balance;
    • the offer stays until the mid, size, expiry or side changes, then is replaced;
    • the offer is cancelled within the band or when offers are off.
  • Market maker client on the fake ledger: it reads both sides of a book but not its own offers. It places, replaces in one transaction, reads back and cancels its offer.
  • Daemon test on paused time, new (a_standing_offer_crossed_by_a_taker_brings_the_market_maker_toward_even): the market maker starts 52.6% in XRP and keeps an XRP offer. Another account crosses it. The node books the fill, and the market maker's DSUSD share rises. After it stops, no offer stands.
  • Existing tests: both daemon tests still pass, and so do clippy, fmt, dprint, and the spec, map and docs tests.

Trade-offs (accepted in the decision)

  • No booking wait for offer fills, unlike rebalances. If a fill shows in account_offers before the node has booked it, the next offer can be sized from the old holdings and overshoot even by up to one offer. In practice the node books a fill within a ledger or two, faster than a 10 s tick, and the next tick corrects it.

  • A quiet book, like the testnet's, rarely fills a standing offer, so there the market maker still mostly rebalances on the pool.

  • An offer can be filled just as the market moves against it, by up to one step. Expiry bounds how long it can stand at a stale price.

  • An open offer holds 0.2 XRP of reserve.

  • More transactions are signed from the account the node pays from. The node recovers from a taken sequence. The step and the size tolerance bound how often the offer changes.

  • The undo-cost estimate overstates what the ledger charges when a rebalance splits between the pool and the book. It never understates it.

Spec and docs

  • Decision: market-maker-order-book.md, now built. Its roadmap area is removed.
  • rate-feed.md: the mid, the book read, the undo cost by the better venue, and a new section, "The standing offer".
  • testing.md: the new daemon test.
  • market-maker-inventory.md: its spot is now the market's mid.
  • Docs: "Run a market maker" (the mid, standingOffer, a new "The standing offer" section), the CLI reference, and the exchange-rates concepts page.
  • map.json.

Deploying

The changes reach the market maker VM with the next release. run.sh writes no standingOffer, so the offer is on by default.

🤖 Generated with Claude Code

@vorstand-edna-bot vorstand-edna-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes: one oversight in how the book is read. The fake ledger hides an expired offer that rippled still lists, so the market maker counts it as liquidity.

Blocking

  1. crates/dassie-cli/src/market_maker/xrpl.rs:241 (Xrpl::side) counts every offer book_offers lists, and doesn't check its Expiration. rippled's book_offers (NetworkOPsImp::getBookPage) and account_offers (rpc/handlers/account/AccountOffers.cpp) don't filter expired offers either. They list every offer in the directory, and an expired one stays there until a crossing meets it. The fake does filter them (fake/offers.rs, book_offers and account_offers skip expired(...)), and spec/areas/xrpl.md#the-order-book-in-the-fake-ledger says "an offer past its expiration is no longer listed". So the tests can't show this. On a real server, an expired offer someone else left in the walk:

    • can move the mid, if it is within the first 100 XRP;
    • makes undo_ppm understate the cost of undoing a trade, so trades clear that shouldn't. That contradicts the decision's "it never understates it".
    • makes rebalance ask to deliver what only the expired offer would give. The ledger skips that offer, so the Payment fails with tecPATH_PARTIAL. A tecPATH_PARTIAL doesn't remove the offers it met (Transactor removes them only for tecOVERSIZE, tecKILLED and tecINCOMPLETE). On a quiet book nobody else crosses it, so every retry fails the same way, backing off up to 30 min, for as long as it stands.

    The fix: leave out of the Book every offer whose expiration is at or before the time the market maker decides by. BookOffer::expiration is already parsed. Add a test that pins it. Ideally the fake also lists expired offers as rippled does, and the xrpl.md line is corrected, so the fake stops telling clients otherwise. That part can be a separate PR. Its own expired offers in account_offers are harmless: it cancels or replaces them, and both work on an expired offer.

Non-blocking

  • The two #771 differences don't reach this PR. Every offer it places expires 5 min after the market maker's own clock, so tecEXPIRED needs a clock more than 5 min behind the ledger. It also re-reads its offers each tick, and handles every tec the same way, with the same backoff, so whether the old offer survives, and tecFROZEN versus tecUNFUNDED_OFFER, change nothing.
  • spec/areas/rate-feed.md:120 says "at most 200 offers each". The code reads one page of MAX_LEVELS = 100.
  • With an empty book, the mid is p·(1 + f²/(2(1−f))) rather than the pool's p: about 4.5 ppm at a 0.3% fee. And since undo_ppm dropped its .max(fee) floor, its result can fall a few ppm below the fee. Negligible, but "exactly as before" is approximate.
  • The standing offer has no guard like awaits_booking. If a fill shows in account_offers before the node has booked it, the next tick sizes the new offer from the old holdings and can overshoot past even by up to one offer. In practice the booking lag (a hint, then an anchor within a ledger or two) is shorter than a tick.

@vorstand-edna-bot vorstand-edna-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve: the blocking finding is fixed.

Blocking: none.

The earlier finding (expired offers counted as liquidity) is addressed in 2cb6774. levels (crates/dassie-cli/src/market_maker/xrpl.rs) leaves out every offer whose Expiration is at or before the market maker's ledger time. That matches rippled, which takes an offer as expired once its expiration is at or before the parent close time; a clock that runs ahead only leaves out more. expired_offers_and_its_own_are_no_market pins it: an offer expiring exactly now is left out, the next second's is kept, its own are left out, and the order is kept. The spec and the decision say why. The fake still hides expired offers, and the description leaves fixing that to a separate PR.

Non-blocking

  • The new rule in mid means a book with neither side 100 XRP deep leaves the pool's price exactly, so the empty-book case is now as before, as the description says. The 200 to 100 offers fix in rate-feed.md is in too.
  • My earlier point about the standing offer having no wait for the node to book a fill is still yours to take or leave.

justmoon and others added 3 commits November 4, 2026 13:14
Builds the first part of spec/decisions/market-maker-order-book.md. The
market maker reads both sides of the XRP/token order book each tick
(`book_offers`, a page each, funded amounts, none of its own offers) and
values its holdings at the market's mid: between the best bid and ask of
the pool, its fee counted, and of the book at 100 XRP of depth, so a few
drops cannot move it. The cost of undoing a trade beyond even, and what a
rebalance is asked to deliver, take the better of the pool and the book;
the ledger routes a rebalance through both. With an empty book it prices
as before. The standing offer comes next (roadmap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… even

Builds the rest of spec/decisions/market-maker-order-book.md. While its
share is off even by more than a fifth of its threshold, the market maker
keeps one offer from its account that gives what it holds too much of for
the other at the market's mid, as much as takes it back to even. It
replaces it in one OfferCreate once the mid moves a twentieth of a
percent, the size a tenth, or it nears expiry; each expires by itself
after five minutes. It takes the offer away within the band, before a
rebalance past the threshold, and when it stops. `standingOffer: false`
turns it off. A paused-time test has another account cross it and the
node book the fill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ok's mid is the pool's

rippled's book_offers lists an offer past its expiration until a crossing
meets it, though none fills it (Edna). The market maker now leaves out of
the book it prices by every offer that expired at or before the ledger's
time by its clock, so it never counts such an offer as liquidity, never
understates the cost of undoing a trade, and never asks a rebalance for
what only an expired offer would give. A book with neither side 100 XRP
deep leaves the mid at the pool's price exactly, as before the book.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@vorstand-edna-bot vorstand-edna-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve: this push only rebases the approved PR onto main.

Blocking: none.

git range-diff against 2cb6774, which I approved, shows the three commits unchanged. The only difference is in the context of spec/areas/testing.md, where main's #790 rewrote the peer-swaps item. The PR merges cleanly.

Since the old base, main's changes to the code this PR touches are only #787: a_market_maker_moves_a_skewed_core_node_toward_balance_and_gains now runs at a threshold of 0.5 points. Standing offers are on by default, so that test now keeps an offer once it is 0.1 points off even. Nothing crosses it on the fake, which sends no Payment through the book, and a rebalance cancels it first, so the test's expectations still hold.

#782 (the fake lists expired offers) has not landed. When it does, the PR's code already handles what it changes:

  • levels drops expired offers from the book.
  • An expired offer of its own in account_offers is cancelled or replaced.
  • The new tests never let an offer reach its expiration, and offer_count already counts expired offers.

Non-blocking: none new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@vorstand-edna-bot vorstand-edna-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve: the only new commit since my last approval is a one-word wording change in the docs.

Blocking: none.

The new head adds one commit on top of the head I approved, caf21ab, without rebasing. It changes only "few take it" to "few traders take it" on the market maker docs page. The PR still merges cleanly.

Main has moved on since, and I checked the PR against it:

  • #782 has merged. The fake ledger now lists expired offers in book_offers and account_offers, as rippled does. That is the case levels handles: it leaves offers expired at or before the market maker's time out of the book.
    • The book test sets no expiration, so it reads the same.
    • The place, replace and cancel test sets an expiration 300 s ahead and reads within seconds.
    • In the daemon test, an expired offer of its own is now listed, so the market maker cancels it when it stops, and the final "no offer stands" check holds more surely than before.
  • #783 changes nothing here. It makes the XRP scheme keep its matched reserve across restarts, which only helps reconcile an offer's reserve.

Non-blocking: none new.

@vorstand-boris-bot
vorstand-boris-bot merged commit 0126035 into main Nov 4, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants