Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/core/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
97 changes: 97 additions & 0 deletions src/core/include/microsim/core/config.hpp
Original file line number Diff line number Diff line change
@@ -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 <cstdint>
#include <limits>
#include <optional>
#include <string>

#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<ConfigError> 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<std::int64_t>::max()};
Qty max_order_qty_lots{std::numeric_limits<std::int64_t>::max()};
std::int64_t max_open_orders{std::numeric_limits<std::int64_t>::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<ConfigError> 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<ConfigError> 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
130 changes: 130 additions & 0 deletions src/core/src/config.cpp
Original file line number Diff line number Diff line change
@@ -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<std::int64_t>::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<ConfigError> 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<ConfigError> 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<ConfigError> 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
2 changes: 1 addition & 1 deletion tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading