Skip to content

Instantly share code, notes, and snippets.

@yenw0d
Created October 8, 2025 12:07
Show Gist options
  • Select an option

  • Save yenw0d/bfeedf3702e579fe10c1b6b3d4a71767 to your computer and use it in GitHub Desktop.

Select an option

Save yenw0d/bfeedf3702e579fe10c1b6b3d4a71767 to your computer and use it in GitHub Desktop.
// Simulation parameters
const ACCOUNTS_TARGET: usize = 20_000;
const INSTRUMENTS_TARGET: usize = 15;
// number of events to stdout before the final line
const TICKS_GENERATED_COUNT: u64 = 1_000_000 - 1;
// Trading constraints
const TRADE_MAX_SZ: u64 = 1_000;
const TRADE_MAX_PX: u64 = 1_000_000;
const TRADE_MIN_PX: u64 = 100;
const MAINTENANCE_MARGIN_PERCENTAGE: i64 = 1;
// Ensure arithmetic won't overflow for the chosen params
const _: () = {
let worst_case_number: u128 = (TRADE_MAX_SZ as u128) // iterated max size trades
.saturating_mul(TICKS_GENERATED_COUNT as u128) // one per tick
.saturating_mul(TRADE_MAX_PX as u128) // notional exposure at max price
.saturating_mul(100 as u128) // we multiply by 100 in margin calcs
.saturating_mul(2 as u128); // for good measure
assert!(worst_case_number < i64::MAX as u128, "fits in i64");
};
fn main() {
let (accounts_target, instruments_target, ticks_generated_count) = parse_cli_overrides();
run(accounts_target, instruments_target, ticks_generated_count);
}
fn run(accounts_target: usize, instruments_target: usize, ticks_generated_count: u64) {
let mut g = Generator {
prng: rand::Prng::new_from_urandom(),
state: solve::State::new(),
ticks_generated_count,
ticks: 0,
};
const MAX_ITERATIONS: u32 = 10_000_000;
for _ in 0..MAX_ITERATIONS {
if g.ticks >= g.ticks_generated_count {
break;
}
// add new accounts and instruments probabilistically
if g.chance(g.state.accounts.len(), accounts_target) {
g.add_account();
continue;
}
if g.chance(g.state.prices.len(), instruments_target) {
g.add_instrument();
continue;
}
// need both accounts and instruments to trade
if g.state.prices.is_empty() || g.state.accounts.is_empty() {
continue;
}
// execute trades and update prices for traded instruments
while g.ratio(1, 10) {
let instrument = (g.prng.rand() * g.prng.rand() * g.state.prices.len() as f64) as usize;
let mut num_trades = 0;
while g.ratio(1, 3) {
if g.execute_trade(instrument) {
num_trades += 1;
}
}
if g.ratio(num_trades.min(4), 5) {
g.update_price(0.001, instrument);
}
}
// fairly often, move the prices on random instruments
if g.ratio(1, 10) {
for instrument in 0..g.state.prices.len() {
if g.ratio(1, 10) {
g.update_price(0.001, instrument);
}
}
}
// very rarely, "jump" the prices on a lot of instruments
if g.ratio(1, 50_000) {
for instrument in 0..g.state.prices.len() {
if g.ratio(1, 5) {
g.update_price(0.05, instrument);
}
}
}
}
// pick a random account, print its equity and notional
let idx = (g.prng.rand() * g.state.accounts.len() as f64) as usize;
println!("{}", idx);
let account = &g.state.accounts[idx];
let (equity, notional) = account.equity_and_notional();
eprintln!("{} {}", equity, notional);
}
struct Generator {
prng: rand::Prng,
state: solve::State,
ticks_generated_count: u64,
ticks: u64,
}
impl Generator {
pub fn add_account(&mut self) {
let balance = self.prng.norm(1_000_000.0, 200_000.0).max(10_000.0) as i64;
self.state.add_account(balance);
println!("a {}", balance);
self.ticks += 1;
}
pub fn add_instrument(&mut self) {
let price = self.prng.norm(10_000.0, 5_000.0).max(1_000.0) as u64;
let instrument = self.state.add_instrument(price);
println!("p {} {}", instrument, price);
self.ticks += 1;
}
pub fn update_price(&mut self, volatility: f64, instrument: usize) {
let old_price = self.state.prices[instrument];
let returns = self.prng.norm(0.0, volatility);
let new_price = (((old_price as f64) * (1.0 + returns)) as u64)
.max(TRADE_MIN_PX)
.min(TRADE_MAX_PX);
self.state.update_price(instrument, new_price);
println!("p {} {}", instrument, new_price);
self.ticks += 1;
// process any liquidations triggered by price change
for (account_idx, equity, notional) in self.state.liquidatable(instrument) {
eprintln!("liquidate {} {} {}", account_idx, equity, notional);
for h in self.state.holders.iter_mut() {
h.remove(&account_idx);
}
self.state.accounts[account_idx] = solve::Account::new(0);
}
}
pub fn execute_trade(&mut self, instrument: usize) -> bool {
let mut account_idx = (self.prng.rand() * self.state.accounts.len() as f64) as usize;
// try up to 10 accounts to find one that can trade
for _ in 0..10 {
account_idx = (account_idx + 1) % self.state.accounts.len();
if let Some(trade_size) = self.calculate_trade_size(account_idx, instrument) {
println!("t {} {} {}", account_idx, instrument, trade_size);
self.state.trade(account_idx, instrument, trade_size);
self.ticks += 1;
return true;
}
}
false
}
fn calculate_trade_size(&mut self, account_idx: usize, instrument: usize) -> Option<i64> {
let account = &mut self.state.accounts[account_idx];
account
.positions
.resize_with(self.state.prices.len(), || solve::Position::default());
// calculate maximum affordable trade size
let (equity, notional) = account.equity_and_notional();
let free_collateral = equity - (notional * MAINTENANCE_MARGIN_PERCENTAGE / 100);
let trade_ntl_max = free_collateral * 100 / MAINTENANCE_MARGIN_PERCENTAGE;
let trade_sz_max = (trade_ntl_max / self.state.prices[instrument] as i64)
.max(0)
.min(TRADE_MAX_SZ as i64) as u64;
// if the instrument is too expensive, see if we have an opposing position to reduce
if trade_sz_max == 0 {
let pos_size = account
.positions
.get(instrument)
.map(|p| p.size)
.unwrap_or(0);
if pos_size != 0 {
return Some(-pos_size);
}
return None;
}
// Generate random trade size with cubic distribution (favors smaller trades)
let size_pct = (self.prng.rand() * self.prng.rand() * self.prng.rand() * 100.0) as u64;
let direction = if self.prng.ratio(1, 2) { -1 } else { 1 };
let size = (trade_sz_max * size_pct / 100) as i64 * direction;
Some(size)
}
fn chance(&mut self, have: usize, target: usize) -> bool {
self.ratio(1, if have < target { 2 } else { 10_000 })
}
fn ratio(&mut self, numerator: u64, denominator: u64) -> bool {
self.prng.ratio(numerator, denominator) && self.ticks < self.ticks_generated_count
}
}
fn parse_cli_overrides() -> (usize, usize, u64) {
fn usage_and_exit() -> ! {
eprintln!(
"Usage: gen [ACCOUNTS INSTRUMENTS TICKS]\n\nDefaults (no args): accounts={} instruments={} ticks={}\n",
ACCOUNTS_TARGET, INSTRUMENTS_TARGET, TICKS_GENERATED_COUNT
);
std::process::exit(1)
}
let args = std::env::args().skip(1).collect::<Vec<String>>();
if args.is_empty() {
return (ACCOUNTS_TARGET, INSTRUMENTS_TARGET, TICKS_GENERATED_COUNT);
}
if args.len() == 3 {
let a = args[0]
.parse::<usize>()
.unwrap_or_else(|_| usage_and_exit());
let i = args[1]
.parse::<usize>()
.unwrap_or_else(|_| usage_and_exit());
let t = args[2].parse::<u64>().unwrap_or_else(|_| usage_and_exit());
return (a, i, t);
}
usage_and_exit()
}
mod rand {
/// A simple pseudorandom number generator using Xorshift64*
pub struct Prng {
seed: u64,
gauss_cached: Option<f64>,
}
impl Prng {
pub fn new(seed: u64) -> Self {
let seed = if seed == 0 { 1 } else { seed };
Self {
seed,
gauss_cached: None,
}
}
pub fn new_from_urandom() -> Self {
let mut seed_bytes = [0u8; 8];
std::fs::File::open("/dev/urandom")
.and_then(|mut f| std::io::Read::read_exact(&mut f, &mut seed_bytes))
.expect("Failed to get random seed from OS");
let seed = u64::from_ne_bytes(seed_bytes);
Self::new(seed)
}
/// Generate next u64 using Xorshift64*
pub fn next_u64(&mut self) -> u64 {
let mut x = self.seed;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.seed = x;
x.wrapping_mul(0x2545F4914F6CDD1D)
}
/// Generate f64 in [0, 1)
pub fn rand(&mut self) -> f64 {
const SCALE: f64 = (1u64 << 53) as f64;
let r = self.next_u64() >> 11;
(r as f64) / SCALE
}
/// Generate normal distribution using Box-Muller transform
pub fn norm(&mut self, mean: f64, stddev: f64) -> f64 {
assert!(stddev >= 0.0);
if stddev == 0.0 {
return mean;
}
// Use cached deviate if available
if let Some(z1) = self.gauss_cached.take() {
return mean + stddev * z1;
}
// Generate two independent standard normals; cache one
loop {
let u = 2.0 * self.rand() - 1.0;
let v = 2.0 * self.rand() - 1.0;
let s = u * u + v * v;
if s == 0.0 || s >= 1.0 {
continue;
}
let m = (-2.0 * s.ln() / s).sqrt();
let z0 = u * m;
let z1 = v * m;
self.gauss_cached = Some(z1);
return mean + stddev * z0;
}
}
/// Return true with probability numerator/denominator
pub fn ratio(&mut self, numerator: u64, denominator: u64) -> bool {
assert!(denominator > 0);
assert!(numerator < denominator);
let r = self.next_u64() as u128;
let threshold = ((numerator as u128) << 64) / (denominator as u128);
r < threshold
}
}
}
mod solve {
use std::collections::HashSet;
/// Trading simulation state
pub struct State {
pub accounts: Vec<Account>,
pub prices: Vec<u64>,
/// accound ids indexed by instrument
pub holders: Vec<HashSet<usize>>,
}
pub struct Account {
pub cash_value: i64,
pub pos_value: i64,
pub positions: Vec<Position>,
pub cached_notional: i64,
}
#[derive(Default)]
pub struct Position {
pub size: i64,
pub total_paid: i64,
pub price: u64,
}
impl State {
pub fn new() -> Self {
Self {
accounts: Vec::new(),
prices: Vec::new(),
holders: Vec::new(),
}
}
pub fn add_account(&mut self, balance: i64) {
self.accounts.push(Account::new(balance));
}
pub fn add_instrument(&mut self, price: u64) -> usize {
self.prices.push(price);
self.holders.push(HashSet::new());
self.prices.len() - 1
}
pub fn update_price(&mut self, instrument: usize, price: u64) {
self.prices[instrument] = price;
let holders = &mut self.holders[instrument];
for account_idx in holders.iter() {
self.accounts[*account_idx].mark_to_market(&self.prices, instrument);
}
}
/// Find accounts that need liquidation (equity < 1% of total notional)
pub fn liquidatable(&self, instrument: usize) -> Vec<(usize, i64, i64)> {
let mut results = Vec::new();
for account_idx in self.holders[instrument].iter() {
let account = &self.accounts[*account_idx];
let (eq, nt) = account.equity_and_notional();
if eq < (nt / 100) {
results.push((*account_idx, eq, nt));
}
}
// sort by notional (desc), then by account index (desc) as tie-breaker
results.sort_by(|a, b| {
let (_, _, notional_a) = a;
let (_, _, notional_b) = b;
let (idx_a, _, _) = a;
let (idx_b, _, _) = b;
match notional_b.cmp(notional_a) {
std::cmp::Ordering::Equal => idx_b.cmp(idx_a),
other => other,
}
});
results
}
pub fn trade(&mut self, account: usize, instrument: usize, size: i64) {
let positions = &mut self.accounts[account].positions;
positions.resize_with(self.prices.len(), || Position::default());
let pos = &mut positions[instrument];
let px = self.prices[instrument];
let old_ntl = pos.size * px as i64;
let paid = size * px as i64;
pos.size += size;
pos.total_paid += paid;
pos.price = px;
let new_ntl = (pos.size * px as i64).abs();
if new_ntl == 0 {
self.holders[instrument].remove(&account);
} else {
self.holders[instrument].insert(account);
}
let acc = &mut self.accounts[account];
acc.cached_notional -= old_ntl.abs();
acc.cached_notional += new_ntl.abs();
acc.cash_value -= paid;
acc.pos_value += paid;
}
}
impl Account {
pub fn new(balance: i64) -> Self {
Self {
cash_value: balance,
positions: Vec::new(),
pos_value: 0,
cached_notional: 0,
}
}
pub fn mark_to_market(&mut self, prices: &[u64], instrument: usize) {
if let Some(pos) = self.positions.get_mut(instrument) {
let new_px = prices[instrument];
let old_ntl = pos.size * pos.price as i64;
let new_ntl = pos.size * new_px as i64;
self.cached_notional -= old_ntl.abs();
self.cached_notional += new_ntl.abs();
let old_value = pos.size * pos.price as i64;
let new_value = pos.size * new_px as i64;
self.pos_value -= old_value;
self.pos_value += new_value;
pos.price = new_px;
}
}
pub fn equity_and_notional(&self) -> (i64, i64) {
(self.cash_value + self.pos_value, self.cached_notional)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment