From 914d153e4a18ed046c84f9100c6eb70285d22200 Mon Sep 17 00:00:00 2001 From: aravgarg28 Date: Thu, 27 Aug 2026 17:11:41 -0500 Subject: [PATCH 1/4] Add price and quantity conversions between text and integers Turns config/display strings like 10.03 into exact tick and lot counts, and back again. Prices that do not land on a whole tick are rejected, not rounded. 16 tests. --- src/core/CMakeLists.txt | 3 +- src/core/include/microsim/core/convert.hpp | 116 ++++++++++++ src/core/src/convert.cpp | 210 +++++++++++++++++++++ tests/unit/core/test_convert.cpp | 139 ++++++++++++++ 4 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 src/core/include/microsim/core/convert.hpp create mode 100644 src/core/src/convert.cpp create mode 100644 tests/unit/core/test_convert.cpp 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/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"); +} From 807f4d208afd69007876a9b962f256b68a96d971 Mon Sep 17 00:00:00 2001 From: aravgarg28 Date: Thu, 27 Aug 2026 17:11:41 -0500 Subject: [PATCH 2/4] Add the reference order book A simple, obviously-correct order book sorted by best price then arrival time. It is the trusted baseline the faster book will be checked against later. 8 tests including the worked example from the rules doc. --- src/book/CMakeLists.txt | 2 +- src/book/include/microsim/book/order_book.hpp | 85 +++++++++ .../include/microsim/book/reference_book.hpp | 111 +++++++++++ src/book/src/reference_book.cpp | 128 +++++++++++++ tests/unit/book/test_reference_book.cpp | 178 ++++++++++++++++++ 5 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 src/book/include/microsim/book/order_book.hpp create mode 100644 src/book/include/microsim/book/reference_book.hpp create mode 100644 src/book/src/reference_book.cpp create mode 100644 tests/unit/book/test_reference_book.cpp 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/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})); +} From 7e2637a4f52694710b5eccc25472bf14065466bf Mon Sep 17 00:00:00 2001 From: aravgarg28 Date: Thu, 27 Aug 2026 17:11:41 -0500 Subject: [PATCH 3/4] Add order checking, tracking, and matching Checks each order against the rules, gives it an id, tracks its status, and matches buyers with sellers by price then time. Handles limit and market orders, partial fills, price improvement, and fees. 15 tests including a market order that sweeps several price levels. --- src/engine/CMakeLists.txt | 2 +- .../microsim/engine/matching_engine.hpp | 180 +++++++++++++++ .../microsim/engine/order_registry.hpp | 94 ++++++++ src/engine/include/microsim/engine/venue.hpp | 50 ++++ src/engine/src/order_registry.cpp | 101 ++++++++ tests/unit/engine/test_matching_engine.cpp | 218 ++++++++++++++++++ tests/unit/engine/test_order_registry.cpp | 201 ++++++++++++++++ 7 files changed, 845 insertions(+), 1 deletion(-) create mode 100644 src/engine/include/microsim/engine/matching_engine.hpp create mode 100644 src/engine/include/microsim/engine/order_registry.hpp create mode 100644 src/engine/include/microsim/engine/venue.hpp create mode 100644 src/engine/src/order_registry.cpp create mode 100644 tests/unit/engine/test_matching_engine.cpp create mode 100644 tests/unit/engine/test_order_registry.cpp 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/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); +} From 45a009b58b4da01e8fb797eb9cd3a2a83e4e79e7 Mon Sep 17 00:00:00 2001 From: aravgarg28 Date: Thu, 27 Aug 2026 17:11:41 -0500 Subject: [PATCH 4/4] Add a demo program that runs the exchange microsim_run feeds a scripted set of orders through the engine and prints the trades and the final order book in plain dollars. A smoke test checks the result. --- CMakeLists.txt | 2 + apps/CMakeLists.txt | 6 ++ apps/microsim_run.cpp | 191 ++++++++++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 14 +++- 4 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 apps/CMakeLists.txt create mode 100644 apps/microsim_run.cpp 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/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")