From 4b00b93de9fdc7a7d46595946b28e118c0ee9fdb Mon Sep 17 00:00:00 2001 From: ReverseZoom2151 Date: Sat, 27 Jun 2026 01:34:30 +0300 Subject: [PATCH 1/5] feat(market): deterministic integer-tick LOB matching engine (M3, WIP) Price-time-priority continuous-double-auction engine: integer price ticks (no float keys), FIFO time priority, process_limit/process_market with partial fills, cancel + priority-preserving modify, canonical-order deterministic batch step, and a depth-ladder/microprice/queue-imbalance snapshot. Golden-hash pinned; 11 tests pass. WIP: the pyo3 PyOrderBook binding + the Python LOB env are pending (the parallel build was interrupted by infra limits); engine is self-contained and validated. --- crates/openoutcry/src/lib.rs | 5 + crates/openoutcry/src/lob_market.rs | 637 ++++++++++++++++++++++++++++ 2 files changed, 642 insertions(+) create mode 100644 crates/openoutcry/src/lob_market.rs diff --git a/crates/openoutcry/src/lib.rs b/crates/openoutcry/src/lib.rs index ad39004..1f7abab 100644 --- a/crates/openoutcry/src/lib.rs +++ b/crates/openoutcry/src/lib.rs @@ -47,6 +47,11 @@ pub use mandate::{mandate_breach, sample_mandate, Mandate, MandateStyle}; pub mod exec_noise; pub use exec_noise::{perturb as perturb_action, ExecNoise}; +// --- Limit-order-book matching engine (M3) ------------------------------------------------- + +pub mod lob_market; +pub use lob_market::{Fill, LadderSnapshot, OrderBook, OrderKind, RestingOrder, Side}; + // --- Endogenous price-impact shared-book market (M2) --------------------------------------- pub mod market; diff --git a/crates/openoutcry/src/lob_market.rs b/crates/openoutcry/src/lob_market.rs new file mode 100644 index 0000000..06d9fa9 --- /dev/null +++ b/crates/openoutcry/src/lob_market.rs @@ -0,0 +1,637 @@ +//! Deterministic continuous-double-auction limit-order book (M3) — price-time priority. +//! +//! The bar-level and endogenous-clearing markets ([`crate::market`]) model price as a +//! scalar that aggregate flow nudges. This module is the microstructure-faithful sibling: +//! a real CDA matching engine with a resting book, FIFO time priority per price level, and +//! partial fills. It is the M3 market surface. +//! +//! ## Determinism is the contract +//! +//! Reference CDA engines key their books on `Decimal`/float prices and break ties with +//! `random.shuffle`; neither is byte-identical across runtimes. Here: +//! +//! - **Prices are integer ticks** (`i64`), so the book keys are exact and order across +//! Rust/WASM/Python is total and identical. `tick_size` is a display scalar only — it +//! never keys the book. +//! - **Time priority is an explicit FIFO** `VecDeque` per price; there is no shuffle. The +//! resting order id is the pre-call `next_order_id`, a monotone counter, so ids are a +//! pure function of the submission sequence. +//! - **The batch [`OrderBook::step`] folds a bar's orders in canonical order** (sorted by +//! agent, then submission index) before touching the book, so however a parallel +//! collector assembles the actions, the matched tape is identical. +//! - The fill tape carries **only integers** (tick, qty, ids, agents); the golden FNV-1a +//! test pins it. Derived observation scalars ([`LadderSnapshot::mid`] / `microprice` / +//! `queue_imbalance`) use only `mul/add/div` over integer inputs — no `ln`/`exp`/`sqrt` +//! — matching the sibling market's cross-runtime arithmetic discipline. +//! +//! ## Leak-free invariant +//! +//! [`OrderBook::step`] consumes a whole bar's orders and matches them against the resting +//! book and each other in canonical order; it never reads a peer's *pending* order out of +//! sequence. A fill price reflects the resting liquidity an aggressor crosses — the +//! price-discovery channel of a real book, not an information leak. + +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, VecDeque}; + +/// Order side. Serialized lowercase for the JSON boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Side { + Buy, + Sell, +} + +/// A resting order on one side of the book at one price level. Its price and side are +/// carried by its location in the book; the struct holds identity, owner, and live size. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RestingOrder { + pub id: u64, + pub agent: usize, + pub qty: u64, +} + +/// One agent order. `Limit`/`Market` carry their own side; `Cancel`/`Modify` reference a +/// resting order by id (side and price are recovered from the book). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum OrderKind { + Limit { + side: Side, + price_tick: i64, + qty: u64, + }, + Market { + side: Side, + qty: u64, + }, + Cancel { + id: u64, + }, + Modify { + id: u64, + new_qty: u64, + }, +} + +/// A single match event: `qty` traded at the resting `price_tick`, between the aggressor +/// (`taker_*`) and the resting order it crossed (`maker_*`). All fields are integers, so +/// the tape is byte-identical across runtimes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Fill { + pub price_tick: i64, + pub qty: u64, + pub maker_id: u64, + pub maker_agent: usize, + pub taker_agent: usize, + pub taker_side: Side, +} + +/// Top-N order-book observation: the bid/ask ladders (`[price_tick, qty]`, best first) and +/// derived microstructure scalars. `mid`, `microprice`, and `queue_imbalance` are in tick +/// units, computed from integer prices/sizes with `mul/add/div` only. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LadderSnapshot { + /// `[price_tick, qty]` per bid level, best (highest) first. + pub bids: Vec<[i64; 2]>, + /// `[price_tick, qty]` per ask level, best (lowest) first. + pub asks: Vec<[i64; 2]>, + /// `(best_bid + best_ask) / 2` in tick units (one-sided or `0.0` when degenerate). + pub mid: f64, + /// Size-weighted mid `(bid*ask_qty + ask*bid_qty) / (bid_qty + ask_qty)`, tick units. + pub microprice: f64, + /// `(bid_qty - ask_qty) / (bid_qty + ask_qty)` over the best levels, in `[-1, 1]`. + pub queue_imbalance: f64, +} + +/// A price-time-priority continuous-double-auction book. `bids`/`asks` are keyed by integer +/// price tick; each level is a FIFO [`VecDeque`] (earliest resting order at the front). +pub struct OrderBook { + bids: BTreeMap>, + asks: BTreeMap>, + tick_size: f64, + next_order_id: u64, +} + +impl OrderBook { + /// An empty book with the given display `tick_size` (price = `price_tick * tick_size`). + pub fn new(tick_size: f64) -> Self { + OrderBook { + bids: BTreeMap::new(), + asks: BTreeMap::new(), + tick_size, + next_order_id: 0, + } + } + + /// The display tick size (price-per-tick); never used to key the book. + pub fn tick_size(&self) -> f64 { + self.tick_size + } + + /// The id the next resting limit will receive — read it before [`Self::process_limit`] + /// to learn the id a (partially or fully resting) order will carry for later + /// cancel/modify. Every limit submission consumes exactly one id. + pub fn next_order_id(&self) -> u64 { + self.next_order_id + } + + /// Best bid price tick (highest), if any. + pub fn best_bid(&self) -> Option { + self.bids.keys().next_back().copied() + } + + /// Best ask price tick (lowest), if any. + pub fn best_ask(&self) -> Option { + self.asks.keys().next().copied() + } + + /// Submit a limit order: cross the opposite side from the best price while the resting + /// price is marketable (asks `<= price_tick` for a buy, bids `>= price_tick` for a + /// sell), generating a [`Fill`] against each resting order touched (decrementing the + /// FIFO head), then rest any remainder at `price_tick`. Consumes one order id. + pub fn process_limit( + &mut self, + side: Side, + price_tick: i64, + qty: u64, + agent: usize, + ) -> Vec { + let id = self.next_order_id; + self.next_order_id += 1; + let (fills, remaining) = self.match_against(side, Some(price_tick), qty, agent); + if remaining > 0 { + let level = match side { + Side::Buy => self.bids.entry(price_tick).or_default(), + Side::Sell => self.asks.entry(price_tick).or_default(), + }; + level.push_back(RestingOrder { + id, + agent, + qty: remaining, + }); + } + fills + } + + /// Submit a market order: cross the opposite side from the best price until filled or + /// the book is empty. Never rests; an empty opposite side is a no-op (empty `Vec`). + pub fn process_market(&mut self, side: Side, qty: u64, agent: usize) -> Vec { + self.match_against(side, None, qty, agent).0 + } + + /// Walk the opposite side from the best price, filling FIFO. `limit` is `Some(tick)` + /// for a limit (stop when the resting price stops crossing) or `None` for a market + /// (cross unconditionally). Returns the fills and the unfilled remainder. + fn match_against( + &mut self, + side: Side, + limit: Option, + qty: u64, + agent: usize, + ) -> (Vec, u64) { + let mut remaining = qty; + let mut fills = Vec::new(); + while remaining > 0 { + let best = match side { + Side::Buy => self.asks.keys().next().copied(), + Side::Sell => self.bids.keys().next_back().copied(), + }; + let Some(price) = best else { break }; + if let Some(lim) = limit { + let crosses = match side { + Side::Buy => price <= lim, + Side::Sell => price >= lim, + }; + if !crosses { + break; + } + } + let book = match side { + Side::Buy => &mut self.asks, + Side::Sell => &mut self.bids, + }; + let level = book.get_mut(&price).unwrap(); + while remaining > 0 { + let Some(head) = level.front_mut() else { break }; + let traded = remaining.min(head.qty); + let maker_id = head.id; + let maker_agent = head.agent; + head.qty -= traded; + remaining -= traded; + if head.qty == 0 { + level.pop_front(); + } + fills.push(Fill { + price_tick: price, + qty: traded, + maker_id, + maker_agent, + taker_agent: agent, + taker_side: side, + }); + } + if level.is_empty() { + book.remove(&price); + } + } + (fills, remaining) + } + + /// Cancel the resting order `id`, freeing its size and preserving the queue position of + /// every other order at its level. Returns whether an order was found and removed. + pub fn cancel_order(&mut self, id: u64) -> bool { + let Some((side, price)) = self.locate(id) else { + return false; + }; + let book = match side { + Side::Buy => &mut self.bids, + Side::Sell => &mut self.asks, + }; + let level = book.get_mut(&price).unwrap(); + let pos = level.iter().position(|o| o.id == id).unwrap(); + level.remove(pos); + if level.is_empty() { + book.remove(&price); + } + true + } + + /// Resize the resting order `id`. A size **decrease** (or no change) keeps its queue + /// position (the real-book rule — you only gave up size); an **increase** loses + /// priority and re-queues at the back of the same price level. `new_qty == 0` cancels. + /// Returns whether an order was found. + pub fn modify_order(&mut self, id: u64, new_qty: u64) -> bool { + if new_qty == 0 { + return self.cancel_order(id); + } + let Some((side, price)) = self.locate(id) else { + return false; + }; + let book = match side { + Side::Buy => &mut self.bids, + Side::Sell => &mut self.asks, + }; + let level = book.get_mut(&price).unwrap(); + let pos = level.iter().position(|o| o.id == id).unwrap(); + if new_qty <= level[pos].qty { + level[pos].qty = new_qty; + } else { + let mut ord = level.remove(pos).unwrap(); + ord.qty = new_qty; + level.push_back(ord); + } + true + } + + /// Find the `(side, price_tick)` of resting order `id`, or `None` if absent. + fn locate(&self, id: u64) -> Option<(Side, i64)> { + for (&price, level) in &self.bids { + if level.iter().any(|o| o.id == id) { + return Some((Side::Buy, price)); + } + } + for (&price, level) in &self.asks { + if level.iter().any(|o| o.id == id) { + return Some((Side::Sell, price)); + } + } + None + } + + /// Process a whole bar's orders in canonical order — sorted by agent, then submission + /// index — so a reordered (e.g. parallel-collected) batch yields the identical tape. + pub fn step(&mut self, orders: &[(usize, OrderKind)]) -> Vec { + let mut idx: Vec = (0..orders.len()).collect(); + idx.sort_by_key(|&i| (orders[i].0, i)); + let mut fills = Vec::new(); + for i in idx { + let (agent, kind) = orders[i]; + match kind { + OrderKind::Limit { + side, + price_tick, + qty, + } => fills.extend(self.process_limit(side, price_tick, qty, agent)), + OrderKind::Market { side, qty } => { + fills.extend(self.process_market(side, qty, agent)) + } + OrderKind::Cancel { id } => { + self.cancel_order(id); + } + OrderKind::Modify { id, new_qty } => { + self.modify_order(id, new_qty); + } + } + } + fills + } + + /// Top-`levels` ladder + derived microstructure scalars (the book observation). Bids + /// are highest-first, asks lowest-first; per-level qty sums the FIFO at that price. + pub fn depth_ladder(&self, levels: usize) -> LadderSnapshot { + let bids: Vec<[i64; 2]> = self + .bids + .iter() + .rev() + .take(levels) + .map(|(&p, q)| [p, level_qty(q) as i64]) + .collect(); + let asks: Vec<[i64; 2]> = self + .asks + .iter() + .take(levels) + .map(|(&p, q)| [p, level_qty(q) as i64]) + .collect(); + + let best_bid = self.bids.iter().next_back(); + let best_ask = self.asks.iter().next(); + let (mid, microprice, queue_imbalance) = match (best_bid, best_ask) { + (Some((&bp, bq)), Some((&ap, aq))) => { + let bqty = level_qty(bq) as f64; + let aqty = level_qty(aq) as f64; + let total = bqty + aqty; + let mid = (bp + ap) as f64 / 2.0; + let micro = (bp as f64 * aqty + ap as f64 * bqty) / total; + let imb = (bqty - aqty) / total; + (mid, micro, imb) + } + (Some((&bp, _)), None) => (bp as f64, bp as f64, 1.0), + (None, Some((&ap, _))) => (ap as f64, ap as f64, -1.0), + (None, None) => (0.0, 0.0, 0.0), + }; + LadderSnapshot { + bids, + asks, + mid, + microprice, + queue_imbalance, + } + } +} + +/// Total resting size at one price level (sum of the FIFO's order qtys). +fn level_qty(level: &VecDeque) -> u64 { + level.iter().map(|o| o.qty).sum() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Dependency-free FNV-1a/64 — the same fingerprint [`crate::scenario_gen`] uses to pin + /// cross-runtime serialization determinism without adding a hash crate. + fn fnv1a(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h + } + + /// Golden fingerprint of a fixed scripted order sequence's resulting fill tape. The tape + /// is all integers, so this value must reproduce on any runtime (the wasm/python crates + /// can assert the same number). + const GOLDEN_TAPE_FNV1A: u64 = 0x8bbc_a7c3_2cea_d625; + + #[test] + fn time_priority_earlier_order_fills_first() { + let mut b = OrderBook::new(1.0); + let a_id = b.next_order_id(); + b.process_limit(Side::Sell, 100, 5, 0); + let _b_id = b.next_order_id(); + b.process_limit(Side::Sell, 100, 5, 1); + // A buy that takes 1 share hits the earliest resting ask (agent 0). + let fills = b.process_limit(Side::Buy, 100, 1, 2); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].maker_id, a_id); + assert_eq!(fills[0].maker_agent, 0); + } + + #[test] + fn partial_fill_decrements_the_head() { + let mut b = OrderBook::new(1.0); + let a_id = b.next_order_id(); + b.process_limit(Side::Sell, 100, 10, 0); + let fills = b.process_market(Side::Buy, 4, 1); + assert_eq!(fills, vec![single(100, 4, a_id, 0, 1, Side::Buy)]); + // 6 left at the head; the next taker keeps hitting the same maker. + let more = b.process_market(Side::Buy, 6, 2); + assert_eq!(more, vec![single(100, 6, a_id, 0, 2, Side::Buy)]); + assert!(b.best_ask().is_none()); + } + + #[test] + fn crossing_limit_matches_then_rests_remainder() { + let mut b = OrderBook::new(1.0); + b.process_limit(Side::Sell, 100, 3, 0); + // Buy 8 @ 100: 3 cross the resting ask, 5 rest as the new best bid. + let rest_id = b.next_order_id(); + let fills = b.process_limit(Side::Buy, 100, 8, 1); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].qty, 3); + assert_eq!(b.best_bid(), Some(100)); + assert!(b.best_ask().is_none()); + let ladder = b.depth_ladder(1); + assert_eq!(ladder.bids, vec![[100, 5]]); + // The resting remainder carries the id we previewed. + assert!(b.cancel_order(rest_id)); + assert!(b.best_bid().is_none()); + } + + #[test] + fn market_order_walks_multiple_levels() { + let mut b = OrderBook::new(1.0); + b.process_limit(Side::Sell, 100, 2, 0); + b.process_limit(Side::Sell, 101, 2, 1); + b.process_limit(Side::Sell, 102, 2, 2); + let fills = b.process_market(Side::Buy, 5, 9); + let prices: Vec = fills.iter().map(|f| f.price_tick).collect(); + let qtys: Vec = fills.iter().map(|f| f.qty).collect(); + assert_eq!(prices, vec![100, 101, 102]); + assert_eq!(qtys, vec![2, 2, 1]); + // One share left at 102. + assert_eq!(b.depth_ladder(3).asks, vec![[102, 1]]); + } + + #[test] + fn cancel_removes_and_frees_the_level() { + let mut b = OrderBook::new(1.0); + let id = b.next_order_id(); + b.process_limit(Side::Buy, 99, 7, 0); + assert!(b.cancel_order(id)); + assert!(b.best_bid().is_none()); + // A market sell now finds nothing to hit. + assert!(b.process_market(Side::Sell, 1, 1).is_empty()); + // Cancelling a stale id is a no-op. + assert!(!b.cancel_order(id)); + } + + #[test] + fn modify_decrease_keeps_increase_loses_priority() { + let mut b = OrderBook::new(1.0); + let a_id = b.next_order_id(); + b.process_limit(Side::Buy, 100, 10, 0); + let b_id = b.next_order_id(); + b.process_limit(Side::Buy, 100, 10, 1); + // Decrease A: it keeps the front of the queue. + assert!(b.modify_order(a_id, 5)); + assert_eq!(b.process_market(Side::Sell, 1, 9)[0].maker_id, a_id); + // Increase A: it loses priority and re-queues behind B. + assert!(b.modify_order(a_id, 8)); + assert_eq!(b.process_market(Side::Sell, 1, 9)[0].maker_id, b_id); + } + + #[test] + fn step_is_canonical_order_deterministic() { + let seed_book = || { + let mut b = OrderBook::new(1.0); + b.process_limit(Side::Sell, 105, 100, 7); + b.process_limit(Side::Buy, 95, 100, 7); + b + }; + let batch: Vec<(usize, OrderKind)> = vec![ + ( + 0, + OrderKind::Limit { + side: Side::Buy, + price_tick: 105, + qty: 5, + }, + ), + ( + 1, + OrderKind::Limit { + side: Side::Sell, + price_tick: 95, + qty: 7, + }, + ), + ( + 2, + OrderKind::Limit { + side: Side::Buy, + price_tick: 106, + qty: 3, + }, + ), + ]; + let mut reversed = batch.clone(); + reversed.reverse(); + + let mut forward = seed_book(); + let mut backward = seed_book(); + let f1 = forward.step(&batch); + let f2 = backward.step(&reversed); + assert_eq!( + serde_json::to_string(&f1).unwrap(), + serde_json::to_string(&f2).unwrap(), + "a re-sorted batch must yield the identical tape" + ); + assert!(!f1.is_empty()); + } + + #[test] + fn empty_book_market_order_is_a_noop() { + let mut b = OrderBook::new(1.0); + assert!(b.process_market(Side::Buy, 10, 0).is_empty()); + assert!(b.process_market(Side::Sell, 10, 0).is_empty()); + let ladder = b.depth_ladder(5); + assert!(ladder.bids.is_empty() && ladder.asks.is_empty()); + assert_eq!(ladder.mid, 0.0); + assert_eq!(ladder.microprice, 0.0); + assert_eq!(ladder.queue_imbalance, 0.0); + } + + #[test] + fn ladder_derives_mid_microprice_and_imbalance() { + let mut b = OrderBook::new(1.0); + b.process_limit(Side::Buy, 100, 6, 0); + b.process_limit(Side::Sell, 102, 2, 1); + let l = b.depth_ladder(1); + assert_eq!(l.bids, vec![[100, 6]]); + assert_eq!(l.asks, vec![[102, 2]]); + assert_eq!(l.mid, 101.0); + // Size-weighted toward the larger (bid) side, i.e. above the mid. + assert_eq!(l.microprice, (100.0 * 2.0 + 102.0 * 6.0) / 8.0); + assert_eq!(l.queue_imbalance, (6.0 - 2.0) / 8.0); + } + + /// A single-fill convenience for the assertions above. + fn single( + price_tick: i64, + qty: u64, + maker_id: u64, + maker_agent: usize, + taker_agent: usize, + taker_side: Side, + ) -> Fill { + Fill { + price_tick, + qty, + maker_id, + maker_agent, + taker_agent, + taker_side, + } + } + + /// A fixed scripted sequence exercising rest / cross / market / cancel / modify, whose + /// resulting tape pins the golden hash. + fn scripted_tape() -> Vec { + let mut b = OrderBook::new(0.01); + let mut tape = Vec::new(); + let batch: Vec<(usize, OrderKind)> = vec![ + ( + 2, + OrderKind::Limit { + side: Side::Sell, + price_tick: 102, + qty: 4, + }, + ), + ( + 0, + OrderKind::Limit { + side: Side::Sell, + price_tick: 101, + qty: 5, + }, + ), + ( + 1, + OrderKind::Limit { + side: Side::Sell, + price_tick: 101, + qty: 3, + }, + ), + ( + 3, + OrderKind::Limit { + side: Side::Buy, + price_tick: 100, + qty: 6, + }, + ), + ]; + tape.extend(b.step(&batch)); + let modify_id = b.next_order_id(); + b.process_limit(Side::Buy, 100, 4, 5); + b.modify_order(modify_id, 2); + tape.extend(b.process_limit(Side::Buy, 102, 10, 6)); + tape.extend(b.process_market(Side::Sell, 7, 7)); + tape + } + + #[test] + fn golden_tape_hash_is_stable() { + let json = serde_json::to_string(&scripted_tape()).unwrap(); + assert_eq!(fnv1a(json.as_bytes()), GOLDEN_TAPE_FNV1A); + } + + #[test] + fn scripted_tape_is_reproducible() { + assert_eq!(scripted_tape(), scripted_tape()); + } +} From 15ede2ae489299d5315cb71583c6d922ac2ab7ed Mon Sep 17 00:00:00 2001 From: ReverseZoom2151 Date: Sat, 27 Jun 2026 01:41:48 +0300 Subject: [PATCH 2/5] feat(market): M3 limit-order-book surface (PyOrderBook + LOBMarketEnv) pyo3 PyOrderBook wraps the deterministic CDA engine (reset_book/step_book over a flat order JSON, fills + depth-ladder out). LOBMarketEnv is a PettingZoo ParallelEnv where N market makers quote into one shared book against a seeded noise trader; per-agent depth-ladder obs + mark-to-mid-minus-inventory reward, leak-free (book clears before the next obs). Passes parallel_api_test. Completes M3 (engine committed in 4b00b93). --- .../python/openoutcry/__init__.py | 4 + .../python/openoutcry/lob_env.py | 234 ++++++++++++++++++ crates/openoutcry-py/src/lib.rs | 95 +++++++ crates/openoutcry-py/tests/test_lob_env.py | 104 ++++++++ 4 files changed, 437 insertions(+) create mode 100644 crates/openoutcry-py/python/openoutcry/lob_env.py create mode 100644 crates/openoutcry-py/tests/test_lob_env.py diff --git a/crates/openoutcry-py/python/openoutcry/__init__.py b/crates/openoutcry-py/python/openoutcry/__init__.py index cb22c24..f0b65a4 100644 --- a/crates/openoutcry-py/python/openoutcry/__init__.py +++ b/crates/openoutcry-py/python/openoutcry/__init__.py @@ -93,6 +93,7 @@ make_block_env, ) from .cascade import LiquidationCascadeEnv, cascade_survived, cascade_summary +from .lob_env import LOBMarketEnv, symmetric_quote_policy, noise_trader_policy from .reward_misspecification import ( MISSPECIFIED_REWARDS, MISSPECIFIED_PROXY_POLICIES, @@ -237,6 +238,9 @@ "LiquidationCascadeEnv", "cascade_survived", "cascade_summary", + "LOBMarketEnv", + "symmetric_quote_policy", + "noise_trader_policy", "MISSPECIFIED_REWARDS", "MISSPECIFIED_PROXY_POLICIES", "misspecification_gap", diff --git a/crates/openoutcry-py/python/openoutcry/lob_env.py b/crates/openoutcry-py/python/openoutcry/lob_env.py new file mode 100644 index 0000000..4a2f38a --- /dev/null +++ b/crates/openoutcry-py/python/openoutcry/lob_env.py @@ -0,0 +1,234 @@ +"""A multi-agent limit-order-book market over the native M3 matching engine. + +:class:`LOBMarketEnv` is a PettingZoo ``ParallelEnv`` where ``n_agents`` market makers +post bid/ask quotes into one shared, deterministic integer-tick order book (the native +``PyOrderBook``: price-time priority, real fills). A seeded noise trader sends market +orders each step so quotes actually fill. Distinct from the bar-level position env and the +M2 endogenous (batch-clearing) market: here orders match against a real resting book. + +**Leak-free.** An agent's observation is the post-step public depth ladder plus its own +inventory/cash; it never sees other agents' pending same-step orders (all quotes are +collected, then the book clears, then the next observation is produced). +""" + +from __future__ import annotations + +import json +from typing import Any, Optional + +import numpy as np + +try: # pragma: no cover - exercised only when pettingzoo is installed + from pettingzoo import ParallelEnv + + _HAS_PZ = True +except Exception: # noqa: BLE001 + ParallelEnv = object # type: ignore[assignment,misc] + _HAS_PZ = False + +from .openoutcry_py import PyOrderBook + +_MID_TICK = 1000 # the reference mid starts here (in ticks) + + +def _splitmix(state: int) -> tuple[int, float]: + """One SplitMix64 draw -> (new_state, unit in [0, 1)); deterministic, no numpy RNG.""" + state = (state + 0x9E3779B97F4A7C15) & 0xFFFFFFFFFFFFFFFF + z = state + z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & 0xFFFFFFFFFFFFFFFF + z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & 0xFFFFFFFFFFFFFFFF + z ^= z >> 31 + return state, (z >> 11) / float(1 << 53) + + +class LOBMarketEnv(ParallelEnv): # type: ignore[misc] + """N market makers quoting into one shared limit-order book. + + Each agent's action is a 2-vector ``[bid_offset, ask_offset]`` of ticks from the + reference mid (clamped to ``[1, max_offset]``); it posts a buy at ``mid - bid_offset`` + and a sell at ``mid + ask_offset``, each of size ``quote_qty``. A seeded noise trader + then sends a market order, the book clears, and reward is the change in mark-to-mid + equity minus a squared-inventory penalty. + """ + + metadata = {"render_modes": [], "name": "openoutcry_lob_v0"} + + def __init__( + self, + n_agents: int = 2, + *, + n_steps: int = 120, + seed: int = 0, + tick_size: float = 0.01, + levels: int = 5, + quote_qty: int = 10, + max_offset: int = 20, + inventory_penalty: float = 0.001, + noise_intensity: float = 2.0, + ) -> None: + if not _HAS_PZ: + raise RuntimeError( + "pettingzoo is not installed. Install the 'pettingzoo' extra to use " + "LOBMarketEnv; the rest of the openoutcry package works without it." + ) + if n_agents < 1: + raise ValueError("n_agents must be >= 1") + self._n_agents = int(n_agents) + self._n_steps = int(n_steps) + self._seed = int(seed) + self._tick_size = float(tick_size) + self._levels = int(levels) + self._quote_qty = int(quote_qty) + self._max_offset = int(max_offset) + self._inv_pen = float(inventory_penalty) + self._noise = float(noise_intensity) + self.possible_agents = [f"agent_{i}" for i in range(self._n_agents)] + + from gymnasium import spaces + + self._obs_dim = 4 * self._levels + 5 # ladder (bids+asks) + mid/micro/imb + inv/cash + self._obs_space = spaces.Box(-np.inf, np.inf, shape=(self._obs_dim,), dtype=np.float64) + self._act_space = spaces.Box( + low=1.0, high=float(self._max_offset), shape=(2,), dtype=np.float32 + ) + + # -- PettingZoo API ---------------------------------------------------- + + def observation_space(self, agent): # noqa: D401 + return self._obs_space + + def action_space(self, agent): # noqa: D401 + return self._act_space + + def reset(self, seed: Optional[int] = None, options: Optional[dict] = None): + if seed is not None: + self._seed = int(seed) + self.agents = list(self.possible_agents) + self._book = PyOrderBook(tick_size=self._tick_size, levels=self._levels) + self._book.reset_book() + self._mid = _MID_TICK + self._rng = self._seed ^ 0x1234_5678_9ABC_DEF0 + self._step = 0 + self._inventory = {a: 0 for a in self.agents} + self._cash = {a: 0.0 for a in self.agents} + ladder = json.loads(self._book.ladder()) + obs = {a: self._obs(a, ladder) for a in self.agents} + infos = {a: {} for a in self.agents} + return obs, infos + + def step(self, actions: dict): + # 1. every live agent posts a two-sided quote (collected before any clear). + orders: list[dict] = [] + for i, a in enumerate(self.possible_agents): + if a not in actions: + continue + bid_off, ask_off = (int(round(float(x))) for x in np.asarray(actions[a]).reshape(-1)[:2]) + bid_off = max(1, min(self._max_offset, bid_off)) + ask_off = max(1, min(self._max_offset, ask_off)) + orders.append({"agent": i, "kind": "limit", "side": "buy", + "price_tick": self._mid - bid_off, "qty": self._quote_qty}) + orders.append({"agent": i, "kind": "limit", "side": "sell", + "price_tick": self._mid + ask_off, "qty": self._quote_qty}) + + # 2. a seeded noise trader sends one market order (agent id n_agents = exogenous). + self._rng, u = _splitmix(self._rng) + if u < 0.5 + 0.1 * self._noise: + self._rng, u2 = _splitmix(self._rng) + side = "buy" if u2 < 0.5 else "sell" + self._rng, u3 = _splitmix(self._rng) + qty = 1 + int(u3 * self._noise * self._quote_qty) + orders.append({"agent": self._n_agents, "kind": "market", "side": side, "qty": qty}) + + out = json.loads(self._book.step_book(json.dumps(orders))) + ladder = out["ladder"] + self._apply_fills(out["fills"], ladder) + self._mid = self._next_mid(ladder) + self._step += 1 + + done = self._step >= self._n_steps + obs, rewards, terms, truncs, infos = {}, {}, {}, {}, {} + for a in self.agents: + obs[a] = self._obs(a, ladder) + rewards[a] = self._reward(a, ladder) + terms[a] = False + truncs[a] = done + infos[a] = {"inventory": self._inventory[a], "cash": self._cash[a]} + if done: + self.agents = [] + return obs, rewards, terms, truncs, infos + + # -- internals --------------------------------------------------------- + + def _apply_fills(self, fills, ladder) -> None: + mid = ladder["mid"] or float(self._mid) + for f in fills: + price = f["price_tick"] + qty = f["qty"] + maker = self.possible_agents[f["maker_agent"]] if f["maker_agent"] < self._n_agents else None + taker = self.possible_agents[f["taker_agent"]] if f["taker_agent"] < self._n_agents else None + # maker side is the opposite of the taker side. + if maker is not None: + if f["taker_side"] == "buy": # maker sold + self._inventory[maker] -= qty + self._cash[maker] += price * qty + else: + self._inventory[maker] += qty + self._cash[maker] -= price * qty + if taker is not None: + if f["taker_side"] == "buy": + self._inventory[taker] += qty + self._cash[taker] -= price * qty + else: + self._inventory[taker] -= qty + self._cash[taker] += price * qty + + def _equity(self, agent: str, mid: float) -> float: + return self._cash[agent] + self._inventory[agent] * mid + + def _reward(self, agent: str, ladder) -> float: + mid = ladder["mid"] or float(self._mid) + eq = self._equity(agent, mid) + prev = getattr(self, "_prev_equity", {}).get(agent, 0.0) + if not hasattr(self, "_prev_equity"): + self._prev_equity = {} + self._prev_equity[agent] = eq + return float(eq - prev - self._inv_pen * self._inventory[agent] ** 2) + + def _next_mid(self, ladder) -> int: + # the reference mid follows the cleared microprice when available, else a seeded walk. + if ladder["bids"] and ladder["asks"]: + return int(round((ladder["bids"][0][0] + ladder["asks"][0][0]) / 2)) + self._rng, u = _splitmix(self._rng) + return self._mid + (1 if u < 0.5 else -1) + + def _obs(self, agent: str, ladder) -> np.ndarray: + bids = ladder["bids"][: self._levels] + asks = ladder["asks"][: self._levels] + vec = np.zeros(self._obs_dim, dtype=np.float64) + for j, lvl in enumerate(bids): + vec[2 * j] = lvl[0] + vec[2 * j + 1] = lvl[1] + base = 2 * self._levels + for j, lvl in enumerate(asks): + vec[base + 2 * j] = lvl[0] + vec[base + 2 * j + 1] = lvl[1] + tail = 4 * self._levels + vec[tail] = ladder["mid"] + vec[tail + 1] = ladder["microprice"] + vec[tail + 2] = ladder["queue_imbalance"] + vec[tail + 3] = self._inventory[agent] + vec[tail + 4] = self._cash[agent] + return vec + + +def symmetric_quote_policy(observation: Any = None, *, offset: int = 3) -> np.ndarray: + """A fixed symmetric two-sided quote `offset` ticks from mid (the MM reference).""" + return np.array([offset, offset], dtype=np.float32) + + +def noise_trader_policy(observation: Any = None, *, max_offset: int = 20) -> np.ndarray: + """A wide, passive quote that rarely fills (a near-inactive reference).""" + return np.array([max_offset, max_offset], dtype=np.float32) + + +__all__ = ["LOBMarketEnv", "symmetric_quote_policy", "noise_trader_policy"] diff --git a/crates/openoutcry-py/src/lib.rs b/crates/openoutcry-py/src/lib.rs index 90c0c53..16182ff 100644 --- a/crates/openoutcry-py/src/lib.rs +++ b/crates/openoutcry-py/src/lib.rs @@ -4,6 +4,7 @@ //! and identical to the language-agnostic protocol any external agent speaks. use openoutcry::exec_noise::{perturb as core_perturb_action, ExecNoise}; +use openoutcry::lob_market::{OrderBook, OrderKind, Side}; use openoutcry::market::{MarketClearing, MarketParams}; use openoutcry::vec_env::AutoresetMode; use openoutcry::{ @@ -627,12 +628,106 @@ impl PyMarketClearing { } } +fn parse_side(s: &str) -> PyResult { + match s { + "buy" => Ok(Side::Buy), + "sell" => Ok(Side::Sell), + other => Err(PyValueError::new_err(format!( + "unknown side {other:?} (expected buy | sell)" + ))), + } +} + +/// Parse one flat order JSON object into an `(agent, OrderKind)` tuple. Shape: +/// `{agent, kind: "limit"|"market"|"cancel"|"modify", side?, price_tick?, qty?, id?, new_qty?}`. +fn parse_order(v: &serde_json::Value) -> PyResult<(usize, OrderKind)> { + let bad = |m: &str| PyValueError::new_err(format!("invalid order: {m}")); + let agent = v["agent"].as_u64().ok_or_else(|| bad("agent"))? as usize; + let kind = v["kind"].as_str().ok_or_else(|| bad("kind"))?; + let order = match kind { + "limit" => OrderKind::Limit { + side: parse_side(v["side"].as_str().ok_or_else(|| bad("side"))?)?, + price_tick: v["price_tick"].as_i64().ok_or_else(|| bad("price_tick"))?, + qty: v["qty"].as_u64().ok_or_else(|| bad("qty"))?, + }, + "market" => OrderKind::Market { + side: parse_side(v["side"].as_str().ok_or_else(|| bad("side"))?)?, + qty: v["qty"].as_u64().ok_or_else(|| bad("qty"))?, + }, + "cancel" => OrderKind::Cancel { + id: v["id"].as_u64().ok_or_else(|| bad("id"))?, + }, + "modify" => OrderKind::Modify { + id: v["id"].as_u64().ok_or_else(|| bad("id"))?, + new_qty: v["new_qty"].as_u64().ok_or_else(|| bad("new_qty"))?, + }, + other => return Err(bad(&format!("unknown kind {other:?}"))), + }; + Ok((agent, order)) +} + +fn ladder_json(book: &OrderBook, levels: usize) -> serde_json::Value { + serde_json::to_value(book.depth_ladder(levels)).unwrap_or(serde_json::Value::Null) +} + +/// A deterministic integer-tick **limit-order-book** matching engine (M3): price-time +/// priority, market/limit/cancel/modify, partial fills, and a depth-ladder observation +/// (`mid` / `microprice` / `queue_imbalance`). JSON at the boundary so the tape is +/// byte-identical across runtimes. `step_book` applies a batch of agent orders in +/// canonical order and returns the resulting fills + post-step ladder. +#[pyclass(name = "PyOrderBook")] +pub struct PyOrderBook { + inner: OrderBook, + levels: usize, +} + +#[pymethods] +impl PyOrderBook { + #[new] + #[pyo3(signature = (tick_size = 0.01, levels = 10))] + fn new(tick_size: f64, levels: usize) -> Self { + PyOrderBook { + inner: OrderBook::new(tick_size), + levels, + } + } + + /// Clear the book and return the (empty) depth-ladder snapshot as JSON. + fn reset_book(&mut self) -> String { + self.inner = OrderBook::new(self.inner.tick_size()); + serde_json::json!({ "ladder": ladder_json(&self.inner, self.levels) }).to_string() + } + + /// Apply a JSON array of flat agent orders (canonical order, price-time priority) and + /// return `{ "fills": [Fill, ...], "ladder": LadderSnapshot }` as JSON. + fn step_book(&mut self, orders_json: &str) -> PyResult { + let arr: Vec = serde_json::from_str(orders_json) + .map_err(|e| PyValueError::new_err(format!("invalid orders JSON: {e}")))?; + let orders: Vec<(usize, OrderKind)> = arr + .iter() + .map(parse_order) + .collect::>>()?; + let fills = self.inner.step(&orders); + let out = serde_json::json!({ + "fills": serde_json::to_value(&fills).map_err(|e| PyValueError::new_err(e.to_string()))?, + "ladder": ladder_json(&self.inner, self.levels), + }); + Ok(out.to_string()) + } + + /// The current depth-ladder snapshot as JSON (without stepping). + fn ladder(&self) -> String { + ladder_json(&self.inner, self.levels).to_string() + } +} + /// The `openoutcry_py` native module (imported as `openoutcry.openoutcry_py`). #[pymodule] fn openoutcry_py(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_function(wrap_pyfunction!(score_run, m)?)?; m.add_function(wrap_pyfunction!(validate_decision_json, m)?)?; m.add_function(wrap_pyfunction!(sample_mandate_json, m)?)?; diff --git a/crates/openoutcry-py/tests/test_lob_env.py b/crates/openoutcry-py/tests/test_lob_env.py new file mode 100644 index 0000000..e12c1af --- /dev/null +++ b/crates/openoutcry-py/tests/test_lob_env.py @@ -0,0 +1,104 @@ +"""Tests for the M3 limit-order-book env + the native PyOrderBook engine boundary.""" + +import json + +import numpy as np +import pytest + + +def _book(): + from openoutcry.openoutcry_py import PyOrderBook + + return PyOrderBook(tick_size=0.01, levels=5) + + +def test_orderbook_resting_and_ladder(): + b = _book() + b.reset_book() + r = json.loads( + b.step_book( + json.dumps( + [ + {"agent": 0, "kind": "limit", "side": "buy", "price_tick": 99, "qty": 10}, + {"agent": 1, "kind": "limit", "side": "sell", "price_tick": 101, "qty": 10}, + ] + ) + ) + ) + assert r["ladder"]["bids"] == [[99, 10]] + assert r["ladder"]["asks"] == [[101, 10]] + assert r["ladder"]["mid"] == 100.0 + assert -1.0 <= r["ladder"]["queue_imbalance"] <= 1.0 + + +def test_orderbook_market_order_crosses(): + b = _book() + b.reset_book() + b.step_book(json.dumps([{"agent": 1, "kind": "limit", "side": "sell", "price_tick": 101, "qty": 10}])) + r = json.loads(b.step_book(json.dumps([{"agent": 2, "kind": "market", "side": "buy", "qty": 4}]))) + assert len(r["fills"]) == 1 + f = r["fills"][0] + assert f["price_tick"] == 101 and f["qty"] == 4 and f["taker_side"] == "buy" and f["maker_agent"] == 1 + # 4 of the 10 resting were consumed. + assert r["ladder"]["asks"] == [[101, 6]] + + +def test_orderbook_canonical_order_deterministic(): + def run(orders): + b = _book() + b.reset_book() + return b.step_book(json.dumps(orders)) + + o = [ + {"agent": 0, "kind": "limit", "side": "buy", "price_tick": 99, "qty": 5}, + {"agent": 1, "kind": "limit", "side": "sell", "price_tick": 101, "qty": 5}, + ] + assert run(o) == run(list(reversed(o))) # reorder input -> identical (canonical sort) + + +def test_orderbook_rejects_bad_order(): + b = _book() + with pytest.raises(Exception): + b.step_book(json.dumps([{"agent": 0, "kind": "limit", "side": "buy"}])) # missing price/qty + + +pettingzoo = pytest.importorskip("pettingzoo") + + +def test_lob_env_constructs_and_steps(): + from openoutcry.lob_env import LOBMarketEnv + + env = LOBMarketEnv(n_agents=2, n_steps=10, seed=1) + obs, infos = env.reset(seed=1) + assert set(obs) == {"agent_0", "agent_1"} + assert obs["agent_0"].shape == env.observation_space("agent_0").shape + actions = {a: env.action_space(a).sample() for a in env.agents} + obs, rewards, terms, truncs, infos = env.step(actions) + assert set(rewards) == {"agent_0", "agent_1"} + assert all(np.isfinite(v) for v in rewards.values()) + + +def test_lob_env_deterministic(): + from openoutcry.lob_env import LOBMarketEnv, symmetric_quote_policy + + def rollout(seed): + env = LOBMarketEnv(n_agents=2, n_steps=12, seed=seed) + env.reset(seed=seed) + out = [] + done = False + while not done: + acts = {a: symmetric_quote_policy(offset=3) for a in env.agents} + _o, r, _t, tr, _i = env.step(acts) + out.append(tuple(round(x, 6) for x in r.values())) + done = any(tr.values()) + return out + + assert rollout(7) == rollout(7) + assert rollout(7) != rollout(8) + + +def test_lob_env_parallel_api(): + from pettingzoo.test import parallel_api_test + from openoutcry.lob_env import LOBMarketEnv + + parallel_api_test(LOBMarketEnv(n_agents=2, n_steps=20, seed=2), num_cycles=10) From 0a4373b0728000a46127dfdd6fbb57b01e4dc4d9 Mon Sep 17 00:00:00 2001 From: ReverseZoom2151 Date: Sat, 27 Jun 2026 01:59:49 +0300 Subject: [PATCH 3/5] =?UTF-8?q?feat(env):=20consume=20sharpebench-sim=200.?= =?UTF-8?q?0.8=20=E2=80=94=20O(1)=20native=20checkpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the sharpebench deps 0.0.7 -> 0.0.8 (TRF turnover cost, parameterized vol/jump generator, and env clone_state/restore_state now available). Wire the O(1) engine snapshot: PyTradingEnv.clone_state/restore_state (serde EnvState), OpenOutcryEnv passthrough, and a native=True fast path on CheckpointableEnv that rewinds in O(1) instead of replaying the decision prefix. Native and replay paths agree byte-for-byte. CostModel literals updated for the additive trf_cost field. --- Cargo.lock | 12 ++--- crates/openoutcry-py/Cargo.toml | 2 +- .../python/openoutcry/checkpoint.py | 49 +++++++++++++------ crates/openoutcry-py/python/openoutcry/gym.py | 10 ++++ crates/openoutcry-py/src/lib.rs | 16 ++++++ crates/openoutcry-py/tests/test_checkpoint.py | 32 ++++++++++++ crates/openoutcry-wasm/src/lib.rs | 1 + crates/openoutcry/Cargo.toml | 6 +-- crates/openoutcry/src/lib.rs | 2 + 9 files changed, 105 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 77d88e2..8196536 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -174,27 +174,27 @@ dependencies = [ [[package]] name = "sharpebench-core" -version = "0.0.7" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b681e05d5afb22a142218846745e2fc5116457efc400356d3f29f840b9ee4106" +checksum = "54e4db24342cfbe250335de0edc85b706a0658426b92ecc042994adc405d0436" dependencies = [ "serde", ] [[package]] name = "sharpebench-protocol" -version = "0.0.7" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f136bec8326bb998b1474962ca5bfe74ae6398590dd0917f83e74376dea42b81" +checksum = "6bf5cca2e27098e699ba16977db36602ffcc7164be99b5b8c8abcdb8c0c5744b" dependencies = [ "serde", ] [[package]] name = "sharpebench-sim" -version = "0.0.7" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b172fd24209c287225ae342a5822074cba20dc3552d38e550d65c6267c8da3" +checksum = "f4272a2e228b1d3021a42ec673064a1a3d41c309e2fc152189e25367536b326b" dependencies = [ "serde", "serde_json", diff --git a/crates/openoutcry-py/Cargo.toml b/crates/openoutcry-py/Cargo.toml index e09de81..1ac2de0 100644 --- a/crates/openoutcry-py/Cargo.toml +++ b/crates/openoutcry-py/Cargo.toml @@ -15,7 +15,7 @@ crate-type = ["cdylib"] [dependencies] openoutcry = { path = "../openoutcry", version = "0.5.0" } -sharpebench-core = "0.0.7" +sharpebench-core = "0.0.8" serde_json = "1" [dependencies.pyo3] diff --git a/crates/openoutcry-py/python/openoutcry/checkpoint.py b/crates/openoutcry-py/python/openoutcry/checkpoint.py index 6e3b6d7..43a1dee 100644 --- a/crates/openoutcry-py/python/openoutcry/checkpoint.py +++ b/crates/openoutcry-py/python/openoutcry/checkpoint.py @@ -15,11 +15,14 @@ into an *independent* env, which is what tree search / MCTS / counterfactual rollouts need: explore a subtree without perturbing the parent. -Cost model (be honest): ``restore_state`` / ``branch`` are **O(prefix length)** — they -replay every action up to the snapshot. For deep trees this is quadratic in the worst case. -An O(1) engine-level snapshot (copy the native simulator state) is a future -``sharpebench-sim`` enhancement; until then, replay is the leak-free, engine-agnostic way to -get exact restoration. +Two restoration paths: + +- **Replay (default):** ``restore_state`` / ``branch`` are O(prefix length) — they replay + every action up to the snapshot. Engine-agnostic and leak-free by construction. +- **Native O(1) (opt-in, ``native=True``):** since ``sharpebench-sim 0.0.8`` the engine + exposes ``clone_state`` / ``restore_state``, so a snapshot is a direct copy of the native + simulator state (cursor + book). ``clone_state(native=True)`` captures that and + ``restore_state`` rewinds in O(1) with no replay — the fast path for deep tree search. Leak-safety: the state carries only construction params + decisions — **never** the underlying ``Dataset`` / native ``TradingEnv`` handle or a raw price series. Those would let @@ -118,6 +121,7 @@ class CheckpointState: actions: list = field(default_factory=list) step: int = 0 include_rng: bool = True + native_state: Any = None # the native O(1) snapshot JSON (set when native=True) def to_dict(self) -> dict: return { @@ -125,6 +129,7 @@ def to_dict(self) -> dict: "actions": [list(a) for a in self.actions], "step": int(self.step), "include_rng": bool(self.include_rng), + "native_state": self.native_state, } @classmethod @@ -134,6 +139,7 @@ def from_dict(cls, d: dict) -> "CheckpointState": actions=[list(a) for a in d.get("actions", [])], step=int(d.get("step", 0)), include_rng=bool(d.get("include_rng", True)), + native_state=d.get("native_state"), ) @@ -170,28 +176,36 @@ def step(self, action): # -- checkpoint API ---------------------------------------------------- - def clone_state(self, *, include_rng: bool = True) -> CheckpointState: + def clone_state(self, *, include_rng: bool = True, native: bool = False) -> CheckpointState: """Capture the current env state as a serializable :class:`CheckpointState`. - O(prefix length) in space (the action list). See :meth:`restore_state` for the - replay cost. ``include_rng`` is documented on :class:`CheckpointState`. + With ``native=False`` (default) the snapshot is the recorded action prefix + (O(prefix length); engine-agnostic). With ``native=True`` it is the engine's O(1) + ``clone_state`` snapshot (cursor + book) — the fast path for deep tree search. + ``include_rng`` is documented on :class:`CheckpointState`. """ return CheckpointState( params=_extract_params(self.env), actions=[a.tolist() for a in self._actions], step=self._step, include_rng=include_rng, + native_state=self.env.clone_state() if native else None, ) def restore_state(self, state: CheckpointState) -> None: - """Rewind THIS env to ``state`` by rebuilding from params and replaying actions. - - Builds a brand-new native env from ``state.params`` (so no stale simulator state - survives), ``reset``s it, and replays every recorded action. **O(prefix length)** — - it re-executes the whole decision prefix. Exact because the engine is deterministic. + """Rewind THIS env to ``state``. If ``state`` carries a ``native_state`` snapshot, + rebuild the env and restore the engine in O(1); otherwise rebuild and replay the + recorded action prefix (O(prefix length)). Both are exact (the engine is + deterministic), so the restored env reproduces the snapshot point byte-for-byte. """ self.env = _build_env(state.params) - self._replay(state) + if state.native_state is not None: + self.env.reset() + self.env.restore_state(state.native_state) + self._actions = [] + self._step = int(state.step) + else: + self._replay(state) def branch(self, state: CheckpointState) -> "CheckpointableEnv": """Return a NEW, independent :class:`CheckpointableEnv` restored to ``state``. @@ -202,7 +216,12 @@ def branch(self, state: CheckpointState) -> "CheckpointableEnv": O(prefix length) to materialize. """ fork = CheckpointableEnv(_build_env(state.params)) - fork._replay(state) + if state.native_state is not None: + fork.env.reset() + fork.env.restore_state(state.native_state) + fork._step = int(state.step) + else: + fork._replay(state) return fork # -- internal ---------------------------------------------------------- diff --git a/crates/openoutcry-py/python/openoutcry/gym.py b/crates/openoutcry-py/python/openoutcry/gym.py index 674c121..1fd9e33 100644 --- a/crates/openoutcry-py/python/openoutcry/gym.py +++ b/crates/openoutcry-py/python/openoutcry/gym.py @@ -251,6 +251,16 @@ def step( terminated = float(info.get("nav", 1.0)) <= 0.0 return obs, float(reward), terminated, truncated, info + def clone_state(self) -> str: + """An O(1) native snapshot of the sim state (cursor + book) as a JSON string. + Pair with :meth:`restore_state` for what-if branching without replaying decisions + (the engine-level checkpoint, vs the replay path in ``CheckpointableEnv``).""" + return self._env.clone_state() + + def restore_state(self, state_json: str) -> None: + """Restore the env to a :meth:`clone_state` snapshot in O(1) (no replay).""" + self._env.restore_state(state_json) + def render(self): # pragma: no cover - no visual rendering return None diff --git a/crates/openoutcry-py/src/lib.rs b/crates/openoutcry-py/src/lib.rs index 16182ff..c1f0878 100644 --- a/crates/openoutcry-py/src/lib.rs +++ b/crates/openoutcry-py/src/lib.rs @@ -91,6 +91,7 @@ fn build_costs( impact_bps: impact_bps.unwrap_or(d.impact_bps), financing_bps: financing_bps.unwrap_or(d.financing_bps), max_participation: max_participation.unwrap_or(d.max_participation), + trf_cost: d.trf_cost, } } @@ -234,6 +235,21 @@ impl PyTradingEnv { }); Ok((observation, res.reward, res.done, info.to_string())) } + + /// An O(1) snapshot of the mutable sim state (cursor + book) as JSON — the native + /// checkpoint that replaces replay-from-decisions. Pair with [`restore_state`]. + fn clone_state(&self) -> PyResult { + let state = self.inner.clone_state(); + serde_json::to_string(&state).map_err(|e| PyValueError::new_err(e.to_string())) + } + + /// Restore the env to a snapshot produced by [`clone_state`] in O(1) (no replay). + fn restore_state(&mut self, state_json: &str) -> PyResult<()> { + let state: openoutcry::EnvState = serde_json::from_str(state_json) + .map_err(|e| PyValueError::new_err(format!("invalid env state: {e}")))?; + self.inner.restore_state(state); + Ok(()) + } } /// A vectorized, batched bank of `B` independent [`PyTradingEnv`]-equivalent lanes — diff --git a/crates/openoutcry-py/tests/test_checkpoint.py b/crates/openoutcry-py/tests/test_checkpoint.py index f1cddb0..a457845 100644 --- a/crates/openoutcry-py/tests/test_checkpoint.py +++ b/crates/openoutcry-py/tests/test_checkpoint.py @@ -183,3 +183,35 @@ def test_state_carries_no_env_handle(): callable(getattr(value, "reset", None)) and callable(getattr(value, "step", None)) ), "checkpoint params must not embed a live env/dataset handle" + + +def test_native_o1_checkpoint_matches_replay(): + """The native O(1) snapshot (native=True) restores byte-identically and agrees with + the replay path.""" + import numpy as np + from openoutcry import OpenOutcryEnv, CheckpointableEnv + + def _act(env): + n = env.action_space.shape[0] + return np.full((n,), 0.2, dtype=np.float32) + + env = CheckpointableEnv(OpenOutcryEnv(n_symbols=3, n_days=60, seed=9)) + env.reset(seed=9) + a = _act(env) + for _ in range(8): + env.step(a) + snap_native = env.clone_state(native=True) + snap_replay = env.clone_state(native=False) + # advance, then restore via the native O(1) path + after = [tuple(env.step(a)[0]["closes"]) for _ in range(4)] + env.restore_state(snap_native) + native_after = [tuple(env.step(a)[0]["closes"]) for _ in range(4)] + # and via replay, on an independent branch + branch = env.branch(snap_replay) + replay_after = [tuple(branch.step(a)[0]["closes"]) for _ in range(4)] + assert native_after == after + assert native_after == replay_after + # the native snapshot round-trips through to_dict/from_dict + from openoutcry import CheckpointState + rt = CheckpointState.from_dict(snap_native.to_dict()) + assert rt.native_state == snap_native.native_state diff --git a/crates/openoutcry-wasm/src/lib.rs b/crates/openoutcry-wasm/src/lib.rs index 222cd2f..394e2d4 100644 --- a/crates/openoutcry-wasm/src/lib.rs +++ b/crates/openoutcry-wasm/src/lib.rs @@ -57,6 +57,7 @@ impl CostsInput { impact_bps: self.impact_bps.unwrap_or(d.impact_bps), financing_bps: self.financing_bps.unwrap_or(d.financing_bps), max_participation: self.max_participation.unwrap_or(d.max_participation), + trf_cost: d.trf_cost, } } } diff --git a/crates/openoutcry/Cargo.toml b/crates/openoutcry/Cargo.toml index b9e070b..12f3cff 100644 --- a/crates/openoutcry/Cargo.toml +++ b/crates/openoutcry/Cargo.toml @@ -9,9 +9,9 @@ keywords = ["trading", "agent", "environment", "backtest", "simulation"] categories = ["science", "finance", "simulation"] [dependencies] -sharpebench-sim = "0.0.7" -sharpebench-protocol = "0.0.7" -sharpebench-core = "0.0.7" +sharpebench-sim = "0.0.8" +sharpebench-protocol = "0.0.8" +sharpebench-core = "0.0.8" serde = { version = "1", features = ["derive"] } # rayon parallelizes the batched step on native targets. Gated out of wasm32 (no diff --git a/crates/openoutcry/src/lib.rs b/crates/openoutcry/src/lib.rs index 1f7abab..c31548a 100644 --- a/crates/openoutcry/src/lib.rs +++ b/crates/openoutcry/src/lib.rs @@ -92,6 +92,8 @@ pub use sharpebench_sim::{ TeamAgent, TradingEnv, Window, + // O(1) env state snapshot (clone_state / restore_state) — sharpebench-sim 0.0.8. + EnvState, }; // --- The language-agnostic wire contract (the standard OpenOutcry governs) ----------------- From 5afe53bcb7aac927bd8f75eedd5b7da0ed49e692 Mon Sep 17 00:00:00 2001 From: ReverseZoom2151 Date: Sat, 27 Jun 2026 02:01:33 +0300 Subject: [PATCH 4/5] docs(readme): M3 limit-order-book market + O(1) checkpoint; sharpebench-sim 0.0.8 --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 39f510a..7aaaa91 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The strategic bet is **interface ownership**: if every trading agent in the open ## Status: published, active (pre-1.0) -Published to **crates.io**, **npm**, and **PyPI** at **v0.5.0**, depending on the **published** `sharpebench-sim 0.0.7` engine (not a vendored copy). CI is green across four surfaces: Rust (`fmt`, `clippy -D warnings`, tests, a WASM target build), `cargo-deny`, the npm package, and the Python wheel (`maturin` + `pytest`). +Published to **crates.io**, **npm**, and **PyPI** at **v0.6.0**, depending on the **published** `sharpebench-sim 0.0.8` engine (not a vendored copy). CI is green across four surfaces: Rust (`fmt`, `clippy -D warnings`, tests, a WASM target build), `cargo-deny`, the npm package, and the Python wheel (`maturin` + `pytest`). Beyond the core `reset`/`step` lifecycle, the environment now ships a full **reinforcement-learning training surface**: @@ -53,9 +53,9 @@ Beyond the core `reset`/`step` lifecycle, the environment now ships a full **rei | **Vectorized rollouts** | `VecTradingEnv` runs B scenario lanes in lockstep (rayon, structure-of-arrays JSON, current-Gymnasium `AutoresetMode`, async `send`/`recv`), exposed as a `gymnasium.vector` env. | | **Point-in-time-safe wrappers** | Causal normalize (no future-bar leak), `TimeLimit`, `FrameStack`, `RecordEpisodeStatistics`, vector-env variants, and `flatten`/`unflatten` Dict-obs helpers, plus a `check_env` conformance harness that *proves* seed-determinism (and adopts Gymnasium's own `check_env`). | | **Gymnasium registration** | Versioned, namespaced IDs: `gymnasium.make("OpenOutcry/Hard-v1")` and `make_vec(...)` route to the scalar and vector envs, with `-Eval-v1` variants on a disjoint held-out seed band. | -| **Multi-agent markets** | A PettingZoo `MultiAgentOpenOutcryEnv` (batched competition: N agents on one frozen scenario, SharpeBench-ranked), and `EndogenousMarketEnv`, a real shared-book market where aggregate flow *moves* the cleared price (Kyle permanent + Almgren-Chriss temporary impact). | +| **Multi-agent markets** | A PettingZoo `MultiAgentOpenOutcryEnv` (batched competition: N agents on one frozen scenario, SharpeBench-ranked), an `EndogenousMarketEnv` (a shared-book market where aggregate flow *moves* the cleared price via Kyle + Almgren-Chriss impact), and an `LOBMarketEnv` over a real **deterministic limit-order-book matching engine** (integer ticks, price-time priority, market/limit/cancel/modify, depth-ladder + microprice + queue-imbalance obs). | | **Per-scenario mandates** | Each scenario samples a trading mandate (long-only, market-neutral, drawdown-capped, beat-a-benchmark); the `verifiers` rubric is mandate-conditioned, so wrong-objective behavior is penalized, not just unrewarded. | -| **Offline-RL + checkpointing** | `to_minari` exports rollouts as a Farama [Minari](https://minari.farama.org) dataset (leak-safe, `recover_environment`-ready); `CheckpointableEnv` clones/restores/branches market state for tree search; `OpenOutcryFuncEnv` is a stateless `gymnasium.functional.FuncEnv` view. | +| **Offline-RL + checkpointing** | `to_minari` exports rollouts as a Farama [Minari](https://minari.farama.org) dataset (leak-safe, `recover_environment`-ready); `CheckpointableEnv` clones/restores/branches market state for tree search (O(1) native engine snapshot or replay-from-decisions); `OpenOutcryFuncEnv` is a stateless `gymnasium.functional.FuncEnv` view. | | **Benchmark protocol** | A committed [`EVALUATION.md`](EVALUATION.md): the canonical eval contract, the disjoint train/held-out split, and a baseline leaderboard (deflated Sharpe to beat is 0.0; pass^k degrades Calm to Hard to Extreme). | | **Harness integration** | An MCP server (`reset` / `step` / `spec` tools) so any MCP agent harness drives an episode with zero glue, a `LookaheadGuard` that refuses agent operations reading future data, versioned JSONL rollout traces that re-score offline through the SharpeBench kernel, and a cost-adjusted `RunMetrics` block for leaderboard ranking. | @@ -205,7 +205,7 @@ The load-bearing standard. An `Observation` is point-in-time; a `Decision` is a A Rust [Cargo workspace](Cargo.toml), `#![forbid(unsafe_code)]`, that **depends on** the published SharpeBench engine rather than vendoring it, so the env and the benchmark cannot drift. ``` -sharpebench-sim (published 0.0.7) ... the leak-free point-in-time engine +sharpebench-sim (published 0.0.8) ... the leak-free point-in-time engine | crates/openoutcry ......... the env, scenario generator, batched VecTradingEnv, | mandate / exec-noise / market-clearing cores, the From 951c4ef26ade0debec2c45bdb00718d11d5ff074 Mon Sep 17 00:00:00 2001 From: ReverseZoom2151 Date: Sat, 27 Jun 2026 02:05:16 +0300 Subject: [PATCH 5/5] style: rustfmt the sharpebench_sim re-export (EnvState sort order) --- crates/openoutcry/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/openoutcry/src/lib.rs b/crates/openoutcry/src/lib.rs index c31548a..dfeb203 100644 --- a/crates/openoutcry/src/lib.rs +++ b/crates/openoutcry/src/lib.rs @@ -76,6 +76,8 @@ pub use sharpebench_sim::{ CostModel, CostProfile, Dataset, + // O(1) env state snapshot (clone_state / restore_state) — sharpebench-sim 0.0.8. + EnvState, // External transports — a conforming agent is just a program that reads observations // (stdin / `POST /decide`) and writes decisions. ExternalAgent, @@ -92,8 +94,6 @@ pub use sharpebench_sim::{ TeamAgent, TradingEnv, Window, - // O(1) env state snapshot (clone_state / restore_state) — sharpebench-sim 0.0.8. - EnvState, }; // --- The language-agnostic wire contract (the standard OpenOutcry governs) -----------------