diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 01d19cd..8f5e7bd 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -10,6 +10,18 @@ Format: ID · Date · Decision · Alternatives considered · Rationale · Status ## Implementation-phase decisions +### D19 — Lean engine-first MVP sequencing +- **Date:** 2026-07-27 +- **Decision:** Drive Release 1's engine core to a runnable, differential-tested single-instrument + exchange with a demo CLI and benchmarks first, deferring the Python bindings (R2), the rigorous + research experiment (R3), and the heavier fuzz/nightly-CI polish (R1-22/R1-24) until after that + milestone. Differential testing against the reference book is kept (it is cheap, high-value + credibility). Later releases then layer on top with nothing discarded. +- **Rationale:** Owner needs a working, demoable, resume-worthy artifact as soon as possible. The + correct benchmarked engine is that artifact; the research layer elevates it but is not required + for a first demo. +- **Status:** LOCKED (owner choice). + ### D18 — macOS toolchain floor raised to AppleClang 16 (Xcode 16); CI runs `macos-15` - **Date:** 2026-07-25 - **Discovered by:** task R1-03 (core strong types) — CI's `macos-14` (Xcode 15.4, libc++ 16) diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index aaed030..7a312c3 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -1 +1 @@ -microsim_add_library(core src/module_info.cpp src/types.cpp src/enums.cpp) +microsim_add_library(core src/module_info.cpp src/types.cpp src/enums.cpp src/config.cpp) diff --git a/src/core/include/microsim/core/config.hpp b/src/core/include/microsim/core/config.hpp new file mode 100644 index 0000000..86f49f8 --- /dev/null +++ b/src/core/include/microsim/core/config.hpp @@ -0,0 +1,97 @@ +#pragma once + +/// \file +/// Instrument, participant, and session configuration, with validation and the +/// exact `Notional` computation (task R1-05). Encodes EXCHANGE_RULES.md §1 +/// (R-1.1/R-1.3), §9 (risk fields), §11 (fees), and the overflow-headroom bound +/// from NUMERIC_REPRESENTATION.md. Config is immutable after construction +/// (R-1.1); validation happens once, at construction, and guarantees the +/// matching path never overflows for in-band prices and valid quantities. + +#include +#include +#include +#include + +#include "microsim/core/types.hpp" + +namespace microsim::core { + +/// What made a configuration invalid. Distinct from RejectReason (which is for +/// runtime messages): these are construction-time errors. +enum class ConfigError : std::uint8_t { + BadTickSize = 0, ///< tick_size <= 0 + BadLotSize, ///< lot_size <= 0 + MinPriceBelowOne, ///< min_price_ticks < 1 (R-1.1) + MaxPriceNotAboveMin, ///< max_price_ticks <= min_price_ticks (R-1.1) + BadMaxOrderQty, ///< max_order_qty_lots < 1 (R-1.1) + NegativeFee, ///< taker fee or maker rebate < 0 (R-11.1) + OverflowHeadroom, ///< worst-case notional lacks 100x int64 headroom + BadRiskLimit, ///< a participant risk limit is negative / inconsistent + BadSessionLength, ///< session length <= 0 + InitialRefOutOfBand, ///< initial reference price outside the instrument band +}; + +[[nodiscard]] const char* to_cstr(ConfigError e) noexcept; + +/// Flat per-lot maker-taker fees (R-11.1). Both are non-negative; the taker pays +/// the fee, the maker receives the rebate. +struct FeeSchedule { + Cash taker_fee_per_lot{0}; + Cash maker_rebate_per_lot{0}; +}; + +/// An instrument definition (R-1.1). `tick_size` and `lot_size` are in minor +/// units; prices/quantities elsewhere are tick/lot counts. +struct InstrumentConfig { + InstrumentId id{}; + std::string symbol; + std::int64_t tick_size{1}; ///< minor units per tick, > 0 + std::int64_t lot_size{1}; ///< units per lot, > 0 + Price min_price{1}; ///< ticks, >= 1 + Price max_price{1}; ///< ticks, > min_price + Qty max_order_qty{1}; ///< lots, >= 1 + FeeSchedule fees{}; + + /// Validates R-1.1/R-1.3/R-11.1 and the per-trade overflow-headroom bound. + [[nodiscard]] std::optional validate() const noexcept; +}; + +/// Pre-trade risk limits for one participant (EXCHANGE_RULES §9). `max_notional` +/// is reserved for a Release-2 check (0 = unused). +struct ParticipantRisk { + Qty max_position_lots{std::numeric_limits::max()}; + Qty max_order_qty_lots{std::numeric_limits::max()}; + std::int64_t max_open_orders{std::numeric_limits::max()}; + Cash max_notional{0}; ///< reserved (R2); 0 = unused +}; + +/// A registered participant (R-2.1) and its risk profile. +struct ParticipantConfig { + ParticipantId id{}; + ParticipantRisk risk{}; + + [[nodiscard]] std::optional validate() const noexcept; +}; + +/// Session parameters (R-12). `max_fills_estimate` bounds the session-aggregate +/// overflow check (a generous default); it is not a hard cap on fills. +struct SessionConfig { + Duration length{Duration{1}}; ///< logical ns, > 0 + Price initial_reference{1}; ///< R-12.4 fallback mark; within band + std::int64_t max_fills_estimate{1'000'000}; + + /// Validates the session against an instrument (band membership, aggregate + /// overflow headroom). + [[nodiscard]] std::optional validate(const InstrumentConfig& instr) const noexcept; +}; + +/// Exact trade notional in cash minor units (R-1.3): +/// `price_ticks * tick_size * qty_lots * lot_size`. Assumes the instrument was +/// validated, so for an in-band price and a valid quantity this cannot overflow. +[[nodiscard]] constexpr Cash Notional(Price price, Qty qty, + const InstrumentConfig& instr) noexcept { + return Cash{price.ticks() * instr.tick_size * qty.lots() * instr.lot_size}; +} + +} // namespace microsim::core diff --git a/src/core/src/config.cpp b/src/core/src/config.cpp new file mode 100644 index 0000000..8d55b07 --- /dev/null +++ b/src/core/src/config.cpp @@ -0,0 +1,130 @@ +#include "microsim/core/config.hpp" + +namespace microsim::core { + +namespace { + +/// The int64 headroom bound: worst-case notional must not exceed this, leaving +/// >= 100x headroom before INT64_MAX (NUMERIC_REPRESENTATION overflow analysis). +constexpr std::int64_t kHeadroomLimit = std::numeric_limits::max() / 100; + +/// True if a*b <= limit for non-negative a, b, limit, computed without +/// overflowing (the division cannot overflow and short-circuits the product). +[[nodiscard]] constexpr bool product_within(std::int64_t a, std::int64_t b, + std::int64_t limit) noexcept { + if (a == 0 || b == 0) { + return true; + } + return a <= limit / b; +} + +/// True if the product of all four non-negative factors stays within `limit`, +/// never overflowing intermediate results. +[[nodiscard]] constexpr bool product4_within(std::int64_t a, std::int64_t b, std::int64_t c, + std::int64_t d, std::int64_t limit) noexcept { + if (!product_within(a, b, limit)) { + return false; + } + const std::int64_t ab = a * b; + if (!product_within(ab, c, limit)) { + return false; + } + const std::int64_t abc = ab * c; + return product_within(abc, d, limit); +} + +} // namespace + +const char* to_cstr(ConfigError e) noexcept { + switch (e) { + case ConfigError::BadTickSize: + return "BAD_TICK_SIZE"; + case ConfigError::BadLotSize: + return "BAD_LOT_SIZE"; + case ConfigError::MinPriceBelowOne: + return "MIN_PRICE_BELOW_ONE"; + case ConfigError::MaxPriceNotAboveMin: + return "MAX_PRICE_NOT_ABOVE_MIN"; + case ConfigError::BadMaxOrderQty: + return "BAD_MAX_ORDER_QTY"; + case ConfigError::NegativeFee: + return "NEGATIVE_FEE"; + case ConfigError::OverflowHeadroom: + return "OVERFLOW_HEADROOM"; + case ConfigError::BadRiskLimit: + return "BAD_RISK_LIMIT"; + case ConfigError::BadSessionLength: + return "BAD_SESSION_LENGTH"; + case ConfigError::InitialRefOutOfBand: + return "INITIAL_REF_OUT_OF_BAND"; + } + return "?"; +} + +std::optional InstrumentConfig::validate() const noexcept { + if (tick_size <= 0) { + return ConfigError::BadTickSize; + } + if (lot_size <= 0) { + return ConfigError::BadLotSize; + } + if (min_price.ticks() < 1) { + return ConfigError::MinPriceBelowOne; + } + if (max_price <= min_price) { + return ConfigError::MaxPriceNotAboveMin; + } + if (max_order_qty.lots() < 1) { + return ConfigError::BadMaxOrderQty; + } + if (fees.taker_fee_per_lot.minor() < 0 || fees.maker_rebate_per_lot.minor() < 0) { + return ConfigError::NegativeFee; + } + // Per-trade worst case: max_price * tick_size * max_order_qty * lot_size. + if (!product4_within(max_price.ticks(), tick_size, max_order_qty.lots(), lot_size, + kHeadroomLimit)) { + return ConfigError::OverflowHeadroom; + } + return std::nullopt; +} + +std::optional ParticipantConfig::validate() const noexcept { + if (risk.max_position_lots.lots() < 0 || risk.max_order_qty_lots.lots() < 1 || + risk.max_open_orders < 0 || risk.max_notional.minor() < 0) { + return ConfigError::BadRiskLimit; + } + return std::nullopt; +} + +std::optional SessionConfig::validate(const InstrumentConfig& instr) const noexcept { + if (length.ns() <= 0) { + return ConfigError::BadSessionLength; + } + if (initial_reference < instr.min_price || initial_reference > instr.max_price) { + return ConfigError::InitialRefOutOfBand; + } + if (max_fills_estimate < 1) { + return ConfigError::BadSessionLength; + } + // Session-aggregate worst case: per-trade worst case * max_fills_estimate. + // Compute the per-trade worst case within the limit first (instrument.validate + // guarantees it fits), then fold in the fill count. + if (!product_within(instr.max_price.ticks(), instr.tick_size, kHeadroomLimit)) { + return ConfigError::OverflowHeadroom; + } + const std::int64_t t1 = instr.max_price.ticks() * instr.tick_size; + if (!product_within(t1, instr.max_order_qty.lots(), kHeadroomLimit)) { + return ConfigError::OverflowHeadroom; + } + const std::int64_t t2 = t1 * instr.max_order_qty.lots(); + if (!product_within(t2, instr.lot_size, kHeadroomLimit)) { + return ConfigError::OverflowHeadroom; + } + const std::int64_t per_trade = t2 * instr.lot_size; + if (!product_within(per_trade, max_fills_estimate, kHeadroomLimit)) { + return ConfigError::OverflowHeadroom; + } + return std::nullopt; +} + +} // namespace microsim::core diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7db0002..0808787 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -22,7 +22,7 @@ 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_events.cpp unit/core/test_config.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_config.cpp b/tests/unit/core/test_config.cpp new file mode 100644 index 0000000..e0a9e1c --- /dev/null +++ b/tests/unit/core/test_config.cpp @@ -0,0 +1,157 @@ +#include +#include +#include + +#include + +#include "microsim/core/config.hpp" + +// R1-05: pins InstrumentConfig/ParticipantConfig/SessionConfig validation +// (EXCHANGE_RULES §1/§9/§11) and the exact Notional computation (R-1.3), plus +// the overflow-headroom bound from NUMERIC_REPRESENTATION.md. + +namespace mc = microsim::core; + +namespace { + +// A valid instrument: $5.00-$15.00 band, 1-cent tick, 1-unit lot, 2c/1c fees. +mc::InstrumentConfig good_instrument() { + return mc::InstrumentConfig{ + .id = mc::InstrumentId{1}, + .symbol = "SIM", + .tick_size = 1, + .lot_size = 1, + .min_price = mc::Price{500}, + .max_price = mc::Price{1500}, + .max_order_qty = mc::Qty{1000}, + .fees = {.taker_fee_per_lot = mc::Cash{2}, .maker_rebate_per_lot = mc::Cash{1}}}; +} + +} // namespace + +// ----- Notional (R-1.3) ------------------------------------------------------- + +TEST(Notional, ExactIntegerProduct) { + mc::InstrumentConfig i = good_instrument(); + // 10 lots @ 1003 ticks, tick=1c, lot=1 → 1003 * 1 * 10 * 1 = 10030 cents. + EXPECT_EQ(mc::Notional(mc::Price{1003}, mc::Qty{10}, i).minor(), 10030); +} + +TEST(Notional, ScalesWithTickAndLot) { + mc::InstrumentConfig i = good_instrument(); + i.tick_size = 5; // 5 minor units/tick + i.lot_size = 100; + // 1200 * 5 * 3 * 100 = 1,800,000 + EXPECT_EQ(mc::Notional(mc::Price{1200}, mc::Qty{3}, i).minor(), 1'800'000); +} + +// ----- InstrumentConfig::validate (R-1.1/R-11.1) ------------------------------ + +TEST(InstrumentValidate, AcceptsGoodConfig) { + EXPECT_EQ(good_instrument().validate(), std::nullopt); +} + +TEST(InstrumentValidate, RejectsEachBadField) { + { + auto i = good_instrument(); + i.tick_size = 0; + EXPECT_EQ(i.validate(), mc::ConfigError::BadTickSize); + } + { + auto i = good_instrument(); + i.lot_size = -1; + EXPECT_EQ(i.validate(), mc::ConfigError::BadLotSize); + } + { + auto i = good_instrument(); + i.min_price = mc::Price{0}; + EXPECT_EQ(i.validate(), mc::ConfigError::MinPriceBelowOne); + } + { + auto i = good_instrument(); + i.max_price = i.min_price; // not strictly above + EXPECT_EQ(i.validate(), mc::ConfigError::MaxPriceNotAboveMin); + } + { + auto i = good_instrument(); + i.max_order_qty = mc::Qty{0}; + EXPECT_EQ(i.validate(), mc::ConfigError::BadMaxOrderQty); + } + { + auto i = good_instrument(); + i.fees.taker_fee_per_lot = mc::Cash{-1}; + EXPECT_EQ(i.validate(), mc::ConfigError::NegativeFee); + } +} + +TEST(InstrumentValidate, OverflowHeadroomBoundary) { + // Choose factors whose product just exceeds INT64_MAX/100 → rejected, and a + // slightly smaller one that is accepted. Use a huge tick_size to trip it. + auto i = good_instrument(); + i.max_price = mc::Price{1'000'000}; + i.max_order_qty = mc::Qty{1'000'000}; + i.tick_size = 1'000'000; + i.lot_size = 1'000'000; // 1e6^4 = 1e24 >> 9.2e16/100 → overflow + EXPECT_EQ(i.validate(), mc::ConfigError::OverflowHeadroom); + + // A modest instrument is comfortably within headroom. + auto j = good_instrument(); + j.max_price = mc::Price{100'000}; + j.max_order_qty = mc::Qty{100'000}; + j.tick_size = 100; + j.lot_size = 1; // 1e5 * 1e2 * 1e5 * 1 = 1e12 < 9.2e14 + EXPECT_EQ(j.validate(), std::nullopt); +} + +// ----- ParticipantConfig::validate (§9) --------------------------------------- + +TEST(ParticipantValidate, AcceptsAndRejects) { + mc::ParticipantConfig p{.id = mc::ParticipantId{1}, + .risk = {.max_position_lots = mc::Qty{500}, + .max_order_qty_lots = mc::Qty{100}, + .max_open_orders = 50, + .max_notional = mc::Cash{0}}}; + EXPECT_EQ(p.validate(), std::nullopt); + + p.risk.max_open_orders = -1; + EXPECT_EQ(p.validate(), mc::ConfigError::BadRiskLimit); + + p.risk.max_open_orders = 50; + p.risk.max_order_qty_lots = mc::Qty{0}; // must be >= 1 + EXPECT_EQ(p.validate(), mc::ConfigError::BadRiskLimit); +} + +// ----- SessionConfig::validate (§12) ------------------------------------------ + +TEST(SessionValidate, BandAndLength) { + auto instr = good_instrument(); + mc::SessionConfig s{.length = mc::Duration{600'000'000'000}, + .initial_reference = mc::Price{1000}, + .max_fills_estimate = 1'000'000}; + EXPECT_EQ(s.validate(instr), std::nullopt); + + s.length = mc::Duration{0}; + EXPECT_EQ(s.validate(instr), mc::ConfigError::BadSessionLength); + + s.length = mc::Duration{1000}; + s.initial_reference = mc::Price{400}; // below band + EXPECT_EQ(s.validate(instr), mc::ConfigError::InitialRefOutOfBand); +} + +TEST(SessionValidate, AggregateOverflowRejected) { + auto instr = good_instrument(); + instr.max_price = mc::Price{1'000'000}; + instr.tick_size = 1'000'000; + instr.max_order_qty = mc::Qty{1'000'000}; // per-trade already large + mc::SessionConfig s{.length = mc::Duration{1000}, + .initial_reference = mc::Price{1'000'000}, + .max_fills_estimate = 1'000'000}; + EXPECT_EQ(s.validate(instr), mc::ConfigError::OverflowHeadroom); +} + +// ----- ConfigError names ------------------------------------------------------ + +TEST(ConfigError, HasNames) { + EXPECT_STREQ(mc::to_cstr(mc::ConfigError::OverflowHeadroom), "OVERFLOW_HEADROOM"); + EXPECT_STREQ(mc::to_cstr(mc::ConfigError::BadTickSize), "BAD_TICK_SIZE"); +}