diff --git a/CMakeLists.txt b/CMakeLists.txt index fe80f5c..d176f0f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,8 @@ add_subdirectory(src/agents) add_subdirectory(src/strategy) add_subdirectory(src/metrics) +add_subdirectory(apps) + if(MICROSIM_BUILD_TESTS) enable_testing() add_subdirectory(tests) diff --git a/apps/CMakeLists.txt b/apps/CMakeLists.txt new file mode 100644 index 0000000..add8237 --- /dev/null +++ b/apps/CMakeLists.txt @@ -0,0 +1,6 @@ +# Command-line front ends. Tier-A ships microsim_run, a scripted demonstration +# driver; the scenario-file replay CLIs (microsim_run v1, microsim_replay) are +# fleshed out in R1-21. + +add_executable(microsim_run microsim_run.cpp) +target_link_libraries(microsim_run PRIVATE microsim::engine microsim::warnings) diff --git a/apps/microsim_run.cpp b/apps/microsim_run.cpp new file mode 100644 index 0000000..1ec4d78 --- /dev/null +++ b/apps/microsim_run.cpp @@ -0,0 +1,191 @@ +/// \file +/// `microsim_run` — a minimal demonstration driver for the exchange (Tier-A +/// walking skeleton; the full scenario-file replay CLI is R1-21). It registers +/// one instrument and a few participants, feeds a scripted order flow through +/// the matching engine, and prints the resulting trades and the final book in +/// human-readable dollars (via the R1-06 boundary conversions). Determinism: +/// no clock reads, no randomness — the output is a pure function of the script. + +#include +#include +#include +#include + +#include "microsim/book/reference_book.hpp" +#include "microsim/core/config.hpp" +#include "microsim/core/convert.hpp" +#include "microsim/core/events.hpp" +#include "microsim/core/messages.hpp" +#include "microsim/core/types.hpp" +#include "microsim/engine/matching_engine.hpp" +#include "microsim/engine/venue.hpp" + +namespace mc = microsim::core; +namespace me = microsim::engine; +namespace mb = microsim::book; + +namespace { + +constexpr int kDp = 2; // the instrument quotes in cents (2 dp) +constexpr mc::InstrumentId kInstr{1}; + +mc::InstrumentConfig demo_instrument() { + return mc::InstrumentConfig{ + .id = kInstr, + .symbol = "SIM", + .tick_size = 1, // 1 minor unit (cent) per tick + .lot_size = 1, + .min_price = mc::Price{500}, // $5.00 + .max_price = mc::Price{1500}, // $15.00 + .max_order_qty = mc::Qty{10000}, + .fees = {.taker_fee_per_lot = mc::Cash{2}, .maker_rebate_per_lot = mc::Cash{1}}}; +} + +std::string usd(mc::Price p, const mc::InstrumentConfig& instr) { + return "$" + mc::price_to_decimal(p, instr, kDp); +} + +std::string usd(mc::Cash c) { + return "$" + mc::cash_to_decimal(c, kDp); +} + +const char* side_str(mc::Side s) { + return s == mc::Side::Buy ? "BUY " : "SELL"; +} + +// A scripted line: a participant name for display plus the message to send. +struct Scripted { + std::string who; + mc::NewOrder msg; +}; + +mc::NewOrder lim(mc::ParticipantId p, std::uint64_t clord, mc::Side side, std::int64_t px, + std::int64_t qty) { + return mc::NewOrder{.participant = p, + .client_order_id = mc::ClientOrderId{clord}, + .instrument = kInstr, + .side = side, + .type = mc::OrderType::Limit, + .qty = mc::Qty{qty}, + .price = mc::Price{px}}; +} + +mc::NewOrder mkt(mc::ParticipantId p, std::uint64_t clord, mc::Side side, std::int64_t qty) { + return mc::NewOrder{.participant = p, + .client_order_id = mc::ClientOrderId{clord}, + .instrument = kInstr, + .side = side, + .type = mc::OrderType::Market, + .qty = mc::Qty{qty}, + .price = mc::Price{}}; +} + +void print_events(const std::vector& evs, const mc::InstrumentConfig& instr) { + for (const mc::Outbound& e : evs) { + if (std::holds_alternative(e)) { + std::cout << " accepted order #" << std::get(e).order_id.value() + << "\n"; + } else if (std::holds_alternative(e)) { + std::cout << " REJECTED (" << mc::to_cstr(std::get(e).reason) + << ")\n"; + } else if (std::holds_alternative(e)) { + const auto& t = std::get(e); + std::cout << " • trade T" << t.trade_id.value() << " " << t.qty.lots() << " @ " + << usd(t.price, instr) << " maker #" << t.maker_order_id.value() << " taker #" + << t.taker_order_id.value() << "\n"; + } else if (std::holds_alternative(e)) { + const auto& c = std::get(e); + std::cout << " canceled " << c.remaining_qty.lots() << " lots (" + << mc::to_cstr(c.reason) << ")\n"; + } + // Fills are the private per-party legs of each Trade; omitted from this + // summary view to keep the transcript readable. + } +} + +template +void print_book(const Engine& eng) { + const mb::BookState s = eng.book().dump_state(); + const mc::InstrumentConfig& instr = eng.instrument(); + std::cout << "\n Final book (price / resting qty):\n"; + std::cout << " ASKS\n"; + // Asks best-first is lowest-first; print worst-to-best so the spread sits in + // the middle, like a real depth ladder. + for (auto it = s.asks.rbegin(); it != s.asks.rend(); ++it) { + mc::Qty q{0}; + for (const auto& o : it->orders) { + q += o.remaining; + } + std::cout << " " << usd(it->price, instr) << " x " << q.lots() << "\n"; + } + std::cout << " ---------------- spread\n"; + for (const auto& level : s.bids) { // bids best-first (highest first) + mc::Qty q{0}; + for (const auto& o : level.orders) { + q += o.remaining; + } + std::cout << " " << usd(level.price, instr) << " x " << q.lots() << "\n"; + } + std::cout << " BIDS\n"; +} + +} // namespace + +int main() { + const mc::InstrumentConfig instr = demo_instrument(); + + me::Venue venue; + venue.add_instrument(instr); + const mc::ParticipantId mm1{1}; + const mc::ParticipantId mm2{2}; + const mc::ParticipantId taker{9}; + venue.add_participant(mc::ParticipantConfig{.id = mm1}); + venue.add_participant(mc::ParticipantConfig{.id = mm2}); + venue.add_participant(mc::ParticipantConfig{.id = taker}); + + me::MatchingEngine eng{venue, instr}; + + // A scripted flow: two makers post depth, then a taker sweeps the offers and + // a marketable sell hits the bid. Mirrors the EXCHANGE_RULES §15 example. + const std::vector script = { + {"MM1", lim(mm1, 1, mc::Side::Sell, 1003, 10)}, // ask $10.03 x10 + {"MM2", lim(mm2, 1, mc::Side::Sell, 1003, 15)}, // ask $10.03 x15 (behind) + {"MM1", lim(mm1, 2, mc::Side::Sell, 1005, 40)}, // ask $10.05 x40 + {"MM1", lim(mm1, 3, mc::Side::Buy, 1001, 20)}, // bid $10.01 x20 + {"MM2", lim(mm2, 2, mc::Side::Buy, 1000, 30)}, // bid $10.00 x30 + {"TKR", mkt(taker, 1, mc::Side::Buy, 60)}, // MARKET BUY 60 -> sweeps + {"TKR", lim(taker, 2, mc::Side::Sell, 1001, 25)}, // SELL crosses the bid + }; + + std::cout << "MicroSim — deterministic exchange simulator\n"; + std::cout << "Instrument " << instr.symbol << " tick $0.01 band " + << usd(instr.min_price, instr) << "–" << usd(instr.max_price, instr) + << " fees: taker " << usd(instr.fees.taker_fee_per_lot) << "/lot, maker rebate " + << usd(instr.fees.maker_rebate_per_lot) << "/lot\n\n"; + + int trade_count = 0; + long long volume = 0; + for (const Scripted& line : script) { + const bool is_market = line.msg.type == mc::OrderType::Market; + std::cout << ">> " << line.who << " " << (is_market ? "MARKET " : "LIMIT ") + << side_str(line.msg.side) << " " << line.msg.qty.lots(); + if (!is_market) { + std::cout << " @ " << usd(line.msg.price, instr); + } + std::cout << "\n"; + + const std::vector evs = eng.process(line.msg); + print_events(evs, instr); + for (const mc::Outbound& e : evs) { + if (std::holds_alternative(e)) { + ++trade_count; + volume += std::get(e).qty.lots(); + } + } + } + + print_book(eng); + std::cout << "\n " << trade_count << " trades, " << volume << " lots matched, " + << eng.registry().size() << " orders processed.\n"; + return 0; +} diff --git a/src/book/CMakeLists.txt b/src/book/CMakeLists.txt index 3ce4c35..eaac420 100644 --- a/src/book/CMakeLists.txt +++ b/src/book/CMakeLists.txt @@ -1,3 +1,3 @@ -microsim_add_library(book src/module_info.cpp) +microsim_add_library(book src/module_info.cpp src/reference_book.cpp) target_link_libraries(microsim_book PUBLIC microsim::core) diff --git a/src/book/include/microsim/book/order_book.hpp b/src/book/include/microsim/book/order_book.hpp new file mode 100644 index 0000000..b1df3ac --- /dev/null +++ b/src/book/include/microsim/book/order_book.hpp @@ -0,0 +1,85 @@ +#pragma once + +/// \file +/// The order-book interface shared by both implementations (task R1-09): the +/// `RestingOrder` value a book stores, the canonical `BookState` snapshot used +/// for differential comparison (INV-15), and the `OrderBookLike` concept the +/// matching engine templates over (ORDER_BOOK_DESIGN.md §"Shared interface"). +/// +/// Both `ReferenceBook` (the oracle, this release) and `FastBook` (R1-19) must +/// satisfy `OrderBookLike`, so the engine, unit/property/differential tests, and +/// benchmarks all run over identical scenarios against either book. The two +/// share *only* this header and `core` — no implementation code (independence, +/// REFERENCE_MODEL.md §"Design constraints"). + +#include +#include +#include + +#include "microsim/core/types.hpp" + +namespace microsim::book { + +using core::OrderId; +using core::ParticipantId; +using core::Price; +using core::Qty; +using core::Side; + +/// A live order as the book sees it. The book tracks only what price-time +/// priority needs; the order's original/filled totals live in the registry +/// (R1-10). `remaining` is the unfilled quantity currently resting. +struct RestingOrder { + OrderId id{}; + ParticipantId participant{}; + Side side{}; + Price price{}; + Qty remaining{}; + + friend bool operator==(const RestingOrder&, const RestingOrder&) noexcept = default; +}; + +/// One price level of a `BookState` snapshot: the level's price and its orders +/// in FIFO (time-priority) order. +struct BookLevelState { + Price price{}; + std::vector orders; ///< front() is the highest-priority order + + friend bool operator==(const BookLevelState&, const BookLevelState&) noexcept = default; +}; + +/// A full, canonical snapshot of a book: both sides as level lists, each ordered +/// best price first, each level's orders in FIFO order. Two books are in the +/// same state iff their `BookState`s compare equal — the book half of the +/// differential check (the event-stream half is the stronger one). +struct BookState { + std::vector bids; ///< best (highest) first + std::vector asks; ///< best (lowest) first + + friend bool operator==(const BookState&, const BookState&) noexcept = default; +}; + +/// The book operations the matching engine and session-end path depend on. Any +/// conforming book keeps orders in price-time priority: `front(side)` is the +/// best-priced, then oldest, resting order on that side. +/// +/// (`for_each_level` is a template member — it cannot appear in a `requires` +/// clause cleanly — and is validated by use, not by this concept.) +template +concept OrderBookLike = requires(B book, const B cbook, const RestingOrder& order, OrderId id, + Side side, Price price, Qty qty) { + // Mutation. + { book.add(order) } -> std::same_as; + { book.reduce(id, qty) } -> std::same_as; + { book.remove(id) } -> std::same_as; + { book.clear() } -> std::same_as; + // Inspection. + { cbook.find(id) } -> std::same_as; + { cbook.front(side) } -> std::same_as; + { cbook.best(side) } -> std::same_as>; + { cbook.depth(side, price) } -> std::same_as; + { cbook.empty(side) } -> std::same_as; + { cbook.dump_state() } -> std::same_as; +}; + +} // namespace microsim::book diff --git a/src/book/include/microsim/book/reference_book.hpp b/src/book/include/microsim/book/reference_book.hpp new file mode 100644 index 0000000..791bbb5 --- /dev/null +++ b/src/book/include/microsim/book/reference_book.hpp @@ -0,0 +1,111 @@ +#pragma once + +/// \file +/// `ReferenceBook` — the slow, obviously-correct order book that serves as the +/// differential-testing oracle (task R1-09, REFERENCE_MODEL.md). Every method is +/// written to read like the matching EXCHANGE_RULES.md paragraph; a reviewer +/// holding the rules doc verifies each by inspection. There is deliberately *no* +/// performance consideration here: `std::map` + `std::list`, linear scans, and +/// on-demand aggregate summation. The moment this is optimized it stops being a +/// reference (REFERENCE_MODEL.md §"No performance consideration whatsoever"). +/// +/// Independence from `FastBook` is on purpose: different data structures mean a +/// shared bug would require the same mistake twice in different shapes. + +#include +#include +#include +#include + +#include "microsim/book/order_book.hpp" +#include "microsim/core/types.hpp" + +namespace microsim::book { + +class ReferenceBook { + public: + // ----- mutation ------------------------------------------------------------ + + /// Add a resting order at the back of its price level's FIFO queue (R-5.6). + /// The order must not already be present. + void add(const RestingOrder& order); + + /// Reduce a resting order's remaining quantity, keeping its queue position + /// (R-7.2 "quantity decrease, same price"). `new_remaining` must be > 0 and + /// < the current remaining. To remove an order entirely, use remove(). + void reduce(OrderId id, Qty new_remaining); + + /// Remove a resting order from the book (a full fill, a cancel, session end). + void remove(OrderId id); + + /// Remove every resting order (session end, R-12.3 clears after cancels). + void clear(); + + // ----- inspection ---------------------------------------------------------- + + /// The resting order with this id, or nullptr if it is not in the book. + [[nodiscard]] const RestingOrder* find(OrderId id) const; + + /// The highest-priority resting order on `side` (best price, FIFO front), or + /// nullptr if that side is empty. This is what the match loop consumes. + [[nodiscard]] const RestingOrder* front(Side side) const; + + /// The best (most aggressive) price resting on `side`, or nullopt if empty: + /// highest bid, lowest ask (R-5.2). + [[nodiscard]] std::optional best(Side side) const; + + /// Total remaining quantity resting at (`side`, `price`) — recomputed by + /// summation, never cached (REFERENCE_MODEL.md: no cached aggregates to get + /// wrong). Zero if the level is empty. + [[nodiscard]] Qty depth(Side side, Price price) const; + + /// Whether `side` has no resting orders. + [[nodiscard]] bool empty(Side side) const; + + /// A canonical snapshot for differential comparison and tests (INV-15): both + /// sides, best price first, each level's orders in FIFO order. + [[nodiscard]] BookState dump_state() const; + + /// Visit every level of `side` best price first, and within a level every + /// order in FIFO priority order. Used by session end (R-12.3), which cancels + /// resting orders in exactly this order. + template + void for_each_order(Side side, Fn&& fn) const { + if (side == Side::Buy) { + for (const auto& [price, orders] : bids_) { + for (const RestingOrder& o : orders) { + fn(o); + } + } + } else { + for (const auto& [price, orders] : asks_) { + for (const RestingOrder& o : orders) { + fn(o); + } + } + } + } + + private: + // Best-first order per side is baked into the map comparator: bids highest + // price first, asks lowest first (R-5.2). Each level is a FIFO list; the front + // of the list is the oldest (highest time priority) order. + using BidLevels = std::map, std::greater<>>; + using AskLevels = std::map, std::less<>>; + + /// Where a resting order lives, so cancel/reduce/remove are direct lookups. + struct Locator { + Side side; + Price price; + std::list::iterator it; + }; + + BidLevels bids_; + AskLevels asks_; + std::map index_; +}; + +static_assert(OrderBookLike, + "ReferenceBook must satisfy the shared order-book interface"); + +} // namespace microsim::book diff --git a/src/book/src/reference_book.cpp b/src/book/src/reference_book.cpp new file mode 100644 index 0000000..6942cf3 --- /dev/null +++ b/src/book/src/reference_book.cpp @@ -0,0 +1,128 @@ +#include "microsim/book/reference_book.hpp" + +#include +#include +#include + +namespace microsim::book { + +void ReferenceBook::add(const RestingOrder& order) { + assert(index_.find(order.id) == index_.end() && "order already resting"); + assert(order.remaining > Qty{0} && "resting order must have positive quantity"); + + // R-5.6: the order joins the back of its price level's FIFO queue. A new level + // is created on first use; the map comparator keeps levels best-price-first. + std::list::iterator it; + if (order.side == Side::Buy) { + std::list& level = bids_[order.price]; + level.push_back(order); + it = std::prev(level.end()); + } else { + std::list& level = asks_[order.price]; + level.push_back(order); + it = std::prev(level.end()); + } + index_.emplace(order.id, Locator{order.side, order.price, it}); +} + +void ReferenceBook::reduce(OrderId id, Qty new_remaining) { + auto i = index_.find(id); + assert(i != index_.end() && "reduce of an order not in the book"); + RestingOrder& o = *i->second.it; + // R-7.2 quantity-decrease-same-price: strictly smaller, still positive; the + // order keeps its list position (time priority) untouched. + assert(new_remaining > Qty{0} && new_remaining < o.remaining && "reduce must shrink to > 0"); + o.remaining = new_remaining; +} + +void ReferenceBook::remove(OrderId id) { + auto i = index_.find(id); + assert(i != index_.end() && "remove of an order not in the book"); + const Locator& loc = i->second; + if (loc.side == Side::Buy) { + auto level = bids_.find(loc.price); + level->second.erase(loc.it); + if (level->second.empty()) { + bids_.erase(level); // drop empty levels so begin() is always the best + } + } else { + auto level = asks_.find(loc.price); + level->second.erase(loc.it); + if (level->second.empty()) { + asks_.erase(level); + } + } + index_.erase(i); +} + +void ReferenceBook::clear() { + bids_.clear(); + asks_.clear(); + index_.clear(); +} + +const RestingOrder* ReferenceBook::find(OrderId id) const { + auto i = index_.find(id); + if (i == index_.end()) { + return nullptr; + } + return &*i->second.it; +} + +const RestingOrder* ReferenceBook::front(Side side) const { + // begin() is the best level (map comparator); a resting level is never empty + // (remove() drops emptied levels), so front() of it is the FIFO head. + if (side == Side::Buy) { + return bids_.empty() ? nullptr : &bids_.begin()->second.front(); + } + return asks_.empty() ? nullptr : &asks_.begin()->second.front(); +} + +std::optional ReferenceBook::best(Side side) const { + if (side == Side::Buy) { + return bids_.empty() ? std::nullopt : std::optional{bids_.begin()->first}; + } + return asks_.empty() ? std::nullopt : std::optional{asks_.begin()->first}; +} + +Qty ReferenceBook::depth(Side side, Price price) const { + Qty total{0}; + const auto sum_level = [&](const auto& levels) { + auto level = levels.find(price); + if (level == levels.end()) { + return; + } + for (const RestingOrder& o : level->second) { + total += o.remaining; // recompute on demand; never a cached aggregate + } + }; + if (side == Side::Buy) { + sum_level(bids_); + } else { + sum_level(asks_); + } + return total; +} + +bool ReferenceBook::empty(Side side) const { + return side == Side::Buy ? bids_.empty() : asks_.empty(); +} + +BookState ReferenceBook::dump_state() const { + BookState state; + for (const auto& [price, orders] : bids_) { + BookLevelState level; + level.price = price; + level.orders.assign(orders.begin(), orders.end()); + state.bids.push_back(std::move(level)); + } + for (const auto& [price, orders] : asks_) { + BookLevelState level; + level.price = price; + level.orders.assign(orders.begin(), orders.end()); + state.asks.push_back(std::move(level)); + } + return state; +} + +} // namespace microsim::book diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 7a312c3..3881aed 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -1 +1,2 @@ -microsim_add_library(core src/module_info.cpp src/types.cpp src/enums.cpp src/config.cpp) +microsim_add_library(core src/module_info.cpp src/types.cpp src/enums.cpp src/config.cpp + src/convert.cpp) diff --git a/src/core/include/microsim/core/convert.hpp b/src/core/include/microsim/core/convert.hpp new file mode 100644 index 0000000..8730891 --- /dev/null +++ b/src/core/include/microsim/core/convert.hpp @@ -0,0 +1,116 @@ +#pragma once + +/// \file +/// Boundary conversions between human/config decimal strings and the engine's +/// exact integer types (task R1-06). This is the *only* place decimal text meets +/// the integer core: everything inside the engine is already ticks/lots/minor +/// units. Implements NUMERIC_REPRESENTATION.md §"Conversion and rounding rules": +/// +/// * decimal string -> minor units -> ticks/lots, *exactly*; +/// * a value that is not a whole multiple of the tick (or lot) is **rejected** +/// (`NotTickMultiple` / `NotLotMultiple`, the `INVALID_TICK` semantics), +/// never rounded; +/// * more fractional digits than the currency scale allows is a rejection, not +/// a truncation; +/// * formatting back to text is integer division/modulo on the scales, never +/// double formatting. +/// +/// Nothing here allocates on the parse path, throws, or reads a clock. The +/// formatting helpers build a std::string (the one allocation, off the hot path). + +#include +#include +#include +#include + +#include "microsim/core/config.hpp" +#include "microsim/core/types.hpp" + +namespace microsim::core { + +/// Why a boundary conversion failed. Distinct from ConfigError (construction of +/// an instrument) and RejectReason (a runtime order message): these describe a +/// single string<->integer conversion at the edge. +enum class ConvertError : std::uint8_t { + Empty = 0, ///< empty or whitespace-only input + BadFormat, ///< a character that is not sign/digit/'.'; misplaced sign or dot + TooManyFractionDigits, ///< more digits after '.' than the currency scale represents + Overflow, ///< magnitude does not fit the int64 minor-unit range + NotTickMultiple, ///< minor units are not a whole number of ticks (INVALID_TICK) + NotLotMultiple, ///< base units are not a whole number of lots + Negative, ///< a negative value where only non-negative is allowed +}; + +[[nodiscard]] const char* to_cstr(ConvertError e) noexcept; + +/// The result of a boundary conversion: a value plus an optional error. When +/// `error` is set, `value` is unspecified. This mirrors the house style of +/// `validate()` returning `std::optional`, extended to carry a value. +template +struct Converted { + T value{}; + std::optional error{}; + + [[nodiscard]] constexpr bool ok() const noexcept { return !error.has_value(); } + + [[nodiscard]] constexpr explicit operator bool() const noexcept { return ok(); } +}; + +// ============================================================================= +// Decimal string -> exact minor units +// ============================================================================= +// +// `frac_digits` is the currency scale: the number of fractional decimal digits +// the minor unit represents (2 for cents, 0 for a whole-unit currency). "10.03" +// with frac_digits=2 is 1003 minor units; "10.031" with frac_digits=2 is +// rejected (TooManyFractionDigits), never silently truncated to 1003. + +/// Parse a signed decimal string to exact minor units at the given scale. +[[nodiscard]] Converted parse_decimal_minor(std::string_view text, + int frac_digits) noexcept; + +// ============================================================================= +// Decimal string -> Price / Qty (with tick/lot validation) +// ============================================================================= + +/// "10.03" (+ tick_size, frac_digits) -> Price in ticks, or NotTickMultiple. +/// `tick_size` is minor units per tick (> 0, as guaranteed by a validated +/// instrument). The minor-unit value must be a whole multiple of `tick_size`. +[[nodiscard]] Converted price_from_decimal(std::string_view text, std::int64_t tick_size, + int frac_digits) noexcept; + +/// Convenience: use the instrument's tick_size and price scale. +[[nodiscard]] Converted price_from_decimal(std::string_view text, + const InstrumentConfig& instr, + int frac_digits) noexcept; + +/// Parse a base-unit decimal/integer quantity to Qty in lots. `lot_size` is base +/// units per lot (> 0). With lot_size == 1 and frac_digits == 0 this is a plain +/// integer parse. Non-multiples of the lot are rejected (NotLotMultiple). +[[nodiscard]] Converted qty_from_decimal(std::string_view text, std::int64_t lot_size, + int frac_digits) noexcept; + +// ============================================================================= +// Integer engine values -> decimal strings (reporting boundary) +// ============================================================================= +// +// Formatting is integer division/modulo on the scales — never double formatting. +// "$10.03" printed from 1003 ticks (tick_size 1, 2 dp) is byte-exact. + +/// Format a Price back to a decimal string: ticks * tick_size minor units, +/// rendered with `frac_digits` fractional places. Handles the negative sign and +/// zero-padding of the fractional part. +[[nodiscard]] std::string price_to_decimal(Price price, std::int64_t tick_size, int frac_digits); + +/// Convenience: use the instrument's tick_size. +[[nodiscard]] std::string price_to_decimal(Price price, const InstrumentConfig& instr, + int frac_digits); + +/// Format a Cash amount (already in minor units) with `frac_digits` places. +[[nodiscard]] std::string cash_to_decimal(Cash cash, int frac_digits); + +/// Format a Qty back to a decimal string of base units: lots * lot_size, with +/// `frac_digits` fractional places (0 for whole-lot instruments). +[[nodiscard]] std::string qty_to_decimal(Qty qty, std::int64_t lot_size, int frac_digits); + +} // namespace microsim::core diff --git a/src/core/src/convert.cpp b/src/core/src/convert.cpp new file mode 100644 index 0000000..059bb4e --- /dev/null +++ b/src/core/src/convert.cpp @@ -0,0 +1,210 @@ +#include "microsim/core/convert.hpp" + +#include +#include + +namespace microsim::core { + +const char* to_cstr(ConvertError e) noexcept { + switch (e) { + case ConvertError::Empty: + return "Empty"; + case ConvertError::BadFormat: + return "BadFormat"; + case ConvertError::TooManyFractionDigits: + return "TooManyFractionDigits"; + case ConvertError::Overflow: + return "Overflow"; + case ConvertError::NotTickMultiple: + return "NotTickMultiple"; + case ConvertError::NotLotMultiple: + return "NotLotMultiple"; + case ConvertError::Negative: + return "Negative"; + } + return "?"; +} + +namespace { + +constexpr int kMaxFracDigits = 18; ///< 10^18 < INT64_MAX; 10^19 overflows int64. + +/// 10^n for 0 <= n <= 18, exact in int64. Callers guarantee the bound. +[[nodiscard]] constexpr std::int64_t pow10(int n) noexcept { + std::int64_t r = 1; + for (int i = 0; i < n; ++i) { + r *= 10; + } + return r; +} + +/// Append digit `d` to non-negative accumulator `acc` (acc*10 + d), guarding the +/// int64 range. Returns false on overflow, leaving `acc` unspecified. +[[nodiscard]] constexpr bool push_digit(std::int64_t& acc, int d) noexcept { + constexpr std::int64_t kMax = std::numeric_limits::max(); + if (acc > (kMax - d) / 10) { + return false; + } + acc = acc * 10 + d; + return true; +} + +[[nodiscard]] constexpr bool is_digit(char c) noexcept { + return c >= '0' && c <= '9'; +} + +[[nodiscard]] bool all_space(std::string_view s) noexcept { + for (char c : s) { + if (std::isspace(static_cast(c)) == 0) { + return false; + } + } + return true; +} + +} // namespace + +Converted parse_decimal_minor(std::string_view text, int frac_digits) noexcept { + if (frac_digits < 0 || frac_digits > kMaxFracDigits) { + return {0, ConvertError::BadFormat}; + } + if (text.empty() || all_space(text)) { + return {0, ConvertError::Empty}; + } + + std::size_t i = 0; + bool negative = false; + if (text[i] == '+' || text[i] == '-') { + negative = (text[i] == '-'); + ++i; + } + + // Accumulate all significant digits into one integer, tracking how many landed + // after the decimal point. minor = digits * 10^(frac_digits - seen_frac). + std::int64_t acc = 0; + int seen_frac = -1; // -1 until a '.' is seen; then counts fractional digits. + int total_digits = 0; + + for (; i < text.size(); ++i) { + const char c = text[i]; + if (c == '.') { + if (seen_frac != -1) { + return {0, ConvertError::BadFormat}; // second dot + } + seen_frac = 0; + continue; + } + if (!is_digit(c)) { + return {0, ConvertError::BadFormat}; + } + if (seen_frac != -1) { + if (++seen_frac > frac_digits) { + return {0, ConvertError::TooManyFractionDigits}; + } + } + if (!push_digit(acc, c - '0')) { + return {0, ConvertError::Overflow}; + } + ++total_digits; + } + + if (total_digits == 0) { + return {0, ConvertError::BadFormat}; // "", "+", ".", "-." etc. + } + + // Scale up to the full minor-unit precision: pad the missing fractional digits. + const int have_frac = seen_frac == -1 ? 0 : seen_frac; + const std::int64_t scale = pow10(frac_digits - have_frac); + if (scale != 1) { + constexpr std::int64_t kMax = std::numeric_limits::max(); + if (acc > kMax / scale) { + return {0, ConvertError::Overflow}; + } + acc *= scale; + } + + return {negative ? -acc : acc, std::nullopt}; +} + +Converted price_from_decimal(std::string_view text, std::int64_t tick_size, + int frac_digits) noexcept { + const auto minor = parse_decimal_minor(text, frac_digits); + if (!minor.ok()) { + return {Price{}, minor.error}; + } + if (minor.value < 0) { + return {Price{}, ConvertError::Negative}; + } + if (tick_size <= 0 || minor.value % tick_size != 0) { + return {Price{}, ConvertError::NotTickMultiple}; + } + return {Price{minor.value / tick_size}, std::nullopt}; +} + +Converted price_from_decimal(std::string_view text, const InstrumentConfig& instr, + int frac_digits) noexcept { + return price_from_decimal(text, instr.tick_size, frac_digits); +} + +Converted qty_from_decimal(std::string_view text, std::int64_t lot_size, + int frac_digits) noexcept { + const auto units = parse_decimal_minor(text, frac_digits); + if (!units.ok()) { + return {Qty{}, units.error}; + } + if (units.value < 0) { + return {Qty{}, ConvertError::Negative}; + } + if (lot_size <= 0 || units.value % lot_size != 0) { + return {Qty{}, ConvertError::NotLotMultiple}; + } + return {Qty{units.value / lot_size}, std::nullopt}; +} + +namespace { + +/// Render `minor` (a signed integer at scale 10^frac_digits) as a decimal string. +[[nodiscard]] std::string format_scaled(std::int64_t minor, int frac_digits) { + std::string out; + if (minor < 0) { + out.push_back('-'); + } + // Work in unsigned magnitude to handle INT64_MIN without overflow. + const std::uint64_t mag = + minor < 0 ? (~static_cast(minor) + 1U) : static_cast(minor); + if (frac_digits == 0) { + out += std::to_string(mag); + return out; + } + const auto scale = static_cast(pow10(frac_digits)); + const std::uint64_t whole = mag / scale; + const std::uint64_t frac = mag % scale; + out += std::to_string(whole); + out.push_back('.'); + std::string frac_str = std::to_string(frac); + if (static_cast(frac_str.size()) < frac_digits) { + out.append(static_cast(frac_digits) - frac_str.size(), '0'); + } + out += frac_str; + return out; +} + +} // namespace + +std::string price_to_decimal(Price price, std::int64_t tick_size, int frac_digits) { + return format_scaled(price.ticks() * tick_size, frac_digits); +} + +std::string price_to_decimal(Price price, const InstrumentConfig& instr, int frac_digits) { + return price_to_decimal(price, instr.tick_size, frac_digits); +} + +std::string cash_to_decimal(Cash cash, int frac_digits) { + return format_scaled(cash.minor(), frac_digits); +} + +std::string qty_to_decimal(Qty qty, std::int64_t lot_size, int frac_digits) { + return format_scaled(qty.lots() * lot_size, frac_digits); +} + +} // namespace microsim::core diff --git a/src/engine/CMakeLists.txt b/src/engine/CMakeLists.txt index c466b71..2c11bd0 100644 --- a/src/engine/CMakeLists.txt +++ b/src/engine/CMakeLists.txt @@ -1,3 +1,3 @@ -microsim_add_library(engine src/module_info.cpp) +microsim_add_library(engine src/module_info.cpp src/order_registry.cpp) target_link_libraries(microsim_engine PUBLIC microsim::core microsim::book microsim::accounting microsim::md) diff --git a/src/engine/include/microsim/engine/matching_engine.hpp b/src/engine/include/microsim/engine/matching_engine.hpp new file mode 100644 index 0000000..2697dff --- /dev/null +++ b/src/engine/include/microsim/engine/matching_engine.hpp @@ -0,0 +1,180 @@ +#pragma once + +/// \file +/// The continuous price-time matching engine for new orders (task R1-12). This +/// is a direct translation of MATCHING_ENGINE_SPEC.md (`handle_new`, +/// `match_loop`, `execute_trade`) citing EXCHANGE_RULES.md rule IDs. It is +/// templated on `OrderBookLike` so the identical algorithm runs over the +/// ReferenceBook (the oracle) now and FastBook (R1-19) later — the differential +/// boundary is exactly this template plus the book concept. +/// +/// Scope (R1-12): NewOrder — LIMIT and MARKET, fills, NO_LIQUIDITY cancels, and +/// per-fill fees (R-11.2). Cancel (R1-13), modify (R1-14), risk (R1-15), and +/// session end (R1-16) land in their own tasks. Sequencing headers (R-10.2) are +/// the sequencer's job (R1-11); this returns event payloads in emission order. + +#include +#include +#include + +#include "microsim/book/order_book.hpp" +#include "microsim/core/config.hpp" +#include "microsim/core/events.hpp" +#include "microsim/core/messages.hpp" +#include "microsim/core/types.hpp" +#include "microsim/engine/order_registry.hpp" +#include "microsim/engine/venue.hpp" + +namespace microsim::engine { + +using core::Cash; +using core::LiquidityFlag; +using core::Outbound; +using core::TradeId; + +/// A matching engine over one instrument and one book type. Owns the book and +/// the order registry; validation reference data comes from the Venue. +template + requires book::OrderBookLike +class MatchingEngine { + public: + MatchingEngine(Venue venue, core::InstrumentConfig instrument) + : venue_(std::move(venue)), instr_(std::move(instrument)) {} + + /// Process a NewOrder to completion (R-5.1: atomic, one message at a time), + /// returning every event it produced, in emission order. + std::vector process(const core::NewOrder& m) { + out_.clear(); + + // Gateway: R-3.3 items 1-8, first failure wins. (Risk item 9 is R1-15.) + if (const auto reason = registry_.validate_new(m, venue_)) { + out_.push_back(core::OrderRejected{.participant = m.participant, + .client_order_id = m.client_order_id, + .order_id = core::OrderId{}, + .reason = *reason}); + return std::move(out_); + } + + // Accept: assign order_id (R-4.1) and emit OrderAccepted (R-4.3). + const core::OrderId id = registry_.create(m); + out_.push_back(core::OrderAccepted{ + .order_id = id, .participant = m.participant, .client_order_id = m.client_order_id}); + + match_loop(id); + + const OrderRecord* o = registry_.lookup(id); + if (o->remaining() > core::Qty{0}) { + if (o->type == core::OrderType::Market) { + // R-5.5: a MARKET order never rests; cancel the unfilled remainder. + out_.push_back(core::OrderCanceled{.order_id = id, + .participant = o->participant, + .remaining_qty = o->remaining(), + .reason = core::CancelReason::NoLiquidity}); + registry_.finalize(id, OrderState::Canceled); + } else { + // R-5.6: rest the LIMIT remainder at the back of its price level. + book_.add(book::RestingOrder{.id = id, + .participant = o->participant, + .side = o->side, + .price = o->price, + .remaining = o->remaining()}); + } + } + // Fully filled: apply_fill already moved it to the FILLED terminal (R-4.3). + return std::move(out_); + } + + // ----- read-only views for the CLI, tests, and (later) MD -------------------- + + [[nodiscard]] const Book& book() const noexcept { return book_; } + + [[nodiscard]] const OrderRegistry& registry() const noexcept { return registry_; } + + [[nodiscard]] const core::InstrumentConfig& instrument() const noexcept { return instr_; } + + private: + /// R-5.3/5.4: while the taker has quantity and is marketable, trade the front + /// of the best opposite level at the maker's price. + void match_loop(core::OrderId taker_id) { + OrderRecord* taker = registry_.lookup(taker_id); + while (taker->remaining() > core::Qty{0} && marketable(*taker)) { + const book::RestingOrder* front = book_.front(core::opposite(taker->side)); + // Capture the maker's identity/price before any mutation invalidates it. + const core::OrderId maker_id = front->id; + const core::ParticipantId maker_party = front->participant; + const core::Price px = front->price; // R-5.4: maker's resting price + const core::Qty avail = front->remaining; + const core::Qty q = std::min(taker->remaining(), avail); + + execute_trade(maker_id, maker_party, *taker, px, q); + + if (q == avail) { + book_.remove(maker_id); // maker fully filled (already FILLED in registry) + } else { + book_.reduce(maker_id, avail - q); // maker keeps queue priority + } + } + } + + /// R-5.3/5.5: a MARKET order matches any opposite liquidity; a LIMIT matches + /// while the best opposite price is at or through its limit. + [[nodiscard]] bool marketable(const OrderRecord& o) const { + const auto opp_best = book_.best(core::opposite(o.side)); + if (!opp_best) { + return false; + } + if (o.type == core::OrderType::Market) { + return true; + } + return o.side == core::Side::Buy ? *opp_best <= o.price : *opp_best >= o.price; + } + + /// R-5.7 + R-11.2: record the fill on both orders, compute flat per-lot fees, + /// and emit the two private Fills (maker then taker) followed by the audit + /// Trade — in exactly this order (determinism requirement). + void execute_trade(core::OrderId maker_id, core::ParticipantId maker_party, OrderRecord& taker, + core::Price px, core::Qty q) { + const TradeId trade_id = next_trade_id_; + next_trade_id_ = next_trade_id_.next(); + + registry_.apply_fill(maker_id, q); + registry_.apply_fill(taker.id, q); + + // R-11.2: taker pays qty * taker_fee (positive cost); maker receives + // qty * maker_rebate (negative cost — a credit). + const Cash taker_fee{q.lots() * instr_.fees.taker_fee_per_lot.minor()}; + const Cash maker_fee{-(q.lots() * instr_.fees.maker_rebate_per_lot.minor())}; + + out_.push_back(core::Fill{.order_id = maker_id, + .participant = maker_party, + .trade_id = trade_id, + .price = px, + .qty = q, + .fee = maker_fee, + .liquidity = LiquidityFlag::Maker}); + out_.push_back(core::Fill{.order_id = taker.id, + .participant = taker.participant, + .trade_id = trade_id, + .price = px, + .qty = q, + .fee = taker_fee, + .liquidity = LiquidityFlag::Taker}); + out_.push_back(core::Trade{.trade_id = trade_id, + .price = px, + .qty = q, + .maker_order_id = maker_id, + .taker_order_id = taker.id, + .maker_participant = maker_party, + .taker_participant = taker.participant, + .aggressor = taker.side}); + } + + Venue venue_; + core::InstrumentConfig instr_; + Book book_; + OrderRegistry registry_; + TradeId next_trade_id_{TradeId::first()}; + std::vector out_; +}; + +} // namespace microsim::engine diff --git a/src/engine/include/microsim/engine/order_registry.hpp b/src/engine/include/microsim/engine/order_registry.hpp new file mode 100644 index 0000000..00f69e4 --- /dev/null +++ b/src/engine/include/microsim/engine/order_registry.hpp @@ -0,0 +1,94 @@ +#pragma once + +/// \file +/// Order identity, lifecycle, and gateway validation (task R1-10). This is the +/// authoritative record of every order the exchange has seen: it assigns the +/// strictly-increasing `order_id` (R-4.1), tracks the R-4.2 state machine with +/// absorbing terminal states (INV-7), enforces per-participant client-order-id +/// uniqueness (R-3.3 item 8), and runs the deterministic validation chain +/// (R-3.3 items 1–8, first failure wins). Risk checks (item 9) are R1-15 and +/// live in a separate stage; matching is R1-12. + +#include +#include +#include +#include +#include + +#include "microsim/core/events.hpp" +#include "microsim/core/messages.hpp" +#include "microsim/core/types.hpp" +#include "microsim/engine/venue.hpp" + +namespace microsim::engine { + +using core::ClientOrderId; +using core::InstrumentId; +using core::OrderId; +using core::OrderType; +using core::ParticipantId; +using core::Price; +using core::Qty; +using core::RejectReason; +using core::Side; + +/// R-4.2 states. `Live` covers both in-flight (matching) and resting orders; the +/// terminal states are absorbing — no event may act on a terminal order (INV-7). +enum class OrderState : std::uint8_t { Live = 0, Filled, Canceled }; + +/// The registry's record of one order. `filled_qty` accumulates across trades; +/// `remaining()` is what is still workable. `price` is the limit price (Price{} +/// for a market order, which never rests). +struct OrderRecord { + OrderId id{}; + ParticipantId participant{}; + ClientOrderId client_order_id{}; + InstrumentId instrument{}; + Side side{}; + OrderType type{}; + Price price{}; + Qty total_qty{}; + Qty filled_qty{}; + OrderState state{OrderState::Live}; + + [[nodiscard]] Qty remaining() const noexcept { return total_qty - filled_qty; } + + [[nodiscard]] bool terminal() const noexcept { return state != OrderState::Live; } +}; + +class OrderRegistry { + public: + /// Validate a NewOrder per R-3.3 items 1–8, in that exact order, first failure + /// wins. Returns the reject reason or nullopt if it passes items 1–8. Pure / + /// const: it records nothing (dedup is recorded only on create()). Risk checks + /// (R-3.3 item 9) run after this, in R1-15. + [[nodiscard]] std::optional validate_new(const core::NewOrder& m, + const Venue& venue) const; + + /// Assign the next order_id (R-4.1), record the client-order-id as used, and + /// create a Live record. Precondition: validation (and, later, risk) passed. + OrderId create(const core::NewOrder& m); + + [[nodiscard]] OrderRecord* lookup(OrderId id); + [[nodiscard]] const OrderRecord* lookup(OrderId id) const; + + /// Apply `q` lots of fill to an order (R-4.3): accumulates filled quantity and + /// flips the order to the absorbing FILLED state when nothing remains. + void apply_fill(OrderId id, Qty q); + + /// Move a Live order to an absorbing terminal state (CANCELED by request / + /// no-liquidity / session-end / modify-to-done; FILLED is normally reached via + /// apply_fill). No-op-safe only on Live orders — asserts otherwise (INV-7). + void finalize(OrderId id, OrderState terminal); + + /// Count of orders ever created (order_ids are dense from 1). + [[nodiscard]] std::size_t size() const noexcept { return orders_.size(); } + + private: + OrderId next_id_{OrderId::first()}; + std::unordered_map orders_; + // (participant, client_order_id) pairs seen this session — R-3.3 item 8. + std::set> used_client_ids_; +}; + +} // namespace microsim::engine diff --git a/src/engine/include/microsim/engine/venue.hpp b/src/engine/include/microsim/engine/venue.hpp new file mode 100644 index 0000000..d5adc84 --- /dev/null +++ b/src/engine/include/microsim/engine/venue.hpp @@ -0,0 +1,50 @@ +#pragma once + +/// \file +/// The venue's registered reference data (task R1-10): the instruments and +/// participants that exist for a simulation. Registration happens before the +/// session starts (R-1.1 instruments immutable, R-2.1 participants pre- +/// registered); the gateway validates every inbound message against it (R-3.3 +/// items 1–2). Lookups are by id and never iterated in an order that reaches +/// output, so determinism (R-10.3) is preserved. + +#include + +#include "microsim/core/config.hpp" +#include "microsim/core/types.hpp" + +namespace microsim::engine { + +class Venue { + public: + /// Register an instrument (R-1.1). Its config must already be validated by the + /// caller; the venue stores it verbatim. A duplicate id overwrites. + void add_instrument(const core::InstrumentConfig& instrument) { + instruments_.insert_or_assign(instrument.id, instrument); + } + + /// Register a participant (R-2.1). + void add_participant(const core::ParticipantConfig& participant) { + participants_.insert_or_assign(participant.id, participant); + } + + /// The instrument with this id, or nullptr if none is registered (drives + /// UNKNOWN_INSTRUMENT, R-3.3 item 1). + [[nodiscard]] const core::InstrumentConfig* find_instrument(core::InstrumentId id) const { + auto it = instruments_.find(id); + return it == instruments_.end() ? nullptr : &it->second; + } + + /// The participant with this id, or nullptr if unregistered (drives + /// UNKNOWN_PARTICIPANT, R-3.3 item 2). + [[nodiscard]] const core::ParticipantConfig* find_participant(core::ParticipantId id) const { + auto it = participants_.find(id); + return it == participants_.end() ? nullptr : &it->second; + } + + private: + std::unordered_map instruments_; + std::unordered_map participants_; +}; + +} // namespace microsim::engine diff --git a/src/engine/src/order_registry.cpp b/src/engine/src/order_registry.cpp new file mode 100644 index 0000000..2d5b26b --- /dev/null +++ b/src/engine/src/order_registry.cpp @@ -0,0 +1,101 @@ +#include "microsim/engine/order_registry.hpp" + +#include + +namespace microsim::engine { + +namespace { + +/// R-3.3 item 3: the enum value is one the engine defines. Messages arrive with +/// typed enums, but a fuzzer (or a corrupt log) can forge an out-of-range byte; +/// this is the guard the reason code MALFORMED exists for. +[[nodiscard]] bool valid_side(Side s) noexcept { + return static_cast(s) <= static_cast(Side::Sell); +} + +[[nodiscard]] bool valid_type(OrderType t) noexcept { + return static_cast(t) <= static_cast(OrderType::Market); +} + +} // namespace + +std::optional OrderRegistry::validate_new(const core::NewOrder& m, + const Venue& venue) const { + // R-3.3, applied in this exact order — first failure wins (one reject per + // message, deterministic reason). + const core::InstrumentConfig* instr = venue.find_instrument(m.instrument); + if (instr == nullptr) { + return RejectReason::UnknownInstrument; // item 1 + } + if (venue.find_participant(m.participant) == nullptr) { + return RejectReason::UnknownParticipant; // item 2 + } + if (!valid_side(m.side) || !valid_type(m.type)) { + return RejectReason::Malformed; // item 3 + } + if (m.type == OrderType::Market && m.price != Price{}) { + return RejectReason::PriceOnMarketOrder; // item 4 + } + if (m.qty < Qty{1}) { + return RejectReason::InvalidQty; // item 5 + } + if (m.qty > instr->max_order_qty) { + return RejectReason::OrderTooLarge; // item 6 + } + if (m.type == OrderType::Limit && (m.price < instr->min_price || m.price > instr->max_price)) { + return RejectReason::PriceOutOfBands; // item 7 + } + if (used_client_ids_.contains({m.participant.value(), m.client_order_id.value()})) { + return RejectReason::DuplicateClientOrderId; // item 8 + } + return std::nullopt; +} + +OrderId OrderRegistry::create(const core::NewOrder& m) { + const OrderId id = next_id_; + next_id_ = next_id_.next(); // R-4.1: strictly increasing in arrival order + + orders_.emplace(id, OrderRecord{.id = id, + .participant = m.participant, + .client_order_id = m.client_order_id, + .instrument = m.instrument, + .side = m.side, + .type = m.type, + .price = m.price, + .total_qty = m.qty, + .filled_qty = Qty{0}, + .state = OrderState::Live}); + used_client_ids_.insert({m.participant.value(), m.client_order_id.value()}); + return id; +} + +OrderRecord* OrderRegistry::lookup(OrderId id) { + auto it = orders_.find(id); + return it == orders_.end() ? nullptr : &it->second; +} + +const OrderRecord* OrderRegistry::lookup(OrderId id) const { + auto it = orders_.find(id); + return it == orders_.end() ? nullptr : &it->second; +} + +void OrderRegistry::apply_fill(OrderId id, Qty q) { + OrderRecord* rec = lookup(id); + assert(rec != nullptr && "fill on an unknown order"); + assert(rec->state == OrderState::Live && "fill on a terminal order (INV-7)"); + assert(q > Qty{0} && q <= rec->remaining() && "fill exceeds remaining"); + rec->filled_qty += q; + if (rec->remaining() == Qty{0}) { + rec->state = OrderState::Filled; // R-4.3 implicit FILLED terminal + } +} + +void OrderRegistry::finalize(OrderId id, OrderState terminal) { + OrderRecord* rec = lookup(id); + assert(rec != nullptr && "finalize of an unknown order"); + assert(rec->state == OrderState::Live && "order already terminal (INV-7 absorbing)"); + assert(terminal != OrderState::Live && "finalize must move to a terminal state"); + rec->state = terminal; +} + +} // namespace microsim::engine diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0808787..4b874c2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -22,7 +22,8 @@ function(microsim_add_test module) endfunction() microsim_add_test(core unit/core/test_link_core.cpp unit/core/test_types.cpp - unit/core/test_events.cpp unit/core/test_config.cpp) + unit/core/test_events.cpp unit/core/test_config.cpp + unit/core/test_convert.cpp) # Compile-fail tests: prove the banned strong-type operations # (docs/numerics/NUMERIC_REPRESENTATION.md) do not compile. Each target is @@ -59,12 +60,19 @@ endforeach() add_executable(cf_core_ok_control unit/core/compile_fail/ok_control.cpp) target_link_libraries(cf_core_ok_control PRIVATE microsim::core microsim::warnings) add_test(NAME core.compile_fail.ok_control_runs COMMAND cf_core_ok_control) -microsim_add_test(book unit/book/test_link_book.cpp) +microsim_add_test(book unit/book/test_link_book.cpp unit/book/test_reference_book.cpp) microsim_add_test(sim unit/sim/test_link_sim.cpp) microsim_add_test(accounting unit/accounting/test_link_accounting.cpp) microsim_add_test(md unit/md/test_link_md.cpp) microsim_add_test(persist unit/persist/test_link_persist.cpp) -microsim_add_test(engine unit/engine/test_link_engine.cpp) +microsim_add_test(engine unit/engine/test_link_engine.cpp unit/engine/test_order_registry.cpp + unit/engine/test_matching_engine.cpp) microsim_add_test(agents unit/agents/test_link_agents.cpp) microsim_add_test(strategy unit/strategy/test_link_strategy.cpp) microsim_add_test(metrics unit/metrics/test_link_metrics.cpp) + +# Smoke test for the demo CLI: it must run to completion and produce the known +# scripted-scenario result (a golden-output check on the walking-skeleton demo). +add_test(NAME app.microsim_run.smoke COMMAND microsim_run) +set_tests_properties(app.microsim_run.smoke + PROPERTIES PASS_REGULAR_EXPRESSION "4 trades, 80 lots matched") diff --git a/tests/unit/book/test_reference_book.cpp b/tests/unit/book/test_reference_book.cpp new file mode 100644 index 0000000..297e57c --- /dev/null +++ b/tests/unit/book/test_reference_book.cpp @@ -0,0 +1,178 @@ +#include + +#include + +#include "microsim/book/reference_book.hpp" +#include "microsim/core/types.hpp" + +// R1-09: pins the ReferenceBook oracle against EXCHANGE_RULES.md price-time +// priority (R-5.2), FIFO joins (R-5.6), best/front selection, on-demand depth, +// and the canonical dump_state used by the differential harness (INV-15). Also +// walks the §15 worked-example book state. + +namespace mb = microsim::book; +namespace mc = microsim::core; + +using mc::OrderId; +using mc::ParticipantId; +using mc::Price; +using mc::Qty; +using mc::Side; + +namespace { + +mb::RestingOrder ord(std::uint64_t id, Side side, std::int64_t price, std::int64_t qty, + std::uint32_t party = 1) { + return mb::RestingOrder{.id = OrderId{id}, + .participant = ParticipantId{party}, + .side = side, + .price = Price{price}, + .remaining = Qty{qty}}; +} + +} // namespace + +// ----- price priority: best bid highest, best ask lowest (R-5.2) -------------- + +TEST(ReferenceBook, BestBidIsHighestBestAskIsLowest) { + mb::ReferenceBook b; + b.add(ord(1, Side::Buy, 1000, 5)); + b.add(ord(2, Side::Buy, 1002, 5)); // more aggressive bid + b.add(ord(3, Side::Sell, 1010, 5)); + b.add(ord(4, Side::Sell, 1008, 5)); // more aggressive ask + + EXPECT_EQ(b.best(Side::Buy), std::optional{Price{1002}}); + EXPECT_EQ(b.best(Side::Sell), std::optional{Price{1008}}); + EXPECT_EQ(b.front(Side::Buy)->id, OrderId{2}); + EXPECT_EQ(b.front(Side::Sell)->id, OrderId{4}); +} + +TEST(ReferenceBook, EmptySideHasNoBestOrFront) { + mb::ReferenceBook b; + EXPECT_TRUE(b.empty(Side::Buy)); + EXPECT_TRUE(b.empty(Side::Sell)); + EXPECT_EQ(b.best(Side::Buy), std::nullopt); + EXPECT_EQ(b.front(Side::Sell), nullptr); +} + +// ----- time priority: FIFO within a level (R-5.2 (b), R-5.6) ------------------ + +TEST(ReferenceBook, FifoWithinLevel) { + mb::ReferenceBook b; + b.add(ord(10, Side::Sell, 1005, 7)); + b.add(ord(11, Side::Sell, 1005, 3)); // same price, later -> behind + b.add(ord(12, Side::Sell, 1005, 9)); + + EXPECT_EQ(b.front(Side::Sell)->id, OrderId{10}); + EXPECT_EQ(b.depth(Side::Sell, Price{1005}), Qty{19}); // 7+3+9, summed on demand + + b.remove(OrderId{10}); + EXPECT_EQ(b.front(Side::Sell)->id, OrderId{11}); // next in FIFO +} + +// ----- reduce keeps queue position; remove drops it --------------------------- + +TEST(ReferenceBook, ReduceKeepsPositionRemoveDropsLevel) { + mb::ReferenceBook b; + b.add(ord(20, Side::Buy, 999, 10)); + b.add(ord(21, Side::Buy, 999, 4)); + + b.reduce(OrderId{20}, Qty{6}); // partial fill in place + EXPECT_EQ(b.front(Side::Buy)->id, OrderId{20}); // still ahead + EXPECT_EQ(b.find(OrderId{20})->remaining, Qty{6}); + EXPECT_EQ(b.depth(Side::Buy, Price{999}), Qty{10}); // 6 + 4 + + b.remove(OrderId{20}); + b.remove(OrderId{21}); + EXPECT_TRUE(b.empty(Side::Buy)); // level dropped when emptied + EXPECT_EQ(b.depth(Side::Buy, Price{999}), Qty{0}); + EXPECT_EQ(b.find(OrderId{20}), nullptr); +} + +// ----- find / membership ------------------------------------------------------ + +TEST(ReferenceBook, FindReturnsRestingOrderOrNull) { + mb::ReferenceBook b; + b.add(ord(30, Side::Sell, 1007, 12, /*party=*/9)); + const mb::RestingOrder* o = b.find(OrderId{30}); + ASSERT_NE(o, nullptr); + EXPECT_EQ(o->participant, ParticipantId{9}); + EXPECT_EQ(o->remaining, Qty{12}); + EXPECT_EQ(b.find(OrderId{999}), nullptr); +} + +// ----- dump_state: canonical, best-first, FIFO order (INV-15) ----------------- + +TEST(ReferenceBook, DumpStateIsCanonical) { + mb::ReferenceBook b; + b.add(ord(1, Side::Buy, 1000, 5)); + b.add(ord(2, Side::Buy, 1001, 6)); // better bid -> listed first + b.add(ord(3, Side::Sell, 1005, 7)); + b.add(ord(4, Side::Sell, 1005, 8)); // same level, FIFO after #3 + b.add(ord(5, Side::Sell, 1004, 9)); // better ask -> listed first + + const mb::BookState s = b.dump_state(); + ASSERT_EQ(s.bids.size(), 2u); + EXPECT_EQ(s.bids[0].price, Price{1001}); // best (highest) bid first + EXPECT_EQ(s.bids[1].price, Price{1000}); + + ASSERT_EQ(s.asks.size(), 2u); + EXPECT_EQ(s.asks[0].price, Price{1004}); // best (lowest) ask first + ASSERT_EQ(s.asks[1].orders.size(), 2u); + EXPECT_EQ(s.asks[1].orders[0].id, OrderId{3}); // FIFO within the 1005 level + EXPECT_EQ(s.asks[1].orders[1].id, OrderId{4}); + + // Equal books compare equal; a divergence in any field breaks equality. + mb::ReferenceBook b2; + b2.add(ord(1, Side::Buy, 1000, 5)); + b2.add(ord(2, Side::Buy, 1001, 6)); + b2.add(ord(3, Side::Sell, 1005, 7)); + b2.add(ord(4, Side::Sell, 1005, 8)); + b2.add(ord(5, Side::Sell, 1004, 9)); + EXPECT_EQ(b.dump_state(), b2.dump_state()); + b2.reduce(OrderId{3}, Qty{1}); + EXPECT_NE(b.dump_state(), b2.dump_state()); +} + +// ----- §15 worked-example book: asks C(10),D(15)@10.03, E(40)@10.05 ----------- + +TEST(ReferenceBook, WorkedExampleAskSweepState) { + mb::ReferenceBook b; + // Ask side as in the primer/worked example (prices in cent ticks). + b.add(ord(/*C=*/101, Side::Sell, 1003, 10)); + b.add(ord(/*D=*/102, Side::Sell, 1003, 15)); + b.add(ord(/*E=*/103, Side::Sell, 1005, 40)); + b.add(ord(/*bid*/ 201, Side::Buy, 1001, 5)); + + // Best ask is the 10.03 level; C is first by FIFO; that level holds 25. + EXPECT_EQ(b.best(Side::Sell), std::optional{Price{1003}}); + EXPECT_EQ(b.front(Side::Sell)->id, OrderId{101}); + EXPECT_EQ(b.depth(Side::Sell, Price{1003}), Qty{25}); + + // Simulate a MARKET BUY 60 sweeping the book (matcher lands in R1-12): C and D + // fully fill and are removed; E fills 35 of 40, keeping its queue position + // with 5 remaining (R-5.6 not triggered — E never left the book). + b.remove(OrderId{101}); + b.remove(OrderId{102}); + b.reduce(OrderId{103}, Qty{5}); + + EXPECT_EQ(b.best(Side::Sell), std::optional{Price{1005}}); // 10.03 empty now + EXPECT_EQ(b.depth(Side::Sell, Price{1005}), Qty{5}); + EXPECT_EQ(b.front(Side::Sell)->id, OrderId{103}); + // Post-state R-5.8: best bid 10.01 < best ask 10.05. + EXPECT_LT(*b.best(Side::Buy), *b.best(Side::Sell)); +} + +// ----- for_each_order visits best-to-worst, FIFO within (R-12.3 order) -------- + +TEST(ReferenceBook, ForEachOrderIsSessionEndOrder) { + mb::ReferenceBook b; + b.add(ord(1, Side::Buy, 1000, 5)); + b.add(ord(2, Side::Buy, 1002, 5)); + b.add(ord(3, Side::Buy, 1002, 5)); // same level as #2, later + + std::vector visited; + b.for_each_order(Side::Buy, [&](const mb::RestingOrder& o) { visited.push_back(o.id.value()); }); + // Best price (1002) first, FIFO within it (#2 then #3), then 1000 (#1). + EXPECT_EQ(visited, (std::vector{2, 3, 1})); +} diff --git a/tests/unit/core/test_convert.cpp b/tests/unit/core/test_convert.cpp new file mode 100644 index 0000000..cd4b219 --- /dev/null +++ b/tests/unit/core/test_convert.cpp @@ -0,0 +1,139 @@ +#include +#include + +#include + +#include "microsim/core/config.hpp" +#include "microsim/core/convert.hpp" + +// R1-06: pins the decimal-string <-> integer boundary conversions +// (NUMERIC_REPRESENTATION.md §"Conversion and rounding rules"): exact parsing to +// minor units/ticks/lots, rejection (never rounding) of non-representable and +// non-tick-multiple values, and integer-only formatting back to decimal text. + +namespace mc = microsim::core; + +namespace { + +mc::InstrumentConfig cent_instrument() { + // 1-cent tick, 1-unit lot; prices quoted with 2 fractional digits. + return mc::InstrumentConfig{.id = mc::InstrumentId{1}, + .symbol = "SIM", + .tick_size = 1, + .lot_size = 1, + .min_price = mc::Price{1}, + .max_price = mc::Price{100000}, + .max_order_qty = mc::Qty{1000000}}; +} + +} // namespace + +// ----- parse_decimal_minor: the exact decimal parse --------------------------- + +TEST(ParseDecimalMinor, WholeAndFraction) { + EXPECT_EQ(mc::parse_decimal_minor("10.03", 2).value, 1003); + EXPECT_EQ(mc::parse_decimal_minor("10", 2).value, 1000); // padded to scale + EXPECT_EQ(mc::parse_decimal_minor("0.01", 2).value, 1); + EXPECT_EQ(mc::parse_decimal_minor("10.3", 2).value, 1030); // one dp padded + EXPECT_EQ(mc::parse_decimal_minor("10.", 2).value, 1000); // trailing dot ok + EXPECT_EQ(mc::parse_decimal_minor(".05", 2).value, 5); // leading dot ok +} + +TEST(ParseDecimalMinor, Sign) { + EXPECT_EQ(mc::parse_decimal_minor("-10.03", 2).value, -1003); + EXPECT_EQ(mc::parse_decimal_minor("+10.03", 2).value, 1003); + EXPECT_EQ(mc::parse_decimal_minor("-0.00", 2).value, 0); +} + +TEST(ParseDecimalMinor, ZeroFracDigitsIsIntegerParse) { + EXPECT_EQ(mc::parse_decimal_minor("1000", 0).value, 1000); + EXPECT_EQ(mc::parse_decimal_minor("1000.0", 0).error, mc::ConvertError::TooManyFractionDigits); +} + +TEST(ParseDecimalMinor, TrailingZeroFormsAreEqual) { + EXPECT_EQ(mc::parse_decimal_minor("10.30", 2).value, mc::parse_decimal_minor("10.3", 2).value); + EXPECT_EQ(mc::parse_decimal_minor("10.00", 2).value, mc::parse_decimal_minor("10", 2).value); +} + +TEST(ParseDecimalMinor, RejectsTooMuchPrecision) { + EXPECT_EQ(mc::parse_decimal_minor("10.031", 2).error, mc::ConvertError::TooManyFractionDigits); + EXPECT_EQ(mc::parse_decimal_minor("0.001", 2).error, mc::ConvertError::TooManyFractionDigits); +} + +TEST(ParseDecimalMinor, RejectsBadFormat) { + EXPECT_EQ(mc::parse_decimal_minor("", 2).error, mc::ConvertError::Empty); + EXPECT_EQ(mc::parse_decimal_minor(" ", 2).error, mc::ConvertError::Empty); + EXPECT_EQ(mc::parse_decimal_minor("1.2.3", 2).error, mc::ConvertError::BadFormat); + EXPECT_EQ(mc::parse_decimal_minor("10a", 2).error, mc::ConvertError::BadFormat); + EXPECT_EQ(mc::parse_decimal_minor("1 0", 2).error, mc::ConvertError::BadFormat); + EXPECT_EQ(mc::parse_decimal_minor(".", 2).error, mc::ConvertError::BadFormat); + EXPECT_EQ(mc::parse_decimal_minor("-", 2).error, mc::ConvertError::BadFormat); +} + +TEST(ParseDecimalMinor, RejectsOverflow) { + // 10^18 * 10 fractional-scale would blow int64; a 19-digit integer overflows. + EXPECT_EQ(mc::parse_decimal_minor("9999999999999999999", 0).error, mc::ConvertError::Overflow); +} + +// ----- price_from_decimal: tick-multiple enforcement -------------------------- + +TEST(PriceFromDecimal, ExactTicks) { + mc::InstrumentConfig i = cent_instrument(); + EXPECT_EQ(mc::price_from_decimal("10.03", i, 2).value.ticks(), 1003); + EXPECT_TRUE(mc::price_from_decimal("10.03", i, 2).ok()); +} + +TEST(PriceFromDecimal, NonTickMultipleRejected) { + mc::InstrumentConfig i = cent_instrument(); + i.tick_size = 5; // 5-minor-unit tick (a "nickel" tick) + // 10.03 = 1003 minor units; 1003 % 5 != 0 -> INVALID_TICK, never rounded. + EXPECT_EQ(mc::price_from_decimal("10.03", i, 2).error, mc::ConvertError::NotTickMultiple); + // 10.05 = 1005 minor units; 1005 / 5 = 201 ticks. + EXPECT_EQ(mc::price_from_decimal("10.05", i, 2).value.ticks(), 201); +} + +TEST(PriceFromDecimal, NegativeRejected) { + mc::InstrumentConfig i = cent_instrument(); + EXPECT_EQ(mc::price_from_decimal("-10.03", i, 2).error, mc::ConvertError::Negative); +} + +// ----- qty_from_decimal ------------------------------------------------------- + +TEST(QtyFromDecimal, IntegerLots) { + EXPECT_EQ(mc::qty_from_decimal("250", 1, 0).value.lots(), 250); +} + +TEST(QtyFromDecimal, LotMultipleEnforced) { + // lot_size 100 base units: 250 base units is not a whole lot. + EXPECT_EQ(mc::qty_from_decimal("250", 100, 0).error, mc::ConvertError::NotLotMultiple); + EXPECT_EQ(mc::qty_from_decimal("300", 100, 0).value.lots(), 3); +} + +// ----- formatting round-trips (integer division/modulo, never doubles) -------- + +TEST(PriceToDecimal, PadsFraction) { + mc::InstrumentConfig i = cent_instrument(); + EXPECT_EQ(mc::price_to_decimal(mc::Price{1003}, i, 2), "10.03"); + EXPECT_EQ(mc::price_to_decimal(mc::Price{1000}, i, 2), "10.00"); + EXPECT_EQ(mc::price_to_decimal(mc::Price{5}, i, 2), "0.05"); +} + +TEST(CashToDecimal, Signed) { + EXPECT_EQ(mc::cash_to_decimal(mc::Cash{-1003}, 2), "-10.03"); + EXPECT_EQ(mc::cash_to_decimal(mc::Cash{0}, 2), "0.00"); + EXPECT_EQ(mc::cash_to_decimal(mc::Cash{1234567}, 0), "1234567"); +} + +TEST(Convert, StringToPriceRoundTrips) { + mc::InstrumentConfig i = cent_instrument(); + for (const char* s : {"10.03", "0.01", "999.99", "10.00"}) { + const auto p = mc::price_from_decimal(s, i, 2); + ASSERT_TRUE(p.ok()) << s; + EXPECT_EQ(mc::price_to_decimal(p.value, i, 2), std::string(s)) << s; + } +} + +TEST(QtyToDecimal, ScalesByLot) { + EXPECT_EQ(mc::qty_to_decimal(mc::Qty{3}, 100, 0), "300"); + EXPECT_EQ(mc::qty_to_decimal(mc::Qty{250}, 1, 0), "250"); +} diff --git a/tests/unit/engine/test_matching_engine.cpp b/tests/unit/engine/test_matching_engine.cpp new file mode 100644 index 0000000..8c424a3 --- /dev/null +++ b/tests/unit/engine/test_matching_engine.cpp @@ -0,0 +1,218 @@ +#include +#include + +#include + +#include "microsim/book/reference_book.hpp" +#include "microsim/core/config.hpp" +#include "microsim/core/events.hpp" +#include "microsim/core/messages.hpp" +#include "microsim/core/types.hpp" +#include "microsim/engine/matching_engine.hpp" +#include "microsim/engine/venue.hpp" + +// R1-12: pins new-order matching (R-5.2..5.8, R-11.2) end to end against the +// ReferenceBook, including the EXCHANGE_RULES.md §15 worked example. + +namespace me = microsim::engine; +namespace mc = microsim::core; +namespace mb = microsim::book; + +using mc::ClientOrderId; +using mc::InstrumentId; +using mc::OrderId; +using mc::OrderType; +using mc::ParticipantId; +using mc::Price; +using mc::Qty; +using mc::Side; + +namespace { + +constexpr InstrumentId kInstr{1}; + +// $5.00-$15.00 band, 1-cent tick, taker fee 2c/lot, maker rebate 1c/lot. +mc::InstrumentConfig instrument() { + return mc::InstrumentConfig{ + .id = kInstr, + .symbol = "SIM", + .tick_size = 1, + .lot_size = 1, + .min_price = Price{500}, + .max_price = Price{1500}, + .max_order_qty = Qty{1000}, + .fees = {.taker_fee_per_lot = mc::Cash{2}, .maker_rebate_per_lot = mc::Cash{1}}}; +} + +using Engine = me::MatchingEngine; + +Engine make_engine() { + me::Venue v; + v.add_instrument(instrument()); + v.add_participant(mc::ParticipantConfig{.id = ParticipantId{1}}); // makers + bid + v.add_participant(mc::ParticipantConfig{.id = ParticipantId{9}}); // taker + return Engine{v, instrument()}; +} + +mc::NewOrder limit(ParticipantId p, std::uint64_t clord, Side side, std::int64_t px, + std::int64_t qty) { + return mc::NewOrder{.participant = p, + .client_order_id = ClientOrderId{clord}, + .instrument = kInstr, + .side = side, + .type = OrderType::Limit, + .qty = Qty{qty}, + .price = Price{px}}; +} + +mc::NewOrder market(ParticipantId p, std::uint64_t clord, Side side, std::int64_t qty) { + return mc::NewOrder{.participant = p, + .client_order_id = ClientOrderId{clord}, + .instrument = kInstr, + .side = side, + .type = OrderType::Market, + .qty = Qty{qty}, + .price = Price{}}; +} + +// Count events of a given alternative. +template +std::size_t count(const std::vector& evs) { + std::size_t n = 0; + for (const auto& e : evs) { + n += std::holds_alternative(e); + } + return n; +} + +// Collect all Trade events in order. +std::vector trades(const std::vector& evs) { + std::vector ts; + for (const auto& e : evs) { + if (std::holds_alternative(e)) { + ts.push_back(std::get(e)); + } + } + return ts; +} + +} // namespace + +// ----- resting: a non-marketable limit joins the book, emits only Accepted ---- + +TEST(MatchingEngine, NonMarketableLimitRests) { + Engine eng = make_engine(); + const auto ev = eng.process(limit(ParticipantId{1}, 1, Side::Sell, 1003, 10)); + EXPECT_EQ(ev.size(), 1u); + EXPECT_EQ(count(ev), 1u); + EXPECT_EQ(count(ev), 0u); + EXPECT_EQ(eng.book().best(Side::Sell), std::optional{Price{1003}}); + EXPECT_EQ(eng.book().depth(Side::Sell, Price{1003}), Qty{10}); +} + +// ----- price improvement: execution at the maker's price (R-5.4) -------------- + +TEST(MatchingEngine, ExecutionAtMakerPriceNotAggressorPrice) { + Engine eng = make_engine(); + eng.process(limit(ParticipantId{1}, 1, Side::Sell, 1003, 10)); // maker ask @10.03 + // Aggressive buy limit priced up at 10.10 — marketable, but trades at 10.03. + const auto ev = eng.process(limit(ParticipantId{9}, 1, Side::Buy, 1010, 4)); + const auto ts = trades(ev); + ASSERT_EQ(ts.size(), 1u); + EXPECT_EQ(ts[0].price, Price{1003}); // maker's price, not 1010 + EXPECT_EQ(ts[0].qty, Qty{4}); + EXPECT_EQ(ts[0].aggressor, Side::Buy); +} + +// ----- partial fill then rest the remainder (R-5.6) --------------------------- + +TEST(MatchingEngine, MarketableLimitFillsThenRestsRemainder) { + Engine eng = make_engine(); + eng.process(limit(ParticipantId{1}, 1, Side::Sell, 1003, 10)); // 10 available + const auto ev = eng.process(limit(ParticipantId{9}, 1, Side::Buy, 1003, 25)); + EXPECT_EQ(count(ev), 1u); // took all 10 + EXPECT_EQ(count(ev), 0u); // limit remainder rests, no cancel + EXPECT_EQ(eng.book().best(Side::Sell), std::nullopt); // ask fully consumed + EXPECT_EQ(eng.book().best(Side::Buy), std::optional{Price{1003}}); + EXPECT_EQ(eng.book().depth(Side::Buy, Price{1003}), Qty{15}); // 25 - 10 rests +} + +// ----- market order with no opposite liquidity -> NO_LIQUIDITY (R-5.5) -------- + +TEST(MatchingEngine, MarketOrderNoLiquidityCanceled) { + Engine eng = make_engine(); + const auto ev = eng.process(market(ParticipantId{9}, 1, Side::Buy, 30)); + EXPECT_EQ(count(ev), 1u); + EXPECT_EQ(count(ev), 0u); + ASSERT_EQ(count(ev), 1u); + const auto& c = std::get(ev.back()); + EXPECT_EQ(c.reason, mc::CancelReason::NoLiquidity); + EXPECT_EQ(c.remaining_qty, Qty{30}); +} + +// ----- fees per fill (R-11.2): taker pays, maker is credited ------------------ + +TEST(MatchingEngine, FeesAreFlatPerLotSigned) { + Engine eng = make_engine(); + eng.process(limit(ParticipantId{1}, 1, Side::Sell, 1003, 10)); + const auto ev = eng.process(limit(ParticipantId{9}, 1, Side::Buy, 1003, 10)); + mc::Fill maker_fill{}; + mc::Fill taker_fill{}; + for (const auto& e : ev) { + if (std::holds_alternative(e)) { + const auto& f = std::get(e); + (f.liquidity == mc::LiquidityFlag::Maker ? maker_fill : taker_fill) = f; + } + } + // 10 lots: taker pays 10*2 = 20 (positive cost); maker receives 10*1 = 10 (a + // credit, stored as negative cost). + EXPECT_EQ(taker_fill.fee, mc::Cash{20}); + EXPECT_EQ(maker_fill.fee, mc::Cash{-10}); +} + +// ----- the §15 worked example: MARKET BUY 60 sweeps C(10),D(15)@10.03,E@10.05 - + +TEST(MatchingEngine, WorkedExampleMarketBuySixty) { + Engine eng = make_engine(); + eng.process(limit(ParticipantId{1}, 1, Side::Sell, 1003, 10)); // C -> order 1 + eng.process(limit(ParticipantId{1}, 2, Side::Sell, 1003, 15)); // D -> order 2 + eng.process(limit(ParticipantId{1}, 3, Side::Sell, 1005, 40)); // E -> order 3 + eng.process(limit(ParticipantId{1}, 4, Side::Buy, 1001, 5)); // resting bid -> order 4 + + const auto ev = eng.process(market(ParticipantId{9}, 1, Side::Buy, 60)); // order 5 + const auto ts = trades(ev); + + ASSERT_EQ(ts.size(), 3u); + EXPECT_EQ(ts[0].qty, Qty{10}); + EXPECT_EQ(ts[0].price, Price{1003}); // vs C + EXPECT_EQ(ts[0].maker_order_id, OrderId{1}); + EXPECT_EQ(ts[1].qty, Qty{15}); + EXPECT_EQ(ts[1].price, Price{1003}); // vs D + EXPECT_EQ(ts[1].maker_order_id, OrderId{2}); + EXPECT_EQ(ts[2].qty, Qty{35}); + EXPECT_EQ(ts[2].price, Price{1005}); // vs E, price-improved book walk + EXPECT_EQ(ts[2].maker_order_id, OrderId{3}); + + // Taker filled 60/60 -> no NO_LIQUIDITY cancel. + EXPECT_EQ(count(ev), 0u); + // Post-state (R-5.8): best bid 10.01 < best ask 10.05, E has 5 left. + EXPECT_EQ(eng.book().best(Side::Buy), std::optional{Price{1001}}); + EXPECT_EQ(eng.book().best(Side::Sell), std::optional{Price{1005}}); + EXPECT_EQ(eng.book().depth(Side::Sell, Price{1005}), Qty{5}); + + // Event emission order within the message: Accepted, then per trade + // (Fill maker, Fill taker, Trade) x3 = 1 + 9 = 10 events. + EXPECT_EQ(ev.size(), 10u); + EXPECT_TRUE(std::holds_alternative(ev.front())); +} + +// ----- rejects still produce exactly one OrderRejected ------------------------ + +TEST(MatchingEngine, RejectedOrderEmitsSingleReject) { + Engine eng = make_engine(); + auto bad = limit(ParticipantId{1}, 1, Side::Buy, 400, 10); // price below band + const auto ev = eng.process(bad); + ASSERT_EQ(ev.size(), 1u); + ASSERT_TRUE(std::holds_alternative(ev.front())); + EXPECT_EQ(std::get(ev.front()).reason, mc::RejectReason::PriceOutOfBands); +} diff --git a/tests/unit/engine/test_order_registry.cpp b/tests/unit/engine/test_order_registry.cpp new file mode 100644 index 0000000..596d254 --- /dev/null +++ b/tests/unit/engine/test_order_registry.cpp @@ -0,0 +1,201 @@ +#include + +#include + +#include "microsim/core/config.hpp" +#include "microsim/core/events.hpp" +#include "microsim/core/messages.hpp" +#include "microsim/core/types.hpp" +#include "microsim/engine/order_registry.hpp" +#include "microsim/engine/venue.hpp" + +// R1-10: pins order identity/lifecycle (R-4.1/R-4.2) and the gateway validation +// chain (R-3.3 items 1-8). Validation order is proven by multi-defect messages: +// when two rules would each fail, the earlier one must win. + +namespace me = microsim::engine; +namespace mc = microsim::core; + +using mc::ClientOrderId; +using mc::InstrumentId; +using mc::OrderId; +using mc::OrderType; +using mc::ParticipantId; +using mc::Price; +using mc::Qty; +using mc::RejectReason; +using mc::Side; + +namespace { + +constexpr InstrumentId kInstr{1}; +constexpr ParticipantId kParty{7}; + +// $5.00-$15.00 band, 1-cent tick, max 1000 lots/order. +mc::InstrumentConfig instrument() { + return mc::InstrumentConfig{.id = kInstr, + .symbol = "SIM", + .tick_size = 1, + .lot_size = 1, + .min_price = Price{500}, + .max_price = Price{1500}, + .max_order_qty = Qty{1000}}; +} + +me::Venue venue_with_one_instrument() { + me::Venue v; + v.add_instrument(instrument()); + v.add_participant(mc::ParticipantConfig{.id = kParty}); + return v; +} + +// A valid limit order the tests mutate one field at a time to trip each rule. +mc::NewOrder good_limit(std::uint64_t clord = 1) { + return mc::NewOrder{.participant = kParty, + .client_order_id = ClientOrderId{clord}, + .instrument = kInstr, + .side = Side::Buy, + .type = OrderType::Limit, + .qty = Qty{10}, + .price = Price{1000}}; +} + +} // namespace + +// ----- happy path ------------------------------------------------------------- + +TEST(OrderRegistry, ValidLimitPasses) { + const me::Venue v = venue_with_one_instrument(); + me::OrderRegistry reg; + EXPECT_EQ(reg.validate_new(good_limit(), v), std::nullopt); +} + +// ----- each rule, in isolation ------------------------------------------------ + +TEST(OrderRegistry, EachValidationRuleFires) { + const me::Venue v = venue_with_one_instrument(); + const me::OrderRegistry reg; + + auto bad_instrument = good_limit(); + bad_instrument.instrument = InstrumentId{99}; + EXPECT_EQ(reg.validate_new(bad_instrument, v), RejectReason::UnknownInstrument); + + auto bad_party = good_limit(); + bad_party.participant = ParticipantId{99}; + EXPECT_EQ(reg.validate_new(bad_party, v), RejectReason::UnknownParticipant); + + auto priced_market = good_limit(); + priced_market.type = OrderType::Market; // price 1000 is left set -> illegal + EXPECT_EQ(reg.validate_new(priced_market, v), RejectReason::PriceOnMarketOrder); + + auto zero_qty = good_limit(); + zero_qty.qty = Qty{0}; + EXPECT_EQ(reg.validate_new(zero_qty, v), RejectReason::InvalidQty); + + auto too_big = good_limit(); + too_big.qty = Qty{1001}; + EXPECT_EQ(reg.validate_new(too_big, v), RejectReason::OrderTooLarge); + + auto out_of_band = good_limit(); + out_of_band.price = Price{400}; // below min 500 + EXPECT_EQ(reg.validate_new(out_of_band, v), RejectReason::PriceOutOfBands); + + auto valid_market = good_limit(); + valid_market.type = OrderType::Market; + valid_market.price = Price{}; // market with no price is fine + EXPECT_EQ(reg.validate_new(valid_market, v), std::nullopt); +} + +// ----- ordering: earlier rule wins over a later one (first-failure-wins) ------ + +TEST(OrderRegistry, FirstFailureWinsAcrossMultipleDefects) { + const me::Venue v = venue_with_one_instrument(); + const me::OrderRegistry reg; + + // Unknown instrument AND unknown participant -> item 1 (instrument) wins. + auto m1 = good_limit(); + m1.instrument = InstrumentId{99}; + m1.participant = ParticipantId{99}; + EXPECT_EQ(reg.validate_new(m1, v), RejectReason::UnknownInstrument); + + // Zero qty AND out-of-band price -> item 5 (qty) beats item 7 (price). + auto m2 = good_limit(); + m2.qty = Qty{0}; + m2.price = Price{400}; + EXPECT_EQ(reg.validate_new(m2, v), RejectReason::InvalidQty); + + // Too-large qty AND out-of-band price -> item 6 (size) beats item 7 (price). + auto m3 = good_limit(); + m3.qty = Qty{5000}; + m3.price = Price{99999}; + EXPECT_EQ(reg.validate_new(m3, v), RejectReason::OrderTooLarge); +} + +// ----- client-order-id dedup (item 8), recorded only on create ---------------- + +TEST(OrderRegistry, DuplicateClientOrderIdRejectedAfterCreate) { + const me::Venue v = venue_with_one_instrument(); + me::OrderRegistry reg; + + // Before creation, the id is free. + EXPECT_EQ(reg.validate_new(good_limit(42), v), std::nullopt); + reg.create(good_limit(42)); + // Now the same participant reusing 42 is a duplicate... + EXPECT_EQ(reg.validate_new(good_limit(42), v), RejectReason::DuplicateClientOrderId); + // ...but a different client id is fine. + EXPECT_EQ(reg.validate_new(good_limit(43), v), std::nullopt); + + // A *different* participant may reuse client id 42 (dedup is per participant). + me::Venue v2 = v; + v2.add_participant(mc::ParticipantConfig{.id = ParticipantId{8}}); + auto other = good_limit(42); + other.participant = ParticipantId{8}; + EXPECT_EQ(reg.validate_new(other, v2), std::nullopt); +} + +// ----- identity: order_ids strictly increasing from 1 (R-4.1) ----------------- + +TEST(OrderRegistry, OrderIdsStrictlyIncreasing) { + me::OrderRegistry reg; + const OrderId a = reg.create(good_limit(1)); + const OrderId b = reg.create(good_limit(2)); + const OrderId c = reg.create(good_limit(3)); + EXPECT_EQ(a, OrderId{1}); + EXPECT_EQ(b, OrderId{2}); + EXPECT_EQ(c, OrderId{3}); + EXPECT_LT(a, b); + EXPECT_LT(b, c); +} + +// ----- lifecycle: fills accumulate, FILLED is terminal (R-4.2/R-4.3) ---------- + +TEST(OrderRegistry, FillsAccumulateToFilledTerminal) { + me::OrderRegistry reg; + const OrderId id = reg.create(good_limit()); // qty 10 + me::OrderRecord* r = reg.lookup(id); + ASSERT_NE(r, nullptr); + EXPECT_EQ(r->state, me::OrderState::Live); + EXPECT_EQ(r->remaining(), Qty{10}); + + reg.apply_fill(id, Qty{4}); + EXPECT_EQ(r->remaining(), Qty{6}); + EXPECT_FALSE(r->terminal()); + + reg.apply_fill(id, Qty{6}); // completes the order + EXPECT_EQ(r->remaining(), Qty{0}); + EXPECT_EQ(r->state, me::OrderState::Filled); + EXPECT_TRUE(r->terminal()); +} + +TEST(OrderRegistry, FinalizeCancelsLiveOrder) { + me::OrderRegistry reg; + const OrderId id = reg.create(good_limit()); + reg.finalize(id, me::OrderState::Canceled); + EXPECT_EQ(reg.lookup(id)->state, me::OrderState::Canceled); + EXPECT_TRUE(reg.lookup(id)->terminal()); +} + +TEST(OrderRegistry, LookupUnknownIsNull) { + me::OrderRegistry reg; + EXPECT_EQ(reg.lookup(OrderId{123}), nullptr); +}