Repository navigation
feat(market-maker): price by the order book and keep a standing offer - #779
Conversation
vorstand-edna-bot
left a comment
There was a problem hiding this comment.
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
-
crates/dassie-cli/src/market_maker/xrpl.rs:241(Xrpl::side) counts every offerbook_offerslists, and doesn't check itsExpiration. rippled'sbook_offers(NetworkOPsImp::getBookPage) andaccount_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_offersandaccount_offersskipexpired(...)), andspec/areas/xrpl.md#the-order-book-in-the-fake-ledgersays "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_ppmunderstate the cost of undoing a trade, so trades clear that shouldn't. That contradicts the decision's "it never understates it". - makes
rebalanceask to deliver what only the expired offer would give. The ledger skips that offer, so the Payment fails withtecPATH_PARTIAL. AtecPATH_PARTIALdoesn't remove the offers it met (Transactorremoves them only fortecOVERSIZE,tecKILLEDandtecINCOMPLETE). 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
Bookevery offer whoseexpirationis at or before the time the market maker decides by.BookOffer::expirationis 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 inaccount_offersare 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
tecEXPIREDneeds a clock more than 5 min behind the ledger. It also re-reads its offers each tick, and handles everytecthe same way, with the same backoff, so whether the old offer survives, andtecFROZENversustecUNFUNDED_OFFER, change nothing. spec/areas/rate-feed.md:120says "at most 200 offers each". The code reads one page ofMAX_LEVELS= 100.- With an empty book, the mid is
p·(1 + f²/(2(1−f)))rather than the pool'sp: about 4.5 ppm at a 0.3% fee. And sinceundo_ppmdropped 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 inaccount_offersbefore 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
left a comment
There was a problem hiding this comment.
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
midmeans 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 inrate-feed.mdis 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.
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>
2cb6774 to
caf21ab
Compare
vorstand-edna-bot
left a comment
There was a problem hiding this comment.
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:
levelsdrops expired offers from the book.- An expired offer of its own in
account_offersis cancelled or replaced. - The new tests never let an offer reach its expiration, and
offer_countalready counts expired offers.
Non-blocking: none new.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vorstand-edna-bot
left a comment
There was a problem hiding this comment.
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_offersandaccount_offers, as rippled does. That is the caselevelshandles: 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.
This builds
spec/decisions/market-maker-order-book.md. It uses the offer groundwork from #771 (merged). Two commits: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::bookreads 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:
MID_DEPTH). The depth requirement means an offer of a few drops can't move the mid. There is a test for this.book_offerslists 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 withtecPATH_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.2. The standing offer
strategy::standing_offeris 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.OfferCreatethat 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.The loop:
account_offers) each tick. If more than one stands, it cancels the extra first and holds that tick.Xrpl::place_offer,cancel_offerandown_offersshare the rebalance's submit-and-follow path, now factored intonextandsubmit.Config:
standingOffer, optional, true by default.Tests
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.Trade-offs (accepted in the decision)
No booking wait for offer fills, unlike rebalances. If a fill shows in
account_offersbefore 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
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.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.shwrites nostandingOffer, so the offer is on by default.🤖 Generated with Claude Code