From 8c81a12a78f49a5e8ff1d175434fc3632a37271b Mon Sep 17 00:00:00 2001 From: Deipey Paanchal Date: Fri, 17 Apr 2026 13:37:01 -0400 Subject: [PATCH] fix: sort-agnostic orderbook walk for market price calculation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `calculate_buy_market_price` and `calculate_sell_market_price` iterated `reversed(positions)` under the assumption that the server returns asks in descending-price order and bids in ascending-price order. That's an undocumented contract — if the server ever changes ordering (or a caller passes a pre-sorted snapshot), the walk returns the *worst* price instead of the marginal price required to fill the requested amount. Sort locally by price instead: * buys walk asks ascending (cheapest first) * sells walk bids descending (highest first) Also consolidate the dict/dataclass position parsing into a single `_normalize_positions` helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- py_clob_client_v2/order_builder/builder.py | 46 ++++++++++++++-------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/py_clob_client_v2/order_builder/builder.py b/py_clob_client_v2/order_builder/builder.py index 39a31da..cd9b28d 100644 --- a/py_clob_client_v2/order_builder/builder.py +++ b/py_clob_client_v2/order_builder/builder.py @@ -40,6 +40,14 @@ "0.0001": RoundConfig(price=4, size=2, amount=6), } + +def _normalize_positions(positions: list): + """Yield (size, price) float pairs, accepting dicts or OrderSummary-like objects.""" + for p in positions: + size = p["size"] if isinstance(p, dict) else p.size + price = p["price"] if isinstance(p, dict) else p.price + yield float(size), float(price) + class OrderBuilder: def __init__( self, @@ -291,19 +299,22 @@ def calculate_buy_market_price( if not positions: raise Exception("no match") - total = 0 - for p in reversed(positions): - size = p["size"] if isinstance(p, dict) else p.size - price = p["price"] if isinstance(p, dict) else p.price - total += float(size) * float(price) + # Walk asks from cheapest to most expensive so the returned price is + # the marginal price required to fill `amount_to_match`. Sort locally + # to avoid depending on the server's (undocumented) ordering. + walk = sorted(_normalize_positions(positions), key=lambda x: x[1]) + + total = 0.0 + for size, price in walk: + total += size * price if total >= amount_to_match: - return float(price) + return price if order_type == OrderType.FOK: raise Exception("no match") - p0 = positions[0] - return float(p0["price"] if isinstance(p0, dict) else p0.price) + # Book can't fully fill — return the worst (highest) price walked. + return walk[-1][1] def calculate_sell_market_price( self, @@ -314,16 +325,19 @@ def calculate_sell_market_price( if not positions: raise Exception("no match") - total = 0 - for p in reversed(positions): - size = p["size"] if isinstance(p, dict) else p.size - price = p["price"] if isinstance(p, dict) else p.price - total += float(size) + # Walk bids from highest to lowest so the returned price is the + # marginal price the seller must accept to move `amount_to_match` + # shares. Sort locally to avoid depending on server ordering. + walk = sorted(_normalize_positions(positions), key=lambda x: x[1], reverse=True) + + total = 0.0 + for size, price in walk: + total += size if total >= amount_to_match: - return float(price) + return price if order_type == OrderType.FOK: raise Exception("no match") - p0 = positions[0] - return float(p0["price"] if isinstance(p0, dict) else p0.price) + # Book can't fully fill — return the worst (lowest) price walked. + return walk[-1][1]