Skip to content

MarlSimEnv — event-time financial environment

SDK 5 runs local funded asset engines on one deterministic Rust event calendar. The SDK supplies the world, delivered observations, pending action execution and generic controls. Applications own agents, features, rewards, models and learning. There are no SDK-owned studies, reference policies, trainers or graphs.

See SDK 5 migration for SDK 4 callers, or v4 migration for a v3 turn-based caller.

Ask for a decision, then submit pending work

from fastboxex import MarlSimEnv

env = MarlSimEnv([
    {"id": 1, "mode": "training",
     "scheduler": {"default_compute_ns": 100, "min_wake_gap_ns": 200}},
    {"id": 2, "scheduler": {"first_wake_ns": 10, "min_wake_gap_ns": 200}},
], horizon_ns=2_000, md_interval_ns=10, trace_capacity=0)

for state in ("Suspended", "Auction", "Trade"):
    env.transition_to(state)

while request := env.next_decision():
    # The caller supplies its agent with observation, events and mode.
    actions = []  # A no-op exercises the boundary without designing an agent.
    ticket = env.submit_decision(request["decision_id"], actions)
    # ticket contains scheduling facts, not fills or a reward.

env.validate()

The native equivalents are sim::marl::{MarlConfig, MarlSimEnv, AgentDecision}. Rust advances market events with the Python GIL released. Python crosses the boundary at decisions, never once per matching/calendar event. Actions are converted directly without a JSON encode/parse round trip.

delivered observation → declared computation → outbound delay → command arrival
                                             publication → public delivery
                                             execution → private delivery

An agent deciding at time 0 with compute duration 100 does not prevent another agent waking at 10 and completing at 15 from arriving first. Policy wall-clock time does not automatically advance modeled time. Calibrating declared computation cost remains the application's responsibility.

Per-agent training and inference

env = MarlSimEnv([{"id": 1}, {"id": 2}])
env.set_agent_mode(1, "training")
env.set_agent_mode(2, "inference")
assert env.agent_mode(1) == "training"
  • Initial mode is configurable per agent; the default is inference.
  • Each issued request carries a snapshot in request["mode"].
  • A switch affects subsequent requests. An issued request and pending actions retain their original mode/scheduling, even if the setter is called mid-request.
  • A switch resets neither world nor model. Different agents can use different modes.
  • reset() restores the configured initial modes; restore/replay preserve changes.
  • Unknown modes/agents and switches after episode termination raise errors.

The caller chooses the learning schedule and must prevent parameter/optimizer updates in inference. The SDK cannot freeze a model it does not own. Inference can remain stochastic and retain recurrent memory. PyTorch model.eval() alone does not prohibit optimizer updates; training rollout collection can use no_grad(). This control is independent of the asset engine's parity/diagnostic mode. The SDK has no algorithm-specific mode or training callback.

Configuration and timing

MarlSimEnv.from_config(mapping) accepts the native schema; unknown fields fail. The convenience constructor uses asset ID 1 unless assets is supplied. Do not mix assets with single-asset configuration keywords.

Field Meaning
assets Tick/lot, risk, fees, depth and publication settings per asset
agents Participant endowments, subscriptions, scheduler and initial mode
horizon_ns Episode boundary; eligible events through that timestamp execute
seed Caller seed namespace; the SDK does not invent learner randomness
history_capacity Bounded delivered-event history per agent
trace_capacity Bounded full journal; default 0 disables recording
max_events Processing budget; exhaustion raises an error
provenance Caller-supplied revisions recorded in the manifest, not policy input

Scheduler fields are first_wake_ns, min_wake_gap_ns, default_compute_ns, priority, outbound_ns, public_delay_ns and private_delay_ns. Computation and minimum wake gap must be positive. The next wake is completion plus the requested gap, bounded below by the minimum; outbound delay does not postpone the next wake.

Calendar ordering is (timestamp, phase, priority, insertion sequence): lifecycle boundaries, computation/commands, publication, delivery, then wakes. Cancellations have no blanket priority over orders. An auction boundary precedes commands arriving at the same timestamp. These are explicit modeled assumptions.

Multi-asset observations and actions

For multiple assets, supply endowments={asset_id: {cash: ..., inventory: ...}} per agent, with observed_assets and tradable_assets as needed. Each asset has segregated funding, matching, lifecycle and fees. There is no automatic currency conversion, cross-margin or atomic basket. Prices, quantities and times are raw integer SDK units.

submit_decision(id, actions, compute_ns=None, next_wake_delay_ns=None) seals an ordered list of separate commands. Kinds are limit, market, delta, ioc, cancel, modify and noop. Market actions require their asset_id; controls and trading permissions are validated natively. A failed submission keeps the request active so the caller can correct it.

