diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index d71d9c3..aaed030 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -1 +1 @@ -microsim_add_library(core src/module_info.cpp src/types.cpp) +microsim_add_library(core src/module_info.cpp src/types.cpp src/enums.cpp) diff --git a/src/core/include/microsim/core/events.hpp b/src/core/include/microsim/core/events.hpp new file mode 100644 index 0000000..af1b3d9 --- /dev/null +++ b/src/core/include/microsim/core/events.hpp @@ -0,0 +1,210 @@ +#pragma once + +/// \file +/// Outbound events: everything the exchange emits (task R1-04). Private events +/// go to the owning participant; the audit `Trade` records a match. Definitions +/// follow the Output-events list in MATCHING_ENGINE_SPEC.md and EXCHANGE_RULES.md +/// §4/§5/§11/§14. +/// +/// Public market-data messages (TradeMD, book deltas) are deliberately NOT here +/// — they belong to the `md` module (R2). Serialization of these events is the +/// log format's job (R1-11). Payloads carry no sequencing header; the sequencer +/// pairs each with an EventHeader, keeping payloads trivially copyable and small. + +#include +#include +#include +#include +#include + +#include "microsim/core/types.hpp" + +namespace microsim::core { + +// ============================================================================= +// Reason codes (R-14, complete enumeration) +// ============================================================================= + +/// Why a message was rejected (R-14 rejects). `InvalidTick` originates only at +/// the config/Python boundary (R-3.4), never inside the engine, but is part of +/// the complete enumeration. +enum class RejectReason : std::uint8_t { + UnknownInstrument = 0, + UnknownParticipant, + Malformed, + PriceOnMarketOrder, + InvalidQty, + OrderTooLarge, + PriceOutOfBands, + InvalidTick, + DuplicateClientOrderId, + MaxOpenOrders, + RiskOrderTooLarge, + MaxPosition, + UnknownOrder, + NotOrderOwner, + TooLateToCancel, + TooLateToModify, + MarketClosed, +}; + +/// Why a resting order was canceled (R-14 cancel reasons). `SelfTradePrevented` +/// is Release 2 (R-8.2) but is part of the complete enumeration. +enum class CancelReason : std::uint8_t { + ByRequest = 0, + NoLiquidity, + SelfTradePrevented, + ModifyToDone, + SessionEnd, +}; + +/// Which side of a trade a fill was on (R-5.7 / R-11.2): the maker rests and is +/// paid a rebate; the taker aggresses and pays a fee. +enum class LiquidityFlag : std::uint8_t { Maker = 0, Taker = 1 }; + +[[nodiscard]] const char* to_cstr(RejectReason r) noexcept; +[[nodiscard]] const char* to_cstr(CancelReason r) noexcept; +[[nodiscard]] const char* to_cstr(LiquidityFlag f) noexcept; + +/// Parse back from the canonical name (round-trips with to_cstr). Returns false +/// on an unknown name; used by log readers and tests. +[[nodiscard]] bool from_cstr(std::string_view name, RejectReason& out) noexcept; +[[nodiscard]] bool from_cstr(std::string_view name, CancelReason& out) noexcept; + +/// All enumerators, for exhaustive iteration in tests (kept in sync with the +/// enums by a static_assert on the count). +inline constexpr std::array kAllRejectReasons = { + RejectReason::UnknownInstrument, + RejectReason::UnknownParticipant, + RejectReason::Malformed, + RejectReason::PriceOnMarketOrder, + RejectReason::InvalidQty, + RejectReason::OrderTooLarge, + RejectReason::PriceOutOfBands, + RejectReason::InvalidTick, + RejectReason::DuplicateClientOrderId, + RejectReason::MaxOpenOrders, + RejectReason::RiskOrderTooLarge, + RejectReason::MaxPosition, + RejectReason::UnknownOrder, + RejectReason::NotOrderOwner, + RejectReason::TooLateToCancel, + RejectReason::TooLateToModify, + RejectReason::MarketClosed, +}; + +inline constexpr std::array kAllCancelReasons = { + CancelReason::ByRequest, CancelReason::NoLiquidity, CancelReason::SelfTradePrevented, + CancelReason::ModifyToDone, CancelReason::SessionEnd, +}; + +// ============================================================================= +// Sequencing header (R-10.2) +// ============================================================================= + +/// Assigned to every outbound event by the sequencer (R1-11). Kept separate from +/// the payloads so the payloads stay small and header-agnostic. +struct EventHeader { + Seq seq_out; ///< strictly increasing, gap-free per simulation + Seq seq_in; ///< the triggering inbound message's seq + SimTime ts_event; ///< logical time at processing +}; + +// ============================================================================= +// Event payloads +// ============================================================================= + +/// The order was accepted; `order_id` is now assigned (R-4.1, R-4.3). Echoes the +/// participant and its client id for correlation. +struct OrderAccepted { + OrderId order_id; + ParticipantId participant; + ClientOrderId client_order_id; +}; + +/// A message was rejected with a single reason (R-3.3, R-4.3). For a rejected +/// NewOrder, `order_id` is unset (default) and `client_order_id` correlates; for +/// a rejected cancel/modify, `order_id` is the target and `client_order_id` is +/// unset. +struct OrderRejected { + ParticipantId participant; + ClientOrderId client_order_id; + OrderId order_id; + RejectReason reason; +}; + +/// A resting order was removed with quantity remaining (R-6.2, R-5.5, R-8.2, +/// R-7.3, R-12.3), carrying the reason and the quantity that did not trade. +struct OrderCanceled { + OrderId order_id; + ParticipantId participant; + Qty remaining_qty; + CancelReason reason; +}; + +/// A modify succeeded (R-7.5), emitted before any fills the modify triggers. +struct OrderModified { + OrderId order_id; + ParticipantId participant; + Qty new_qty; + Price new_price; +}; + +/// One counterparty's private view of a trade (R-5.7, R-11.2): the fee charged +/// (taker, positive) or rebate paid (maker, encoded as the signed cash delta), +/// and which side of the book this order was. +struct Fill { + OrderId order_id; + ParticipantId participant; + TradeId trade_id; + Price price; + Qty qty; + Cash fee; ///< signed: taker pays (>0 cost), maker receives (<0 cost) + LiquidityFlag liquidity; +}; + +/// The audit record of a match (R-5.7): full identities and the aggressor side. +/// The anonymized public version (TradeMD) is produced by the `md` module (R2). +struct Trade { + TradeId trade_id; + Price price; + Qty qty; + OrderId maker_order_id; + OrderId taker_order_id; + ParticipantId maker_participant; + ParticipantId taker_participant; + Side aggressor; +}; + +/// Any outbound event. The FILLED-terminal state is implicit (the fill that +/// brings remaining to zero), so there is no separate OrderFilled event (R-4.3). +using Outbound = + std::variant; + +// ----- invariants on the enumerations and payload sizes ----------------------- + +static_assert(kAllRejectReasons.size() == static_cast(RejectReason::MarketClosed) + 1, + "kAllRejectReasons must list every RejectReason"); +static_assert(kAllCancelReasons.size() == static_cast(CancelReason::SessionEnd) + 1, + "kAllCancelReasons must list every CancelReason"); + +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(OrderAccepted) <= 64); +static_assert(sizeof(OrderRejected) <= 64); +static_assert(sizeof(OrderCanceled) <= 64); +static_assert(sizeof(OrderModified) <= 64); +static_assert(sizeof(Fill) <= 64); +static_assert(sizeof(Trade) <= 64); +static_assert(std::is_trivially_copyable_v); + +std::ostream& operator<<(std::ostream& os, RejectReason r); +std::ostream& operator<<(std::ostream& os, CancelReason r); +std::ostream& operator<<(std::ostream& os, LiquidityFlag f); + +} // namespace microsim::core diff --git a/src/core/include/microsim/core/messages.hpp b/src/core/include/microsim/core/messages.hpp new file mode 100644 index 0000000..5145e66 --- /dev/null +++ b/src/core/include/microsim/core/messages.hpp @@ -0,0 +1,74 @@ +#pragma once + +/// \file +/// Inbound messages: everything a participant (or the simulation) can send to +/// the exchange (task R1-04). These are the raw business payloads defined by +/// EXCHANGE_RULES.md §3, §6, §7 and the Input-events list in +/// MATCHING_ENGINE_SPEC.md. The authoritative ordering fields (seq, ts_event) +/// are assigned later by the sequencer (R1-11) and live in EventHeader +/// (events.hpp) — they are not part of the payload, which keeps every message +/// trivially copyable and within the 64-byte budget (MEMORY_MODEL.md). + +#include +#include +#include + +#include "microsim/core/types.hpp" + +namespace microsim::core { + +/// The two supported order types (R-3.1). No others exist in the MVP. +enum class OrderType : std::uint8_t { Limit = 0, Market = 1 }; + +[[nodiscard]] const char* to_cstr(OrderType t) noexcept; + +/// New order (R-3.2). For a MARKET order `price` must be the default Price{} +/// (zero) — the validation chain (R-3.3 item 4) rejects a priced market order. +struct NewOrder { + ParticipantId participant; + ClientOrderId client_order_id; + InstrumentId instrument; + Side side; + OrderType type; + Qty qty; + Price price; ///< meaningful for LIMIT; Price{} for MARKET +}; + +/// Cancel a resting order (R-6.1). `order_id` is the sole key; a participant may +/// cancel only its own orders (enforced by the gateway, R-6.1). +struct CancelOrder { + ParticipantId participant; + OrderId order_id; +}; + +/// Cancel/replace (R-7.1). Both fields are the new *total* values; send the +/// current value to leave one unchanged. Priority effects are the engine's job +/// (R-7.2), not the message's. +struct ModifyOrder { + ParticipantId participant; + OrderId order_id; + Qty new_qty; + Price new_price; +}; + +/// Internal control event closing the trading session (R-12). Carries no +/// fields; the engine cancels all resting orders on receipt (R-12.3). +struct SessionEnd {}; + +/// Any inbound message, as delivered to the exchange. The engine dispatches on +/// the active alternative (MATCHING_ENGINE_SPEC.md top-level dispatch). +using Inbound = std::variant; + +// Each message is a trivially copyable value within the event size budget. +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(NewOrder) <= 64); +static_assert(sizeof(CancelOrder) <= 64); +static_assert(sizeof(ModifyOrder) <= 64); +static_assert(std::is_trivially_copyable_v); + +std::ostream& operator<<(std::ostream& os, OrderType t); + +} // namespace microsim::core diff --git a/src/core/src/enums.cpp b/src/core/src/enums.cpp new file mode 100644 index 0000000..bf8fc2e --- /dev/null +++ b/src/core/src/enums.cpp @@ -0,0 +1,122 @@ +#include "microsim/core/events.hpp" +#include "microsim/core/messages.hpp" + +// String tables for the message/event enums (R1-04). Each to_cstr is a switch +// with no default, so -Wswitch makes adding an enumerator without a name a +// compile error — the exhaustiveness check the task asks for. + +namespace microsim::core { + +const char* to_cstr(OrderType t) noexcept { + switch (t) { + case OrderType::Limit: + return "LIMIT"; + case OrderType::Market: + return "MARKET"; + } + return "?"; +} + +const char* to_cstr(RejectReason r) noexcept { + switch (r) { + case RejectReason::UnknownInstrument: + return "UNKNOWN_INSTRUMENT"; + case RejectReason::UnknownParticipant: + return "UNKNOWN_PARTICIPANT"; + case RejectReason::Malformed: + return "MALFORMED"; + case RejectReason::PriceOnMarketOrder: + return "PRICE_ON_MARKET_ORDER"; + case RejectReason::InvalidQty: + return "INVALID_QTY"; + case RejectReason::OrderTooLarge: + return "ORDER_TOO_LARGE"; + case RejectReason::PriceOutOfBands: + return "PRICE_OUT_OF_BANDS"; + case RejectReason::InvalidTick: + return "INVALID_TICK"; + case RejectReason::DuplicateClientOrderId: + return "DUPLICATE_CLIENT_ORDER_ID"; + case RejectReason::MaxOpenOrders: + return "MAX_OPEN_ORDERS"; + case RejectReason::RiskOrderTooLarge: + return "RISK_ORDER_TOO_LARGE"; + case RejectReason::MaxPosition: + return "MAX_POSITION"; + case RejectReason::UnknownOrder: + return "UNKNOWN_ORDER"; + case RejectReason::NotOrderOwner: + return "NOT_ORDER_OWNER"; + case RejectReason::TooLateToCancel: + return "TOO_LATE_TO_CANCEL"; + case RejectReason::TooLateToModify: + return "TOO_LATE_TO_MODIFY"; + case RejectReason::MarketClosed: + return "MARKET_CLOSED"; + } + return "?"; +} + +const char* to_cstr(CancelReason r) noexcept { + switch (r) { + case CancelReason::ByRequest: + return "BY_REQUEST"; + case CancelReason::NoLiquidity: + return "NO_LIQUIDITY"; + case CancelReason::SelfTradePrevented: + return "SELF_TRADE_PREVENTED"; + case CancelReason::ModifyToDone: + return "MODIFY_TO_DONE"; + case CancelReason::SessionEnd: + return "SESSION_END"; + } + return "?"; +} + +const char* to_cstr(LiquidityFlag f) noexcept { + switch (f) { + case LiquidityFlag::Maker: + return "MAKER"; + case LiquidityFlag::Taker: + return "TAKER"; + } + return "?"; +} + +bool from_cstr(std::string_view name, RejectReason& out) noexcept { + for (RejectReason r : kAllRejectReasons) { + if (name == to_cstr(r)) { + out = r; + return true; + } + } + return false; +} + +bool from_cstr(std::string_view name, CancelReason& out) noexcept { + for (CancelReason r : kAllCancelReasons) { + if (name == to_cstr(r)) { + out = r; + return true; + } + } + return false; +} + +std::ostream& operator<<(std::ostream& os, OrderType t) { + return os << to_cstr(t); +} + +std::ostream& operator<<(std::ostream& os, RejectReason r) { + return os << to_cstr(r); +} + +std::ostream& operator<<(std::ostream& os, CancelReason r) { + return os << to_cstr(r); +} + +std::ostream& operator<<(std::ostream& os, LiquidityFlag f) { + return os << to_cstr(f); +} + +} // namespace microsim::core diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a0e1aee..7db0002 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,10 +14,15 @@ function(microsim_add_test module) add_executable(${target} ${ARGN}) target_link_libraries(${target} PRIVATE microsim::${module} microsim::warnings GTest::gtest_main) - gtest_discover_tests(${target}) + # DISCOVERY_MODE PRE_TEST defers test enumeration from build time to ctest + # time. Build-time discovery (the default) intermittently fails on CI runners + # with "Missing expected JSON file with test list"; PRE_TEST avoids running + # the executables during the build entirely. + gtest_discover_tests(${target} DISCOVERY_MODE PRE_TEST) endfunction() -microsim_add_test(core unit/core/test_link_core.cpp unit/core/test_types.cpp) +microsim_add_test(core unit/core/test_link_core.cpp unit/core/test_types.cpp + unit/core/test_events.cpp) # Compile-fail tests: prove the banned strong-type operations # (docs/numerics/NUMERIC_REPRESENTATION.md) do not compile. Each target is diff --git a/tests/unit/core/test_events.cpp b/tests/unit/core/test_events.cpp new file mode 100644 index 0000000..e011826 --- /dev/null +++ b/tests/unit/core/test_events.cpp @@ -0,0 +1,123 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include "microsim/core/events.hpp" +#include "microsim/core/messages.hpp" + +// R1-04: pins the message/event structs and reason-code enums against +// EXCHANGE_RULES.md §3/§4/§5/§14 and the MATCHING_ENGINE_SPEC I/O lists. Layout +// (trivial-copyability, size budget) is enforced by static_assert in the headers +// and re-stated here; this file covers the enum tables and struct field wiring. + +namespace mc = microsim::core; + +// ----- layout, re-stated so a regression shows up as a failing test ---------- + +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(mc::Trade) <= 64); +static_assert(sizeof(mc::Fill) <= 64); +static_assert(sizeof(mc::NewOrder) <= 64); + +// ----- R-3.2: NewOrder field wiring ------------------------------------------ + +TEST(NewOrder, HoldsAllFields) { + mc::NewOrder o{.participant = mc::ParticipantId{3}, + .client_order_id = mc::ClientOrderId{99}, + .instrument = mc::InstrumentId{1}, + .side = mc::Side::Buy, + .type = mc::OrderType::Limit, + .qty = mc::Qty{25}, + .price = mc::Price{1003}}; + EXPECT_EQ(o.participant.value(), 3u); + EXPECT_EQ(o.client_order_id.value(), 99u); + EXPECT_EQ(o.side, mc::Side::Buy); + EXPECT_EQ(o.type, mc::OrderType::Limit); + EXPECT_EQ(o.qty.lots(), 25); + EXPECT_EQ(o.price.ticks(), 1003); +} + +TEST(NewOrder, MarketOrderPriceIsDefaultZero) { + // R-3.2: a MARKET order has no price; the convention is the default Price{}. + mc::NewOrder o{}; + o.type = mc::OrderType::Market; + EXPECT_EQ(o.price.ticks(), 0); +} + +// ----- Inbound / Outbound variant dispatch ----------------------------------- + +TEST(Inbound, VariantDispatch) { + mc::Inbound m = mc::CancelOrder{.participant = mc::ParticipantId{2}, .order_id = mc::OrderId{7}}; + ASSERT_TRUE(std::holds_alternative(m)); + EXPECT_EQ(std::get(m).order_id.value(), 7u); +} + +TEST(Outbound, CarriesTradeAndFill) { + mc::Outbound e = mc::Fill{.order_id = mc::OrderId{5}, + .participant = mc::ParticipantId{2}, + .trade_id = mc::TradeId{1}, + .price = mc::Price{1003}, + .qty = mc::Qty{10}, + .fee = mc::Cash{20}, + .liquidity = mc::LiquidityFlag::Taker}; + const auto& f = std::get(e); + EXPECT_EQ(f.liquidity, mc::LiquidityFlag::Taker); + EXPECT_EQ(f.fee.minor(), 20); +} + +// ----- R-14: reason-code enum tables ----------------------------------------- + +TEST(RejectReason, EveryValueHasAUniqueName) { + std::set names; + for (mc::RejectReason r : mc::kAllRejectReasons) { + const char* s = mc::to_cstr(r); + ASSERT_NE(s, nullptr); + EXPECT_GT(std::strlen(s), 0u); + EXPECT_STRNE(s, "?") << "unnamed RejectReason"; + EXPECT_TRUE(names.insert(s).second) << "duplicate name: " << s; + } + EXPECT_EQ(names.size(), mc::kAllRejectReasons.size()); +} + +TEST(RejectReason, RoundTripsThroughName) { + for (mc::RejectReason r : mc::kAllRejectReasons) { + mc::RejectReason back{}; + ASSERT_TRUE(mc::from_cstr(mc::to_cstr(r), back)) << mc::to_cstr(r); + EXPECT_EQ(back, r); + } + mc::RejectReason unused{}; + EXPECT_FALSE(mc::from_cstr("NOT_A_REASON", unused)); +} + +TEST(RejectReason, MatchesSpecCanonicalNames) { + // Spot-check the exact strings from EXCHANGE_RULES.md §14. + EXPECT_STREQ(mc::to_cstr(mc::RejectReason::DuplicateClientOrderId), "DUPLICATE_CLIENT_ORDER_ID"); + EXPECT_STREQ(mc::to_cstr(mc::RejectReason::PriceOutOfBands), "PRICE_OUT_OF_BANDS"); + EXPECT_STREQ(mc::to_cstr(mc::RejectReason::MarketClosed), "MARKET_CLOSED"); +} + +TEST(CancelReason, EveryValueHasAUniqueNameAndRoundTrips) { + std::set names; + for (mc::CancelReason r : mc::kAllCancelReasons) { + const char* s = mc::to_cstr(r); + EXPECT_STRNE(s, "?"); + EXPECT_TRUE(names.insert(s).second); + mc::CancelReason back{}; + ASSERT_TRUE(mc::from_cstr(s, back)); + EXPECT_EQ(back, r); + } + EXPECT_EQ(names.size(), mc::kAllCancelReasons.size()); +} + +TEST(OrderTypeAndLiquidity, Names) { + EXPECT_STREQ(mc::to_cstr(mc::OrderType::Limit), "LIMIT"); + EXPECT_STREQ(mc::to_cstr(mc::OrderType::Market), "MARKET"); + EXPECT_STREQ(mc::to_cstr(mc::LiquidityFlag::Maker), "MAKER"); + EXPECT_STREQ(mc::to_cstr(mc::LiquidityFlag::Taker), "TAKER"); +}