From 99f42329aad83d4f488a877455b956eeccc4c61d Mon Sep 17 00:00:00 2001 From: Andrzej Pijanowski Date: Wed, 11 Mar 2026 11:42:13 +0100 Subject: [PATCH 1/6] feat: Introduce PriceLadder for efficient order book management and update OrderBook to utilize it --- CMakeLists.txt | 2 + src/order_book.cpp | 366 +++++++++---------------------------------- src/order_book.hpp | 20 ++- src/price_ladder.cpp | 179 +++++++++++++++++++++ src/price_ladder.hpp | 78 +++++++++ 5 files changed, 338 insertions(+), 307 deletions(-) create mode 100644 src/price_ladder.cpp create mode 100644 src/price_ladder.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c8e7576..c75e09e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,7 @@ if(NOT BUILD_TESTS_ONLY) add_executable(lob_app src/main.cpp src/order_book.cpp + src/price_ladder.cpp src/binance_adapter.cpp src/kraken_adapter.cpp ) @@ -55,6 +56,7 @@ if(BUILD_TESTS) tests/test_symbol_normalizer.cpp tests/test_kraken_utils.cpp src/order_book.cpp + src/price_ladder.cpp ) target_include_directories(lob_tests PRIVATE src) diff --git a/src/order_book.cpp b/src/order_book.cpp index a73a9c7..16d202e 100644 --- a/src/order_book.cpp +++ b/src/order_book.cpp @@ -12,52 +12,31 @@ namespace { constexpr double kPriceScale = 100'000'000.0; } -/** - * @brief Construct an OrderBook with a specified Out-of-Index (OFI) view depth. - * - * @param ofiDepthArg Maximum number of price levels to keep in each OFI side (bids and asks). - */ -OrderBook::OrderBook(std::size_t ofiDepthArg) : ofiDepth(ofiDepthArg) {} - -/** - * @brief Replace the entire live order book state from a snapshot JSON. - * - * Parses the provided snapshot into temporary state and, on successful parsing, - * atomically swaps it into the live book: updates lastUpdateId, replaces bid - * and ask maps, rebuilds OFI views for both sides, and marks the snapshot as - * applied. Parsing failures leave the existing live state unchanged. - * - * @param snapshot JSON object expected to contain: - * - "lastUpdateId": numeric update identifier, - * - "asks": array of [priceString, quantityString] pairs, - * - "bids": array of [priceString, quantityString] pairs. - * Price and quantity strings are parsed via parseDecimal; levels with - * quantity equal to zero are omitted. - */ +OrderBook::OrderBook(std::size_t ofiDepthArg, long long tickSize) + : ofiDepth(ofiDepthArg), + bidLadder(tickSize), + askLadder(tickSize) {} + void OrderBook::applySnapshot(const nlohmann::json& snapshot) { - // Parse into temporaries first so a throw leaves the live book unchanged. const long long newLastUpdateId = snapshot["lastUpdateId"].get(); - std::unordered_map newBids; - std::unordered_map newAsks; + std::vector> newAsks, newBids; for (const auto& ask : snapshot["asks"]) { long long price = parseDecimal(ask[0].get()); - long long qty = parseDecimal(ask[1].get()); - if (qty > 0) { - newAsks[price] = qty; - } + long long qty = parseDecimal(ask[1].get()); + if (qty > 0) newAsks.push_back({price, qty}); } for (const auto& bid : snapshot["bids"]) { long long price = parseDecimal(bid[0].get()); - long long qty = parseDecimal(bid[1].get()); - if (qty > 0) { - newBids[price] = qty; - } + long long qty = parseDecimal(bid[1].get()); + if (qty > 0) newBids.push_back({price, qty}); } - // All parsing succeeded — take the lock and swap atomically. + std::lock_guard lock(orderBookMutex); lastUpdateId = newLastUpdateId; - bidState = std::move(newBids); - askState = std::move(newAsks); + bidLadder.clear(); + askLadder.clear(); + for (const auto& [p, q] : newBids) bidLadder.set(p, q); + for (const auto& [p, q] : newAsks) askLadder.set(p, q); ofiBids.clear(); ofiAsks.clear(); rebuildOfiSide(true); @@ -66,33 +45,10 @@ void OrderBook::applySnapshot(const nlohmann::json& snapshot) { printf("snapshot applied\n"); } -/** - * @brief Apply a parsed incremental update to the order book and produce level/OFI deltas. - * - * Requires the caller to hold orderBookMutex. Validates sequence continuity using - * `firstId`/`lastId`, applies the provided ask and bid level changes to internal - * state, advances `lastUpdateId` on success, and accumulates any emitted - * LevelDelta entries describing direct level changes and OFI-view side effects. - * - * If `lastId` is less than or equal to the current `lastUpdateId`, the update - * is treated as already applied and no deltas are produced. If `firstId` is - * greater than `lastUpdateId + 1`, a gap is detected and the update is not applied. - * - * @param firstId First sequence id of the incoming update range. - * @param lastId Last sequence id of the incoming update range. - * @param asks Span of (price, quantity) pairs representing ask-level updates; values are stored as - * internal scaled integers. - * @param bids Span of (price, quantity) pairs representing bid-level updates; values are stored as - * internal scaled integers. - * @param kind Event kind that categorizes emitted deltas (e.g., Maintenance or Backfill). - * @return UpdateResult `success` is `true` when the update was applied (or already applied), - * `false` when a gap was detected. `deltas` contains emitted LevelDelta entries. - */ UpdateResult OrderBook::applyUpdateCore(long long firstId, long long lastId, std::span> asks, std::span> bids, EventKind kind) { - // Must be called with orderBookMutex held. if (lastId <= lastUpdateId) { return {true, {}}; } @@ -112,29 +68,9 @@ UpdateResult OrderBook::applyUpdateCore(long long firstId, long long lastId, return result; } -/** - * @brief Parse a JSON incremental update and apply it to the live order book. - * - * Parses the JSON fields for first and last update IDs and the ask/bid level - * arrays, then applies the parsed update to the book under the internal mutex. - * Parsing is performed before acquiring the mutex so parse failures do not - * partially mutate the live state. - * - * @param update JSON object containing incremental update fields: - * - "U": first update ID - * - "u": last update ID - * - "a": array of ask levels [price, quantity] - * - "b": array of bid levels [price, quantity] - * @param kind Event kind to attribute to resulting level deltas. - * @return UpdateResult Result describing whether the update was applied and any - * generated level deltas; indicates failure when the - * update is out-of-order or a gap is detected. - */ UpdateResult OrderBook::applyUpdate(const nlohmann::json& update, EventKind kind) { - // Parse all data before acquiring the lock so a JSON exception cannot leave - // the live book half-mutated. const long long firstId = update["U"].get(); - const long long lastId = update["u"].get(); + const long long lastId = update["u"].get(); std::vector> asks, bids; for (const auto& ask : update["a"]) { asks.push_back( @@ -148,23 +84,6 @@ UpdateResult OrderBook::applyUpdate(const nlohmann::json& update, EventKind kind return applyUpdateCore(firstId, lastId, asks, bids, kind); } -/** - * @brief Applies a parsed incremental update to the order book using an ID range and level changes. - * - * Applies the provided asks and bids (price/quantity pairs) for update IDs in the closed interval - * [firstId, lastId] and returns the resulting update outcome and any level deltas. - * - * @param firstId First update identifier in the incoming update sequence. - * @param lastId Last update identifier in the incoming update sequence. - * @param asks Span of ask levels as (price, quantity) pairs; both values are stored as integers - * using kPriceScale. - * @param bids Span of bid levels as (price, quantity) pairs; both values are stored as integers - * using kPriceScale. - * @param kind EventKind that classifies the origin of the update (affects how deltas are labeled). - * @return UpdateResult Result describing whether the update was applied and the list of produced - * LevelDelta entries. On detection of a gap (missing updates) the result indicates failure and - * contains no deltas. - */ UpdateResult OrderBook::applyUpdate(long long firstId, long long lastId, std::span> asks, std::span> bids, @@ -173,30 +92,9 @@ UpdateResult OrderBook::applyUpdate(long long firstId, long long lastId, return applyUpdateCore(firstId, lastId, asks, bids, kind); } -/** - * @brief Apply a batch of bid and ask level updates and produce the resulting level deltas. - * - * Parses the provided JSON level arrays and applies each price-level change to the live order - * book, updating OFI views and emitting LevelDelta records for direct changes and any - * secondary changes caused by OFI membership updates. - * - * @param bidsArr JSON array of bid levels; each element must be an array ["price", "quantity"] with - * string values. - * @param asksArr JSON array of ask levels; each element must be an array ["price", "quantity"] with - * string values. - * @param kind EventKind indicating the source/type of the update (e.g., Maintenance or Backfill). - * @return std::vector Vector of LevelDelta entries produced by applying the provided - * updates. - */ std::vector OrderBook::applyDelta(const nlohmann::json& bidsArr, const nlohmann::json& asksArr, EventKind kind) { - // Parse all level data before acquiring the lock so that a JSON/parse exception - // cannot leave the live book half-mutated. - struct ParsedLevel { - long long price; - long long qty; - bool isBid; - }; + struct ParsedLevel { long long price; long long qty; bool isBid; }; std::vector parsed; for (const auto& bid : bidsArr) { parsed.push_back({parseDecimal(bid[0].get()), @@ -215,52 +113,20 @@ std::vector OrderBook::applyDelta(const nlohmann::json& bidsArr, return deltas; } -/** - * @brief Apply a single price-level change to the internal state and update OFI views, emitting - * resulting deltas. - * - * Applies the new quantity for `price` on the bid side if `isBid` is true, otherwise on the ask - * side. Updates the OFI (top-of-book) view for that side and appends one or more LevelDelta entries - * to `out` describing the direct change to `price` and any secondary view-membership changes caused - * by the update. - * - * If the net quantity change for `price` is zero, the function performs no mutation and appends no - * deltas. - * - * @param price Integer-encoded price key (scaled). - * @param newQty New integer-encoded quantity for the price; zero means remove the level. - * @param isBid True to operate on bids, false to operate on asks. - * @param kind Event kind that triggered the change; propagated to emitted deltas (secondaries are - * tagged as Backfill if the triggering kind is Backfill, otherwise as Maintenance). - * @param[out] out Vector to which generated LevelDelta entries (direct and secondary) are appended. - */ void OrderBook::applyLevelChange(long long price, long long newQty, bool isBid, EventKind kind, std::vector& out) { - auto& state = isBid ? bidState : askState; - long long prevQty = 0; - auto it = state.find(price); - if (it != state.end()) { - prevQty = it->second; - } + auto& ladder = isBid ? bidLadder : askLadder; + const long long prevQty = ladder.get(price); - if (newQty == 0) { - state.erase(price); - } else { - state[price] = newQty; - } + ladder.set(price, newQty); const long long delta = newQty - prevQty; - if (delta == 0) { - return; - } + if (delta == 0) { return; } - // Check view membership directly (no copy) before mutating the view. const std::vector& viewRef = isBid ? ofiBids : ofiAsks; const bool wasInView = std::any_of(viewRef.begin(), viewRef.end(), [price](const Level& l) { return l.first == price; }); - // updateOfiView reports any structural change it makes (eviction / replacement) - // so we never need a before-snapshot or post-diff. const ViewChangeResult viewChange = updateOfiView(price, newQty, isBid); const bool nowInView = std::any_of(viewRef.begin(), viewRef.end(), @@ -268,11 +134,6 @@ void OrderBook::applyLevelChange(long long price, long long newQty, bool isBid, out.push_back(LevelDelta{price, newQty, delta, isBid, wasInView, nowInView, kind}); - // Secondary deltas for OFI-view structural changes caused by this update. - // deltaQty is the view-contribution change (not a state change): - // eviction: -evictedQty (view qty dropped from evictedQty to 0) - // replacement: +replacementQty (view qty grew from 0 to replacementQty) - // Propagate the phase kind: Backfill → Backfill; Genuine → Maintenance. const EventKind secondaryKind = (kind == EventKind::Backfill) ? EventKind::Backfill : EventKind::Maintenance; @@ -292,7 +153,6 @@ OrderBook::ViewChangeResult OrderBook::updateOfiView(long long price, long long bool isBid) { ViewChangeResult result; if (isBid) { - // ofiBids sorted descending: front = best bid, back = worst bid in view. auto it = std::lower_bound(ofiBids.begin(), ofiBids.end(), price, [](const Level& a, long long p) { return a.first > p; }); const bool inView = (it != ofiBids.end() && it->first == price); @@ -301,44 +161,35 @@ OrderBook::ViewChangeResult OrderBook::updateOfiView(long long price, long long if (newQty == 0) { const bool wasFull = (ofiBids.size() == ofiDepth); ofiBids.erase(it); - // Find the single replacement: max bid price strictly below the new worst-in-view. - // O(N) scan, no allocation. The replacement always belongs at push_back() - // because it's worse than every price currently in the view. - // When ofiBids is empty any bid qualifies (no upper bound). - const bool hasWorst = !ofiBids.empty(); - const long long worstInView = hasWorst ? ofiBids.back().first : 0; - long long repPrice = 0, repQty = 0; - for (const auto& [p, q] : bidState) { - if ((!hasWorst || p < worstInView) && p > repPrice) { - repPrice = p; - repQty = q; - } - } + // O(1) amortised: walk inward from the OFI boundary tick-by-tick. + const bool hasWorst = !ofiBids.empty(); + const long long worst = hasWorst ? ofiBids.back().first : 0; + const long long repPrice = hasWorst ? bidLadder.prevBelow(worst) + : bidLadder.bestHigh(); + const long long repQty = repPrice > 0 ? bidLadder.get(repPrice) : 0; if (repPrice != 0) { ofiBids.push_back({repPrice, repQty}); } if (wasFull && ofiBids.size() == ofiDepth) { result.replacementPrice = ofiBids.back().first; - result.replacementQty = ofiBids.back().second; + result.replacementQty = ofiBids.back().second; } } else { it->second = newQty; } } else if (newQty > 0) { const bool viewNotFull = ofiBids.size() < ofiDepth; - const bool beatWorst = !ofiBids.empty() && price > ofiBids.back().first; + const bool beatWorst = !ofiBids.empty() && price > ofiBids.back().first; if (viewNotFull || beatWorst) { ofiBids.insert(it, {price, newQty}); if (ofiBids.size() > ofiDepth) { - // Capture the evicted worst level before removing it. result.evictedPrice = ofiBids.back().first; - result.evictedQty = ofiBids.back().second; + result.evictedQty = ofiBids.back().second; ofiBids.pop_back(); } } } } else { - // ofiAsks sorted ascending: front = best ask, back = worst ask in view. auto it = std::lower_bound(ofiAsks.begin(), ofiAsks.end(), price, [](const Level& a, long long p) { return a.first < p; }); const bool inView = (it != ofiAsks.end() && it->first == price); @@ -347,35 +198,30 @@ OrderBook::ViewChangeResult OrderBook::updateOfiView(long long price, long long if (newQty == 0) { const bool wasFull = (ofiAsks.size() == ofiDepth); ofiAsks.erase(it); - // Find the single replacement: min ask price strictly above the new worst-in-view. - // O(N) scan, no allocation. The replacement always belongs at push_back() - // because it's worse than every price currently in the view. - const long long threshold = ofiAsks.empty() ? 0 : ofiAsks.back().first; - long long repPrice = 0, repQty = 0; - for (const auto& [p, q] : askState) { - if (p > threshold && (repPrice == 0 || p < repPrice)) { - repPrice = p; - repQty = q; - } - } + // O(1) amortised: walk outward from the OFI boundary tick-by-tick. + const bool hasWorst = !ofiAsks.empty(); + const long long worst = hasWorst ? ofiAsks.back().first : 0; + const long long repPrice = hasWorst ? askLadder.nextAbove(worst) + : askLadder.bestLow(); + const long long repQty = repPrice > 0 ? askLadder.get(repPrice) : 0; if (repPrice != 0) { ofiAsks.push_back({repPrice, repQty}); } if (wasFull && ofiAsks.size() == ofiDepth) { result.replacementPrice = ofiAsks.back().first; - result.replacementQty = ofiAsks.back().second; + result.replacementQty = ofiAsks.back().second; } } else { it->second = newQty; } } else if (newQty > 0) { const bool viewNotFull = ofiAsks.size() < ofiDepth; - const bool beatWorst = !ofiAsks.empty() && price < ofiAsks.back().first; + const bool beatWorst = !ofiAsks.empty() && price < ofiAsks.back().first; if (viewNotFull || beatWorst) { ofiAsks.insert(it, {price, newQty}); if (ofiAsks.size() > ofiDepth) { result.evictedPrice = ofiAsks.back().first; - result.evictedQty = ofiAsks.back().second; + result.evictedQty = ofiAsks.back().second; ofiAsks.pop_back(); } } @@ -384,87 +230,48 @@ OrderBook::ViewChangeResult OrderBook::updateOfiView(long long price, long long return result; } -/** - * @brief Rebuilds the OFI (top-of-book) view for the specified side from the full order state. - * - * Recomputes the side's OFI list to contain up to `ofiDepth` price levels selected from the full - * side state: the highest prices for bids and the lowest prices for asks. - * - * @param isBid If `true`, rebuild the bid OFI view (best bids first); if `false`, rebuild the ask - * OFI view (best asks first). - */ void OrderBook::rebuildOfiSide(bool isBid) { if (isBid) { - std::vector all(bidState.begin(), bidState.end()); - const std::size_t n = std::min(ofiDepth, all.size()); - std::partial_sort(all.begin(), all.begin() + static_cast(n), all.end(), - [](const Level& a, const Level& b) { return a.first > b.first; }); - ofiBids.assign(all.begin(), all.begin() + static_cast(n)); + ofiBids.clear(); + long long p = bidLadder.bestHigh(); + while (p > 0 && ofiBids.size() < ofiDepth) { + ofiBids.push_back({p, bidLadder.get(p)}); + p = bidLadder.prevBelow(p); + } + // ofiBids is built best-first (descending) — already correct order. } else { - std::vector all(askState.begin(), askState.end()); - const std::size_t n = std::min(ofiDepth, all.size()); - std::partial_sort(all.begin(), all.begin() + static_cast(n), all.end(), - [](const Level& a, const Level& b) { return a.first < b.first; }); - ofiAsks.assign(all.begin(), all.begin() + static_cast(n)); + ofiAsks.clear(); + long long p = askLadder.bestLow(); + while (p > 0 && ofiAsks.size() < ofiDepth) { + ofiAsks.push_back({p, askLadder.get(p)}); + p = askLadder.nextAbove(p); + } + // ofiAsks is built best-first (ascending) — already correct order. } } -/** - * @brief Reset the order book to an empty, not-applied state. - * - * Clears all bid/ask state and OFI views, sets last update id to 0, - * and marks that no snapshot has been applied. - * - * This operation is performed under the internal mutex to synchronize - * concurrent access to the live order book state. - */ void OrderBook::clear() { std::lock_guard lock(orderBookMutex); lastUpdateId = 0; snapshotApplied.store(false); - bidState.clear(); - askState.clear(); + bidLadder.clear(); + askLadder.clear(); ofiBids.clear(); ofiAsks.clear(); } -/** - * @brief Retrieve the last applied update identifier. - * - * @return long long The current last update identifier stored in the order book. - */ long long OrderBook::getLastUpdateId() const { std::lock_guard lock(orderBookMutex); return lastUpdateId; } -/** - * @brief Indicates whether a snapshot has been applied to the order book. - * - * @return `true` if a snapshot has been applied, `false` otherwise. - */ bool OrderBook::isSnapshotApplied() const { return snapshotApplied.load(); } -/** - * @brief Produce a consistent summary of order-book counts and top-of-book prices. - * - * Returns a snapshot of aggregated statistics taken under the internal mutex so the - * values reflect a consistent view at the time of the call. The counts are the total - * number of price levels in the full bid and ask state; bestAsk and bestBid are taken - * from the OFI (top-of-book) views and converted to floating-point by dividing by - * kPriceScale. - * - * @return Stats Struct containing: - * - asksCount: number of ask levels, - * - bidsCount: number of bid levels, - * - bestAsk: best ask price (0.0 if OFI ask view is empty), - * - bestBid: best bid price (0.0 if OFI bid view is empty). - */ OrderBook::Stats OrderBook::getStats() const { std::lock_guard lock(orderBookMutex); Stats s; - s.asksCount = askState.size(); - s.bidsCount = bidState.size(); + s.asksCount = askLadder.activeCount(); + s.bidsCount = bidLadder.activeCount(); if (!ofiAsks.empty()) { s.bestAsk = static_cast(ofiAsks.front().first) / kPriceScale; } @@ -474,62 +281,36 @@ OrderBook::Stats OrderBook::getStats() const { return s; } -/** - * @brief Retrieve a point-in-time snapshot of the current order book state. - * - * The returned Snapshot contains whether a full snapshot has been applied and, when - * applied, the complete sets of ask and bid levels. Ask levels are ordered - * ascending by price (best ask first), and bid levels are ordered descending by - * price (best bid first). - * - * @return Snapshot - * - `applied`: `true` if a full snapshot has been applied, `false` otherwise. - * - `asks`: vector of ask price/quantity pairs ordered ascending by price. - * - `bids`: vector of bid price/quantity pairs ordered descending by price. - */ OrderBook::Snapshot OrderBook::getSnapshot() const { std::lock_guard lock(orderBookMutex); Snapshot s; s.applied = snapshotApplied.load(); - if (!s.applied) { - return s; - } + if (!s.applied) { return s; } - s.asks.assign(askState.begin(), askState.end()); - std::sort(s.asks.begin(), s.asks.end(), - [](const Level& a, const Level& b) { return a.first < b.first; }); + askLadder.forEach([&s](long long p, long long q) { s.asks.push_back({p, q}); }); + // forEach visits in ascending order — asks are already ascending. - s.bids.assign(bidState.begin(), bidState.end()); - std::sort(s.bids.begin(), s.bids.end(), - [](const Level& a, const Level& b) { return a.first > b.first; }); + bidLadder.forEach([&s](long long p, long long q) { s.bids.push_back({p, q}); }); + // forEach visits in ascending order — bids need descending. + std::reverse(s.bids.begin(), s.bids.end()); return s; } -/** - * @brief Print the current order book (last update id, asks, and bids) to standard output. - * - * Acquires the internal mutex and writes the last update id followed by all ask - * levels sorted ascending by price and all bid levels sorted descending by price. - * Prices and quantities are converted from the internal integer representation - * to floating-point values using kPriceScale. - */ void OrderBook::printOrderBook() const { std::lock_guard lock(orderBookMutex); std::cout << "Last Update ID: " << lastUpdateId << '\n'; - std::vector sortedAsks(askState.begin(), askState.end()); - std::sort(sortedAsks.begin(), sortedAsks.end(), - [](const Level& a, const Level& b) { return a.first < b.first; }); + std::vector sortedAsks, sortedBids; + askLadder.forEach([&sortedAsks](long long p, long long q) { sortedAsks.push_back({p, q}); }); + bidLadder.forEach([&sortedBids](long long p, long long q) { sortedBids.push_back({p, q}); }); + std::reverse(sortedBids.begin(), sortedBids.end()); // descending + std::cout << "Asks:\n"; for (const auto& [price, qty] : sortedAsks) { std::cout << "Price: " << static_cast(price) / kPriceScale << ", Quantity: " << static_cast(qty) / kPriceScale << '\n'; } - - std::vector sortedBids(bidState.begin(), bidState.end()); - std::sort(sortedBids.begin(), sortedBids.end(), - [](const Level& a, const Level& b) { return a.first > b.first; }); std::cout << "Bids:\n"; for (const auto& [price, qty] : sortedBids) { std::cout << "Price: " << static_cast(price) / kPriceScale @@ -537,17 +318,10 @@ void OrderBook::printOrderBook() const { } } -/** - * @brief Print a brief summary of order-book counts and best-of-book prices to stdout. - * - * Acquires the internal mutex and writes the total number of ask and bid price - * levels and the best ask/bid (top of OFI view) to standard output. Best prices - * are scaled by kPriceScale and formatted with two decimal places; if an OFI - * side is empty its best price is reported as 0.00. - */ void OrderBook::printOrderBookStats() const { std::lock_guard lock(orderBookMutex); - std::cout << "Total Asks: " << askState.size() << ", Total Bids: " << bidState.size() << '\n'; + std::cout << "Total Asks: " << askLadder.activeCount() + << ", Total Bids: " << bidLadder.activeCount() << '\n'; std::cout << std::fixed << std::setprecision(2) << "Best Ask: " << (ofiAsks.empty() ? 0.0 : static_cast(ofiAsks.front().first) / kPriceScale) << ", Best Bid: " diff --git a/src/order_book.hpp b/src/order_book.hpp index 396a437..3c1e7ac 100644 --- a/src/order_book.hpp +++ b/src/order_book.hpp @@ -3,11 +3,11 @@ #include #include #include -#include #include #include #include "ofi_types.hpp" +#include "price_ladder.hpp" class OrderBook { public: @@ -27,7 +27,9 @@ class OrderBook { }; // ofiDepth: number of top price levels tracked in the OFI view per side (default 10). - explicit OrderBook(std::size_t ofiDepth = 10); + // tickSize: minimum price increment in internal 1e8 units (default 1 000 000 = $0.01). + explicit OrderBook(std::size_t ofiDepth = 10, + long long tickSize = PriceLadder::kDefaultTick); // Clears all state and rebuilds from the Binance-format snapshot JSON. // Rebuilds the OFI view from the top-ofiDepth levels of the new state. @@ -69,9 +71,10 @@ class OrderBook { std::atomic snapshotApplied{false}; std::size_t ofiDepth; - // State layer: O(1) lookup and update, unordered. - std::unordered_map bidState; - std::unordered_map askState; + // Full order book state via price ladders — O(1) insert/update/delete, + // O(1) best-price, O(ticks_gap) next/prev level (≈ O(1) for dense books). + PriceLadder bidLadder; + PriceLadder askLadder; // OFI view: top-ofiDepth levels per side, kept sorted. // ofiBids: sorted descending by price (best bid first). @@ -80,8 +83,6 @@ class OrderBook { std::vector ofiAsks; // Returned by updateOfiView to describe secondary OFI-view structural changes. - // Avoids diffing view snapshots: updateOfiView knows exactly what it evicted/added. - // A zero price means no change occurred on that slot. struct ViewChangeResult { long long evictedPrice = 0; long long evictedQty = 0; @@ -102,12 +103,9 @@ class OrderBook { std::vector& out); // Update OFI view for one side after a level change. - // Returns a ViewChangeResult describing any eviction or replacement that occurred. - // Must be called with orderBookMutex held. ViewChangeResult updateOfiView(long long price, long long newQty, bool isBid); - // Full rebuild of one OFI view side from the state map (O(N log M)). - // Called when a level is removed from the view and a replacement must be found. + // Full rebuild of one OFI view side from the ladder (O(ofiDepth * avg_gap)). // Must be called with orderBookMutex held. void rebuildOfiSide(bool isBid); }; diff --git a/src/price_ladder.cpp b/src/price_ladder.cpp new file mode 100644 index 0000000..793e435 --- /dev/null +++ b/src/price_ladder.cpp @@ -0,0 +1,179 @@ +#include "price_ladder.hpp" + +#include +#include + +PriceLadder::PriceLadder(long long tickSizeArg, int halfRangeArg) + : tickSize(tickSizeArg), + halfRange(halfRangeArg), + size((halfRangeArg * 2) + 1), + bestLowIdx((halfRangeArg * 2) + 1) { + qtys = std::make_unique(size); + std::memset(qtys.get(), 0, static_cast(size) * sizeof(long long)); +} + +int PriceLadder::toIdx(long long price) const noexcept { + return static_cast((price - basePrice) / tickSize); +} + +long long PriceLadder::toPrice(int idx) const noexcept { + return basePrice + (static_cast(idx) * tickSize); +} + +bool PriceLadder::inRange(long long price) const noexcept { + if (!initialized) { + return false; + } + const long long offset = price - basePrice; + if (offset < 0) { + return false; + } + return static_cast(offset / tickSize) < size; +} + +void PriceLadder::initCenter(long long price) { + const long long rounded = (price / tickSize) * tickSize; + basePrice = rounded - (static_cast(halfRange) * tickSize); + initialized = true; +} + +void PriceLadder::rebuildBestIndices() { + bestHighIdx = -1; + bestLowIdx = size; + count = 0; + for (int i = 0; i < size; ++i) { + if (qtys[i] > 0) { + ++count; + bestHighIdx = std::max(bestHighIdx, i); + bestLowIdx = std::min(bestLowIdx, i); + } + } +} + +void PriceLadder::recenter(long long price) { + const long long rounded = (price / tickSize) * tickSize; + const long long newBase = rounded - (static_cast(halfRange) * tickSize); + const int shift = static_cast((newBase - basePrice) / tickSize); + + if (shift > 0 && shift < size) { + const int keep = size - shift; + std::memmove(qtys.get(), qtys.get() + shift, + static_cast(keep) * sizeof(long long)); + std::memset(qtys.get() + keep, 0, + static_cast(shift) * sizeof(long long)); + } else if (shift < 0 && -shift < size) { + const int keep = size + shift; // shift is negative + std::memmove(qtys.get() - shift, qtys.get(), + static_cast(keep) * sizeof(long long)); + std::memset(qtys.get(), 0, + static_cast(-shift) * sizeof(long long)); + } else { + // Shift >= array size: all existing entries fall outside the new window. + std::memset(qtys.get(), 0, static_cast(size) * sizeof(long long)); + } + basePrice = newBase; + rebuildBestIndices(); +} + +void PriceLadder::set(long long price, long long qty) { + if (!initialized) { + initCenter(price); + } else if (!inRange(price)) { + recenter(price); + } + + const int idx = toIdx(price); + const long long old = qtys[idx]; + qtys[idx] = qty; + + if (old == 0 && qty > 0) { + ++count; + } else if (old > 0 && qty == 0) { + --count; + } + + // Maintain bestHighIdx. + if (qty > 0 && idx > bestHighIdx) { + bestHighIdx = idx; + } else if (qty == 0 && idx == bestHighIdx) { + while (bestHighIdx >= 0 && qtys[bestHighIdx] == 0) { --bestHighIdx; } + } + + // Maintain bestLowIdx. + if (qty > 0 && idx < bestLowIdx) { + bestLowIdx = idx; + } else if (qty == 0 && idx == bestLowIdx) { + while (bestLowIdx < size && qtys[bestLowIdx] == 0) { ++bestLowIdx; } + } +} + +long long PriceLadder::get(long long price) const { + if (!inRange(price)) { + return 0; + } + return qtys[toIdx(price)]; +} + +void PriceLadder::clear() { + if (initialized) { + std::memset(qtys.get(), 0, static_cast(size) * sizeof(long long)); + } + count = 0; + bestHighIdx = -1; + bestLowIdx = size; + initialized = false; +} + +long long PriceLadder::bestHigh() const { + if (bestHighIdx < 0) { + return 0; + } + return toPrice(bestHighIdx); +} + +long long PriceLadder::bestLow() const { + if (bestLowIdx >= size) { + return 0; + } + return toPrice(bestLowIdx); +} + +long long PriceLadder::prevBelow(long long below) const { + if (!initialized || bestHighIdx < 0) { + return 0; + } + const long long offset = below - basePrice; + if (offset <= 0) { + return 0; + } + int startIdx = static_cast(offset / tickSize) - 1; + startIdx = std::min(startIdx, size - 1); + for (int i = startIdx; i >= 0; --i) { + if (qtys[i] > 0) { + return toPrice(i); + } + } + return 0; +} + +long long PriceLadder::nextAbove(long long above) const { + if (!initialized || bestLowIdx >= size) { + return 0; + } + const long long offset = above - basePrice; + int startIdx; + if (offset < 0) { + startIdx = 0; + } else { + startIdx = static_cast(offset / tickSize) + 1; + } + if (startIdx >= size) { + return 0; + } + for (int i = startIdx; i < size; ++i) { + if (qtys[i] > 0) { + return toPrice(i); + } + } + return 0; +} diff --git a/src/price_ladder.hpp b/src/price_ladder.hpp new file mode 100644 index 0000000..63ec598 --- /dev/null +++ b/src/price_ladder.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +// Flat-array order book side, indexed by price tick. +// +// All prices must be integer multiples of tickSize (in internal 1e8 units). +// Supports O(1) insert/update/delete, O(1) best-price lookup, and O(gap) +// "next active level above/below a boundary" — which is O(1) amortised for +// dense books (BTC/USDT at $0.01 tick). +// +// Memory: (2 * halfRange + 1) * 8 bytes per ladder. +// Default: ±300 000 ticks = ±$3 000 at $0.01 tick ≈ 4.8 MB per ladder. +class PriceLadder { +public: + static constexpr long long kDefaultTick = 1'000'000LL; // $0.01 in 1e8 units + static constexpr int kDefaultHalfRange = 300'000; // ±$3 000 at default tick + + explicit PriceLadder(long long tickSizeArg = kDefaultTick, + int halfRangeArg = kDefaultHalfRange); + + // Set (or remove if qty == 0) a price level. + // Triggers re-centering if price falls outside the current window. + void set(long long price, long long qty); + + // Returns qty for price; 0 if absent or out of current window. + long long get(long long price) const; + + // Remove all levels and reset the centre so the next set() re-initialises. + void clear(); + + std::size_t activeCount() const { return count; } + + // Highest price with qty > 0. Returns 0 if empty. + long long bestHigh() const; + + // Lowest price with qty > 0. Returns 0 if empty. + long long bestLow() const; + + // Highest price with qty > 0 strictly less than 'below'. Returns 0 if none. + long long prevBelow(long long below) const; + + // Lowest price with qty > 0 strictly greater than 'above'. Returns 0 if none. + long long nextAbove(long long above) const; + + // Visit every active level in ascending price order. fn(price, qty). + template + void forEach(Fn&& fn) const { + for (int i = 0; i < size; ++i) { + if (qtys[i] > 0) { + fn(toPrice(i), qtys[i]); + } + } + } + +private: + long long tickSize; + int halfRange; + int size; // halfRange * 2 + 1 + long long basePrice = 0; // price at index 0; always a multiple of tickSize + bool initialized = false; + + std::unique_ptr qtys; + std::size_t count = 0; + + int bestHighIdx = -1; // highest index with qty > 0; -1 when empty + int bestLowIdx; // lowest index with qty > 0; size when empty + + int toIdx(long long price) const noexcept; + long long toPrice(int idx) const noexcept; + bool inRange(long long price) const noexcept; + + void initCenter(long long price); + void recenter(long long price); + void rebuildBestIndices(); +}; From b052269da6dce2c14a4fb5adf167c91debe895b4 Mon Sep 17 00:00:00 2001 From: Andrzej Pijanowski Date: Wed, 11 Mar 2026 11:43:16 +0100 Subject: [PATCH 2/6] pre-commit --- src/order_book.cpp | 56 ++++++++++++++++++++++++-------------------- src/order_book.hpp | 3 +-- src/price_ladder.cpp | 24 ++++++++++--------- src/price_ladder.hpp | 18 +++++++------- 4 files changed, 54 insertions(+), 47 deletions(-) diff --git a/src/order_book.cpp b/src/order_book.cpp index 16d202e..68cf24e 100644 --- a/src/order_book.cpp +++ b/src/order_book.cpp @@ -13,21 +13,19 @@ constexpr double kPriceScale = 100'000'000.0; } OrderBook::OrderBook(std::size_t ofiDepthArg, long long tickSize) - : ofiDepth(ofiDepthArg), - bidLadder(tickSize), - askLadder(tickSize) {} + : ofiDepth(ofiDepthArg), bidLadder(tickSize), askLadder(tickSize) {} void OrderBook::applySnapshot(const nlohmann::json& snapshot) { const long long newLastUpdateId = snapshot["lastUpdateId"].get(); std::vector> newAsks, newBids; for (const auto& ask : snapshot["asks"]) { long long price = parseDecimal(ask[0].get()); - long long qty = parseDecimal(ask[1].get()); + long long qty = parseDecimal(ask[1].get()); if (qty > 0) newAsks.push_back({price, qty}); } for (const auto& bid : snapshot["bids"]) { long long price = parseDecimal(bid[0].get()); - long long qty = parseDecimal(bid[1].get()); + long long qty = parseDecimal(bid[1].get()); if (qty > 0) newBids.push_back({price, qty}); } @@ -70,7 +68,7 @@ UpdateResult OrderBook::applyUpdateCore(long long firstId, long long lastId, UpdateResult OrderBook::applyUpdate(const nlohmann::json& update, EventKind kind) { const long long firstId = update["U"].get(); - const long long lastId = update["u"].get(); + const long long lastId = update["u"].get(); std::vector> asks, bids; for (const auto& ask : update["a"]) { asks.push_back( @@ -94,7 +92,11 @@ UpdateResult OrderBook::applyUpdate(long long firstId, long long lastId, std::vector OrderBook::applyDelta(const nlohmann::json& bidsArr, const nlohmann::json& asksArr, EventKind kind) { - struct ParsedLevel { long long price; long long qty; bool isBid; }; + struct ParsedLevel { + long long price; + long long qty; + bool isBid; + }; std::vector parsed; for (const auto& bid : bidsArr) { parsed.push_back({parseDecimal(bid[0].get()), @@ -121,7 +123,9 @@ void OrderBook::applyLevelChange(long long price, long long newQty, bool isBid, ladder.set(price, newQty); const long long delta = newQty - prevQty; - if (delta == 0) { return; } + if (delta == 0) { + return; + } const std::vector& viewRef = isBid ? ofiBids : ofiAsks; const bool wasInView = std::any_of(viewRef.begin(), viewRef.end(), @@ -162,29 +166,29 @@ OrderBook::ViewChangeResult OrderBook::updateOfiView(long long price, long long const bool wasFull = (ofiBids.size() == ofiDepth); ofiBids.erase(it); // O(1) amortised: walk inward from the OFI boundary tick-by-tick. - const bool hasWorst = !ofiBids.empty(); - const long long worst = hasWorst ? ofiBids.back().first : 0; - const long long repPrice = hasWorst ? bidLadder.prevBelow(worst) - : bidLadder.bestHigh(); - const long long repQty = repPrice > 0 ? bidLadder.get(repPrice) : 0; + const bool hasWorst = !ofiBids.empty(); + const long long worst = hasWorst ? ofiBids.back().first : 0; + const long long repPrice = + hasWorst ? bidLadder.prevBelow(worst) : bidLadder.bestHigh(); + const long long repQty = repPrice > 0 ? bidLadder.get(repPrice) : 0; if (repPrice != 0) { ofiBids.push_back({repPrice, repQty}); } if (wasFull && ofiBids.size() == ofiDepth) { result.replacementPrice = ofiBids.back().first; - result.replacementQty = ofiBids.back().second; + result.replacementQty = ofiBids.back().second; } } else { it->second = newQty; } } else if (newQty > 0) { const bool viewNotFull = ofiBids.size() < ofiDepth; - const bool beatWorst = !ofiBids.empty() && price > ofiBids.back().first; + const bool beatWorst = !ofiBids.empty() && price > ofiBids.back().first; if (viewNotFull || beatWorst) { ofiBids.insert(it, {price, newQty}); if (ofiBids.size() > ofiDepth) { result.evictedPrice = ofiBids.back().first; - result.evictedQty = ofiBids.back().second; + result.evictedQty = ofiBids.back().second; ofiBids.pop_back(); } } @@ -199,29 +203,29 @@ OrderBook::ViewChangeResult OrderBook::updateOfiView(long long price, long long const bool wasFull = (ofiAsks.size() == ofiDepth); ofiAsks.erase(it); // O(1) amortised: walk outward from the OFI boundary tick-by-tick. - const bool hasWorst = !ofiAsks.empty(); - const long long worst = hasWorst ? ofiAsks.back().first : 0; - const long long repPrice = hasWorst ? askLadder.nextAbove(worst) - : askLadder.bestLow(); - const long long repQty = repPrice > 0 ? askLadder.get(repPrice) : 0; + const bool hasWorst = !ofiAsks.empty(); + const long long worst = hasWorst ? ofiAsks.back().first : 0; + const long long repPrice = + hasWorst ? askLadder.nextAbove(worst) : askLadder.bestLow(); + const long long repQty = repPrice > 0 ? askLadder.get(repPrice) : 0; if (repPrice != 0) { ofiAsks.push_back({repPrice, repQty}); } if (wasFull && ofiAsks.size() == ofiDepth) { result.replacementPrice = ofiAsks.back().first; - result.replacementQty = ofiAsks.back().second; + result.replacementQty = ofiAsks.back().second; } } else { it->second = newQty; } } else if (newQty > 0) { const bool viewNotFull = ofiAsks.size() < ofiDepth; - const bool beatWorst = !ofiAsks.empty() && price < ofiAsks.back().first; + const bool beatWorst = !ofiAsks.empty() && price < ofiAsks.back().first; if (viewNotFull || beatWorst) { ofiAsks.insert(it, {price, newQty}); if (ofiAsks.size() > ofiDepth) { result.evictedPrice = ofiAsks.back().first; - result.evictedQty = ofiAsks.back().second; + result.evictedQty = ofiAsks.back().second; ofiAsks.pop_back(); } } @@ -285,7 +289,9 @@ OrderBook::Snapshot OrderBook::getSnapshot() const { std::lock_guard lock(orderBookMutex); Snapshot s; s.applied = snapshotApplied.load(); - if (!s.applied) { return s; } + if (!s.applied) { + return s; + } askLadder.forEach([&s](long long p, long long q) { s.asks.push_back({p, q}); }); // forEach visits in ascending order — asks are already ascending. diff --git a/src/order_book.hpp b/src/order_book.hpp index 3c1e7ac..ad39630 100644 --- a/src/order_book.hpp +++ b/src/order_book.hpp @@ -28,8 +28,7 @@ class OrderBook { // ofiDepth: number of top price levels tracked in the OFI view per side (default 10). // tickSize: minimum price increment in internal 1e8 units (default 1 000 000 = $0.01). - explicit OrderBook(std::size_t ofiDepth = 10, - long long tickSize = PriceLadder::kDefaultTick); + explicit OrderBook(std::size_t ofiDepth = 10, long long tickSize = PriceLadder::kDefaultTick); // Clears all state and rebuilds from the Binance-format snapshot JSON. // Rebuilds the OFI view from the top-ofiDepth levels of the new state. diff --git a/src/price_ladder.cpp b/src/price_ladder.cpp index 793e435..40ec768 100644 --- a/src/price_ladder.cpp +++ b/src/price_ladder.cpp @@ -39,13 +39,13 @@ void PriceLadder::initCenter(long long price) { void PriceLadder::rebuildBestIndices() { bestHighIdx = -1; - bestLowIdx = size; + bestLowIdx = size; count = 0; for (int i = 0; i < size; ++i) { if (qtys[i] > 0) { ++count; bestHighIdx = std::max(bestHighIdx, i); - bestLowIdx = std::min(bestLowIdx, i); + bestLowIdx = std::min(bestLowIdx, i); } } } @@ -59,14 +59,12 @@ void PriceLadder::recenter(long long price) { const int keep = size - shift; std::memmove(qtys.get(), qtys.get() + shift, static_cast(keep) * sizeof(long long)); - std::memset(qtys.get() + keep, 0, - static_cast(shift) * sizeof(long long)); + std::memset(qtys.get() + keep, 0, static_cast(shift) * sizeof(long long)); } else if (shift < 0 && -shift < size) { const int keep = size + shift; // shift is negative std::memmove(qtys.get() - shift, qtys.get(), static_cast(keep) * sizeof(long long)); - std::memset(qtys.get(), 0, - static_cast(-shift) * sizeof(long long)); + std::memset(qtys.get(), 0, static_cast(-shift) * sizeof(long long)); } else { // Shift >= array size: all existing entries fall outside the new window. std::memset(qtys.get(), 0, static_cast(size) * sizeof(long long)); @@ -82,7 +80,7 @@ void PriceLadder::set(long long price, long long qty) { recenter(price); } - const int idx = toIdx(price); + const int idx = toIdx(price); const long long old = qtys[idx]; qtys[idx] = qty; @@ -96,14 +94,18 @@ void PriceLadder::set(long long price, long long qty) { if (qty > 0 && idx > bestHighIdx) { bestHighIdx = idx; } else if (qty == 0 && idx == bestHighIdx) { - while (bestHighIdx >= 0 && qtys[bestHighIdx] == 0) { --bestHighIdx; } + while (bestHighIdx >= 0 && qtys[bestHighIdx] == 0) { + --bestHighIdx; + } } // Maintain bestLowIdx. if (qty > 0 && idx < bestLowIdx) { bestLowIdx = idx; } else if (qty == 0 && idx == bestLowIdx) { - while (bestLowIdx < size && qtys[bestLowIdx] == 0) { ++bestLowIdx; } + while (bestLowIdx < size && qtys[bestLowIdx] == 0) { + ++bestLowIdx; + } } } @@ -118,9 +120,9 @@ void PriceLadder::clear() { if (initialized) { std::memset(qtys.get(), 0, static_cast(size) * sizeof(long long)); } - count = 0; + count = 0; bestHighIdx = -1; - bestLowIdx = size; + bestLowIdx = size; initialized = false; } diff --git a/src/price_ladder.hpp b/src/price_ladder.hpp index 63ec598..18ba0d7 100644 --- a/src/price_ladder.hpp +++ b/src/price_ladder.hpp @@ -15,11 +15,11 @@ // Default: ±300 000 ticks = ±$3 000 at $0.01 tick ≈ 4.8 MB per ladder. class PriceLadder { public: - static constexpr long long kDefaultTick = 1'000'000LL; // $0.01 in 1e8 units - static constexpr int kDefaultHalfRange = 300'000; // ±$3 000 at default tick + static constexpr long long kDefaultTick = 1'000'000LL; // $0.01 in 1e8 units + static constexpr int kDefaultHalfRange = 300'000; // ±$3 000 at default tick explicit PriceLadder(long long tickSizeArg = kDefaultTick, - int halfRangeArg = kDefaultHalfRange); + int halfRangeArg = kDefaultHalfRange); // Set (or remove if qty == 0) a price level. // Triggers re-centering if price falls outside the current window. @@ -57,10 +57,10 @@ class PriceLadder { private: long long tickSize; - int halfRange; - int size; // halfRange * 2 + 1 + int halfRange; + int size; // halfRange * 2 + 1 long long basePrice = 0; // price at index 0; always a multiple of tickSize - bool initialized = false; + bool initialized = false; std::unique_ptr qtys; std::size_t count = 0; @@ -68,9 +68,9 @@ class PriceLadder { int bestHighIdx = -1; // highest index with qty > 0; -1 when empty int bestLowIdx; // lowest index with qty > 0; size when empty - int toIdx(long long price) const noexcept; - long long toPrice(int idx) const noexcept; - bool inRange(long long price) const noexcept; + int toIdx(long long price) const noexcept; + long long toPrice(int idx) const noexcept; + bool inRange(long long price) const noexcept; void initCenter(long long price); void recenter(long long price); From 80656ec97f70e8638660f7e366848fed222a647b Mon Sep 17 00:00:00 2001 From: Andrzej Pijanowski Date: Wed, 11 Mar 2026 11:51:24 +0100 Subject: [PATCH 3/6] fix: Improve PriceLadder constructor validation and adjust bestLowIdx initialization --- src/price_ladder.cpp | 33 +++++++++++++++++++++------------ src/price_ladder.hpp | 2 +- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/price_ladder.cpp b/src/price_ladder.cpp index 40ec768..167b9c9 100644 --- a/src/price_ladder.cpp +++ b/src/price_ladder.cpp @@ -2,13 +2,20 @@ #include #include +#include +#include -PriceLadder::PriceLadder(long long tickSizeArg, int halfRangeArg) - : tickSize(tickSizeArg), - halfRange(halfRangeArg), - size((halfRangeArg * 2) + 1), - bestLowIdx((halfRangeArg * 2) + 1) { - qtys = std::make_unique(size); +PriceLadder::PriceLadder(long long tickSizeArg, int halfRangeArg) { + if (tickSizeArg <= 0) { + throw std::invalid_argument("PriceLadder: tickSizeArg must be positive"); + } + if (halfRangeArg < 0 || halfRangeArg > (std::numeric_limits::max() - 1) / 2) { + throw std::invalid_argument("PriceLadder: halfRangeArg out of range"); + } + tickSize = tickSizeArg; + halfRange = halfRangeArg; + size = (halfRangeArg * 2) + 1; + qtys = std::make_unique(static_cast(size)); std::memset(qtys.get(), 0, static_cast(size) * sizeof(long long)); } @@ -39,13 +46,13 @@ void PriceLadder::initCenter(long long price) { void PriceLadder::rebuildBestIndices() { bestHighIdx = -1; - bestLowIdx = size; + bestLowIdx = -1; count = 0; for (int i = 0; i < size; ++i) { if (qtys[i] > 0) { ++count; bestHighIdx = std::max(bestHighIdx, i); - bestLowIdx = std::min(bestLowIdx, i); + if (bestLowIdx < 0) { bestLowIdx = i; } // ascending scan: first found is lowest } } } @@ -100,12 +107,14 @@ void PriceLadder::set(long long price, long long qty) { } // Maintain bestLowIdx. - if (qty > 0 && idx < bestLowIdx) { + if (qty > 0 && (bestLowIdx < 0 || idx < bestLowIdx)) { bestLowIdx = idx; } else if (qty == 0 && idx == bestLowIdx) { + ++bestLowIdx; while (bestLowIdx < size && qtys[bestLowIdx] == 0) { ++bestLowIdx; } + if (bestLowIdx >= size) { bestLowIdx = -1; } } } @@ -122,7 +131,7 @@ void PriceLadder::clear() { } count = 0; bestHighIdx = -1; - bestLowIdx = size; + bestLowIdx = -1; initialized = false; } @@ -134,7 +143,7 @@ long long PriceLadder::bestHigh() const { } long long PriceLadder::bestLow() const { - if (bestLowIdx >= size) { + if (bestLowIdx < 0) { return 0; } return toPrice(bestLowIdx); @@ -159,7 +168,7 @@ long long PriceLadder::prevBelow(long long below) const { } long long PriceLadder::nextAbove(long long above) const { - if (!initialized || bestLowIdx >= size) { + if (!initialized || bestLowIdx < 0) { return 0; } const long long offset = above - basePrice; diff --git a/src/price_ladder.hpp b/src/price_ladder.hpp index 18ba0d7..8d04ef8 100644 --- a/src/price_ladder.hpp +++ b/src/price_ladder.hpp @@ -66,7 +66,7 @@ class PriceLadder { std::size_t count = 0; int bestHighIdx = -1; // highest index with qty > 0; -1 when empty - int bestLowIdx; // lowest index with qty > 0; size when empty + int bestLowIdx = -1; // lowest index with qty > 0; -1 when empty int toIdx(long long price) const noexcept; long long toPrice(int idx) const noexcept; From 049ef278d90b5df38f661de063627fa3f46c1ff0 Mon Sep 17 00:00:00 2001 From: Andrzej Pijanowski Date: Wed, 11 Mar 2026 15:13:25 +0100 Subject: [PATCH 4/6] fix: Add assertion for non-negative quantity in PriceLadder::set method --- src/price_ladder.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/price_ladder.cpp b/src/price_ladder.cpp index 167b9c9..70a9c47 100644 --- a/src/price_ladder.cpp +++ b/src/price_ladder.cpp @@ -1,6 +1,7 @@ #include "price_ladder.hpp" #include +#include #include #include #include @@ -81,6 +82,7 @@ void PriceLadder::recenter(long long price) { } void PriceLadder::set(long long price, long long qty) { + assert(qty >= 0 && "qty must be non-negative"); if (!initialized) { initCenter(price); } else if (!inRange(price)) { From 6e37fae28ac58768dabade2cc1fc84e5cd1990c2 Mon Sep 17 00:00:00 2001 From: Andrzej Pijanowski Date: Wed, 11 Mar 2026 15:30:31 +0100 Subject: [PATCH 5/6] fix: Adjust calculation of startIdx in PriceLadder::prevBelow method for accurate index determination --- src/price_ladder.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/price_ladder.cpp b/src/price_ladder.cpp index 70a9c47..bac6f6b 100644 --- a/src/price_ladder.cpp +++ b/src/price_ladder.cpp @@ -159,7 +159,7 @@ long long PriceLadder::prevBelow(long long below) const { if (offset <= 0) { return 0; } - int startIdx = static_cast(offset / tickSize) - 1; + int startIdx = static_cast((offset - 1) / tickSize); startIdx = std::min(startIdx, size - 1); for (int i = startIdx; i >= 0; --i) { if (qtys[i] > 0) { From 43f31b676356017712d876b03f369c9ec6b7a17b Mon Sep 17 00:00:00 2001 From: Andrzej Pijanowski Date: Wed, 11 Mar 2026 16:32:29 +0100 Subject: [PATCH 6/6] fix: Refactor shift calculation in PriceLadder::recenter for improved type safety --- src/price_ladder.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/price_ladder.cpp b/src/price_ladder.cpp index bac6f6b..0bf31f7 100644 --- a/src/price_ladder.cpp +++ b/src/price_ladder.cpp @@ -61,14 +61,16 @@ void PriceLadder::rebuildBestIndices() { void PriceLadder::recenter(long long price) { const long long rounded = (price / tickSize) * tickSize; const long long newBase = rounded - (static_cast(halfRange) * tickSize); - const int shift = static_cast((newBase - basePrice) / tickSize); + const long long shiftLL = (newBase - basePrice) / tickSize; - if (shift > 0 && shift < size) { + if (shiftLL > 0 && shiftLL < static_cast(size)) { + const int shift = static_cast(shiftLL); const int keep = size - shift; std::memmove(qtys.get(), qtys.get() + shift, static_cast(keep) * sizeof(long long)); std::memset(qtys.get() + keep, 0, static_cast(shift) * sizeof(long long)); - } else if (shift < 0 && -shift < size) { + } else if (shiftLL < 0 && -shiftLL < static_cast(size)) { + const int shift = static_cast(shiftLL); const int keep = size + shift; // shift is negative std::memmove(qtys.get() - shift, qtys.get(), static_cast(keep) * sizeof(long long));