From be01b2bde2ef5a2f86422dd4304c5a64bf795597 Mon Sep 17 00:00:00 2001 From: Nexory Date: Sat, 6 Jun 2026 10:45:10 +0200 Subject: [PATCH 1/2] fix(clob book): show best bid/ask at the top of the orderbook (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The display iterated bids and asks in storage order — best prices ended up at the bottom of the table. Reverse the iteration order so the maker side stays at the top, matching how every other CLOB UI presents the book. Closes #75. --- src/output/clob/books.rs | 102 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/src/output/clob/books.rs b/src/output/clob/books.rs index aaecf33..a91fe7a 100644 --- a/src/output/clob/books.rs +++ b/src/output/clob/books.rs @@ -38,6 +38,7 @@ pub fn print_order_book( let rows: Vec = result .bids .iter() + .rev() .map(|o| Row { price: o.price.to_string(), size: o.size.to_string(), @@ -56,6 +57,7 @@ pub fn print_order_book( let rows: Vec = result .asks .iter() + .rev() .map(|o| Row { price: o.price.to_string(), size: o.size.to_string(), @@ -158,3 +160,103 @@ pub fn print_last_trades_prices( } Ok(()) } + +#[cfg(test)] +mod tests { + use chrono::Utc; + use polymarket_client_sdk_v2::clob::types::TickSize; + use polymarket_client_sdk_v2::clob::types::response::{OrderBookSummaryResponse, OrderSummary}; + use polymarket_client_sdk_v2::types::{B256, Decimal, U256}; + use rust_decimal_macros::dec; + + /// Build a minimal `OrderBookSummaryResponse` with caller-supplied bids and asks. + /// The bids are provided in WORST-first order (ascending price) and asks in + /// WORST-first order (descending price), which is what a naïve API response might + /// contain and what the bug causes to be printed. + fn make_book( + bids: Vec, + asks: Vec, + ) -> OrderBookSummaryResponse { + OrderBookSummaryResponse::builder() + .market(B256::ZERO) + .asset_id(U256::ZERO) + .timestamp(Utc::now()) + .min_order_size(dec!(1)) + .neg_risk(false) + .tick_size(TickSize::Hundredth) + .bids(bids) + .asks(asks) + .build() + } + + fn make_order(price: Decimal, size: Decimal) -> OrderSummary { + OrderSummary::builder().price(price).size(size).build() + } + + /// Returns the price strings in the order that `print_order_book` would render them, + /// mirroring the exact `.iter().rev().map(|o| o.price.to_string())` chain used in the + /// production function. + fn rendered_bid_prices(book: &OrderBookSummaryResponse) -> Vec { + book.bids.iter().rev().map(|o| o.price.to_string()).collect() + } + + fn rendered_ask_prices(book: &OrderBookSummaryResponse) -> Vec { + book.asks.iter().rev().map(|o| o.price.to_string()).collect() + } + + /// The first bid shown must be the BEST bid (highest price = 0.50). + /// The current code iterates in insertion order without sorting, so it + /// renders 0.30 first — causing this test to FAIL on unpatched code. + #[test] + fn bids_rendered_best_price_first() { + // Bids inserted worst-to-best (ascending price), as might come from the API. + let book = make_book( + vec![ + make_order(dec!(0.30), dec!(100)), + make_order(dec!(0.40), dec!(100)), + make_order(dec!(0.50), dec!(100)), + ], + vec![ + make_order(dec!(0.50), dec!(100)), + make_order(dec!(0.60), dec!(100)), + make_order(dec!(0.70), dec!(100)), + ], + ); + + let bid_prices = rendered_bid_prices(&book); + assert_eq!( + bid_prices[0], "0.50", + "first rendered bid must be the best (highest) bid price 0.50, got {} \ + — bids are printed in insertion order without sorting", + bid_prices[0] + ); + } + + /// The first ask shown must be the BEST ask (lowest price = 0.50). + /// Current code iterates in insertion order — FAILS when asks are stored + /// worst-first (descending price). + #[test] + fn asks_rendered_best_price_first() { + // Asks inserted worst-to-best (descending price), as might come from the API. + let book = make_book( + vec![ + make_order(dec!(0.30), dec!(100)), + make_order(dec!(0.40), dec!(100)), + make_order(dec!(0.50), dec!(100)), + ], + vec![ + make_order(dec!(0.70), dec!(100)), + make_order(dec!(0.60), dec!(100)), + make_order(dec!(0.50), dec!(100)), + ], + ); + + let ask_prices = rendered_ask_prices(&book); + assert_eq!( + ask_prices[0], "0.50", + "first rendered ask must be the best (lowest) ask price 0.50, got {} \ + — asks are printed in insertion order without sorting", + ask_prices[0] + ); + } +} From d45be37088ad0757f0d58f995280e2c8e1605159 Mon Sep 17 00:00:00 2001 From: Nexory Date: Sat, 6 Jun 2026 11:11:33 +0200 Subject: [PATCH 2/2] test: replace tautological tests with helper-call assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous tests defined their own `.iter().rev()` chain via `rendered_bid_prices` / `rendered_ask_prices` instead of exercising the production iterator, so removing `.rev()` from `print_order_book` would still leave the tests green — a tautology. This commit extracts the ordering into two thin functions, `bids_in_display_order` and `asks_in_display_order`, both used by `print_order_book`. Tests now call these helpers directly. Removing `.rev()` from either helper makes the corresponding test fail with a verbatim wrong-order assertion, which is the property we want pinned. Empirical verification: - HEAD (with .rev()): 3 passed; 0 failed - bids helper without .rev(): 2 passed; 1 failed (bids test) - asks helper without .rev(): 2 passed; 1 failed (asks test) - cargo fmt --check: exit 0 - cargo clippy --all-targets -- -D warnings: exit 0 An empty-input case is added so the helpers are also pinned against panics on empty bids/asks. --- src/output/clob/books.rs | 144 +++++++++++++++------------------------ 1 file changed, 55 insertions(+), 89 deletions(-) diff --git a/src/output/clob/books.rs b/src/output/clob/books.rs index a91fe7a..ca8c195 100644 --- a/src/output/clob/books.rs +++ b/src/output/clob/books.rs @@ -1,5 +1,5 @@ use polymarket_client_sdk_v2::clob::types::response::{ - LastTradePriceResponse, LastTradesPricesResponse, OrderBookSummaryResponse, + LastTradePriceResponse, LastTradesPricesResponse, OrderBookSummaryResponse, OrderSummary, }; use serde_json::json; use tabled::settings::Style; @@ -7,6 +7,22 @@ use tabled::{Table, Tabled}; use crate::output::{DASH, OutputFormat, truncate}; +/// Returns bids in display order (best bid first). +/// +/// The CLOB API returns bids ascending by price, so the best (highest-priced) +/// bid is reversed to the top for human-facing display. +fn bids_in_display_order(bids: &[OrderSummary]) -> Vec<&OrderSummary> { + bids.iter().rev().collect() +} + +/// Returns asks in display order (best ask first). +/// +/// The CLOB API returns asks descending by price, so the best (lowest-priced) +/// ask is reversed to the top for human-facing display. +fn asks_in_display_order(asks: &[OrderSummary]) -> Vec<&OrderSummary> { + asks.iter().rev().collect() +} + pub fn print_order_book( result: &OrderBookSummaryResponse, output: &OutputFormat, @@ -35,10 +51,8 @@ pub fn print_order_book( println!("No bids."); } else { println!("Bids:"); - let rows: Vec = result - .bids - .iter() - .rev() + let rows: Vec = bids_in_display_order(&result.bids) + .into_iter() .map(|o| Row { price: o.price.to_string(), size: o.size.to_string(), @@ -54,10 +68,8 @@ pub fn print_order_book( println!("No asks."); } else { println!("Asks:"); - let rows: Vec = result - .asks - .iter() - .rev() + let rows: Vec = asks_in_display_order(&result.asks) + .into_iter() .map(|o| Row { price: o.price.to_string(), size: o.size.to_string(), @@ -163,100 +175,54 @@ pub fn print_last_trades_prices( #[cfg(test)] mod tests { - use chrono::Utc; - use polymarket_client_sdk_v2::clob::types::TickSize; - use polymarket_client_sdk_v2::clob::types::response::{OrderBookSummaryResponse, OrderSummary}; - use polymarket_client_sdk_v2::types::{B256, Decimal, U256}; + use super::{OrderSummary, asks_in_display_order, bids_in_display_order}; use rust_decimal_macros::dec; - /// Build a minimal `OrderBookSummaryResponse` with caller-supplied bids and asks. - /// The bids are provided in WORST-first order (ascending price) and asks in - /// WORST-first order (descending price), which is what a naïve API response might - /// contain and what the bug causes to be printed. - fn make_book( - bids: Vec, - asks: Vec, - ) -> OrderBookSummaryResponse { - OrderBookSummaryResponse::builder() - .market(B256::ZERO) - .asset_id(U256::ZERO) - .timestamp(Utc::now()) - .min_order_size(dec!(1)) - .neg_risk(false) - .tick_size(TickSize::Hundredth) - .bids(bids) - .asks(asks) - .build() + fn order(price: rust_decimal::Decimal) -> OrderSummary { + OrderSummary::builder().price(price).size(dec!(100)).build() } - fn make_order(price: Decimal, size: Decimal) -> OrderSummary { - OrderSummary::builder().price(price).size(size).build() - } + // The CLOB API returns bids ascending (worst price first). + // The display order helper must reverse that so the best (highest) bid is first. + #[test] + fn bids_in_display_order_puts_best_bid_first() { + let api_order = vec![order(dec!(0.30)), order(dec!(0.40)), order(dec!(0.50))]; - /// Returns the price strings in the order that `print_order_book` would render them, - /// mirroring the exact `.iter().rev().map(|o| o.price.to_string())` chain used in the - /// production function. - fn rendered_bid_prices(book: &OrderBookSummaryResponse) -> Vec { - book.bids.iter().rev().map(|o| o.price.to_string()).collect() - } + let displayed: Vec = bids_in_display_order(&api_order) + .into_iter() + .map(|o| o.price) + .collect(); - fn rendered_ask_prices(book: &OrderBookSummaryResponse) -> Vec { - book.asks.iter().rev().map(|o| o.price.to_string()).collect() + assert_eq!( + displayed, + vec![dec!(0.50), dec!(0.40), dec!(0.30)], + "bids must be reversed for display so the highest-priced bid is on top" + ); } - /// The first bid shown must be the BEST bid (highest price = 0.50). - /// The current code iterates in insertion order without sorting, so it - /// renders 0.30 first — causing this test to FAIL on unpatched code. + // The CLOB API returns asks descending (worst price first). + // The display order helper must reverse that so the best (lowest) ask is first. #[test] - fn bids_rendered_best_price_first() { - // Bids inserted worst-to-best (ascending price), as might come from the API. - let book = make_book( - vec![ - make_order(dec!(0.30), dec!(100)), - make_order(dec!(0.40), dec!(100)), - make_order(dec!(0.50), dec!(100)), - ], - vec![ - make_order(dec!(0.50), dec!(100)), - make_order(dec!(0.60), dec!(100)), - make_order(dec!(0.70), dec!(100)), - ], - ); + fn asks_in_display_order_puts_best_ask_first() { + let api_order = vec![order(dec!(0.70)), order(dec!(0.60)), order(dec!(0.50))]; + + let displayed: Vec = asks_in_display_order(&api_order) + .into_iter() + .map(|o| o.price) + .collect(); - let bid_prices = rendered_bid_prices(&book); assert_eq!( - bid_prices[0], "0.50", - "first rendered bid must be the best (highest) bid price 0.50, got {} \ - — bids are printed in insertion order without sorting", - bid_prices[0] + displayed, + vec![dec!(0.50), dec!(0.60), dec!(0.70)], + "asks must be reversed for display so the lowest-priced ask is on top" ); } - /// The first ask shown must be the BEST ask (lowest price = 0.50). - /// Current code iterates in insertion order — FAILS when asks are stored - /// worst-first (descending price). + // Empty input must not panic and must produce an empty display order. #[test] - fn asks_rendered_best_price_first() { - // Asks inserted worst-to-best (descending price), as might come from the API. - let book = make_book( - vec![ - make_order(dec!(0.30), dec!(100)), - make_order(dec!(0.40), dec!(100)), - make_order(dec!(0.50), dec!(100)), - ], - vec![ - make_order(dec!(0.70), dec!(100)), - make_order(dec!(0.60), dec!(100)), - make_order(dec!(0.50), dec!(100)), - ], - ); - - let ask_prices = rendered_ask_prices(&book); - assert_eq!( - ask_prices[0], "0.50", - "first rendered ask must be the best (lowest) ask price 0.50, got {} \ - — asks are printed in insertion order without sorting", - ask_prices[0] - ); + fn empty_inputs_produce_empty_output() { + let empty: Vec = vec![]; + assert!(bids_in_display_order(&empty).is_empty()); + assert!(asks_in_display_order(&empty).is_empty()); } }