The delivered observation contains asset-keyed market snapshots and receipt times, own account state, standing orders and a cursor. request["events"] contains deliveries since that agent's previous decision cut: public snapshots, anonymous public trades or private fills/acks/account/order changes. Public trades do not disclose participant/order identities. exchange_view(agent_id) reads delivered state, not authoritative matcher state. events_since(agent_id, cursor) raises on a future cursor or overwritten history.

depth_array(agent_id, asset_id) returns an owned contiguous NumPy uint64[10,4] array: bid price/quantity, ask price/quantity, zero padded, with exchange and receipt timestamps. It reads delivered information only. The buffer cannot mutate the engine and later events cannot overwrite it. External Torch code can use torch.from_numpy(); feature normalization, dtype/device transfer and models stay outside the SDK. No Torch dependency or GPU zero-copy guarantee is imposed.

Setup, termination and evaluator facts

Before the first decision, use bootstrap(command) for funded setup commands and transition_to(state, asset_id=...) for lifecycle changes. After starting, use schedule_transition() or schedule_command(). These are controller facilities, not extra information for an autonomous policy.

At the horizon, no new decision is issued. Eligible arrivals/deliveries execute; future pending work remains checkpointed. There is no forced liquidation or fresh terminal delivery. exchange_view() still returns the last delivered information. reset() reconstructs initial endowments, modes and scheduling; repeat setup.

evaluator_state() returns authoritative accounts (agent then asset), signed venue_cash (asset), clock_ns, ended and processed_events. Keys and financial values are exact Python integers. This on-demand snapshot does not refresh agent caches or calculate rewards, wealth, statistics or reports. Never pass it to an autonomous policy; applications compute their stated evaluator metrics separately.

Checkpoints and replay

checkpoint() returns schema-2 world bytes, including active requests, selected modes, pending events, delivered caches and journal state. Restore requires the same schema/client version. The caller separately saves models, optimizers, buffers, recurrent memory and RNG state. Load only trusted checkpoints.

The full journal records controller inputs, mode changes, decisions, completions, executions, lifecycle, publications and deliveries. Drain it incrementally with drain_trace(); a full buffer raises an error before consuming the next event. history() is the evaluator journal, not an agent observation stream. Store manifest() beside the journal. The SDK does not inspect Git, package files or the host to invent provenance.

MarlSimEnv.replay(config, inputs) verifies recorded decision identities/times and mode-switch boundaries. Inputs are the input fields of journal records with kind == "input". Continue with next_decision() to process remaining events. trace_hash is a reproducibility checksum, meaningful only with recording enabled, not a security certificate. Disable detailed tracing when it is unnecessary.

Optional native read views (SDK 5.1)

next_decision_view() issues the same native request as next_decision(), but returns a read-only fastboxex.marl.DecisionView instead of eagerly materializing every order/event as Python dictionaries. Do not call both while a request is active.

request = env.next_decision_view()
if request is not None:
    cash = request.account(1)["cash"]
    own_orders = request.own_orders_array(1)
    public_depth = request.public_depth(1)
    delivered_tables = request.events_arrays()
    ticket = env.submit_decision(request.decision_id, [])

The view captures identity, mode, cursor, exchange/receipt times and delivered state. It owns no controller/world handle and cannot select another participant. Later executions/mode changes cannot refresh old views. own_order() is a point query; own_best_bid/ask() mean own resting prices, not public market prices. public_best_bid/ask() read the captured public prices without a depth-array copy. No second order tracker, maintained statistic or policy/model is added to matching.

Arrays are owned contiguous uint64, with layouts published in ORDER_COLUMNS and DELIVERED_COLUMNS in fastboxex.marl. The delivered tables include all delivery headers, owner-private events/accounts/order changes (including tombstones), public snapshot counters/levels and anonymous public trades. Tables join on the per-agent delivery cursor and retain source order. This is a non-consuming captured stream, not a drain of immediate matcher events. as_dict() explicitly requests the complete legacy representation when required.

The MarlSimEnv and synchronous SimExchange controllers separately provide monitor_depth_array, monitor_depth_summary and monitor_owner_queue_array. They expose authoritative state for reconstruction/diagnostics and must never replace an autonomous agent's delivered input. SimConnection.drain_events_array() consumes both private event kinds once in native order, retaining producer capacity; it is an alternative to, not an extra copy of, the legacy event drains. Prices/quantities/fees remain exact; raw wide depth totals use Python integers. Device transfer and normalization stay in the application. No GPU interoperability or speedup is implied by these APIs.