use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use crate::{Dataset, MarketObservation, PositionState, SymbolSnapshot};
const WARMUP: usize = 20;
const LOOKBACK: usize = 20;
const EPS: f64 = 1e-12;
const VOL_WINDOW: usize = 20;
const VOL_FACTOR_CAP: f64 = 3.0;
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct MarketParams {
pub lambda: f64,
pub eta: f64,
pub volume_scale: f64,
pub vol_scale: f64,
}
impl Default for MarketParams {
fn default() -> Self {
Self {
lambda: 0.1,
eta: 0.05,
volume_scale: 1.0,
vol_scale: 0.0,
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct AgentFill {
pub symbol: String,
pub size: f64,
pub fill_price: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct ClearResult {
pub cleared_mids: Vec<f64>,
pub net_flow: Vec<f64>,
pub rewards: Vec<f64>,
pub navs: Vec<f64>,
pub fills: Vec<Vec<AgentFill>>,
pub observations: Vec<MarketObservation>,
pub done: bool,
}
#[derive(Clone, Debug)]
struct AgentBook {
cash: f64,
shares: Vec<f64>,
cost_basis: Vec<f64>,
prev_weight: Vec<f64>,
}
#[derive(Clone, Debug)]
struct VolTracker {
ring: [f64; VOL_WINDOW],
head: usize,
count: usize,
sum_sq: f64,
}
impl VolTracker {
fn new() -> Self {
Self {
ring: [0.0; VOL_WINDOW],
head: 0,
count: 0,
sum_sq: 0.0,
}
}
fn proxy(&self) -> f64 {
if self.count == 0 {
0.0
} else {
self.sum_sq / self.count as f64
}
}
fn push(&mut self, ret: f64) {
let sq = ret * ret;
if self.count == VOL_WINDOW {
self.sum_sq -= self.ring[self.head];
} else {
self.count += 1;
}
self.ring[self.head] = sq;
self.sum_sq += sq;
self.head = (self.head + 1) % VOL_WINDOW;
}
}
pub struct MarketClearing {
symbols: Vec<String>,
dates: Vec<String>,
exo: Vec<Vec<f64>>,
capital: f64,
impact_mult: Vec<f64>,
prev_mid: Vec<f64>,
cleared_history: Vec<Vec<f64>>,
vol: Vec<VolTracker>,
agents: Vec<AgentBook>,
cursor: usize,
start_bar: usize,
n_bars: usize,
}
impl MarketClearing {
pub fn from_dataset(data: &Dataset, n_agents: usize, capital: f64) -> Self {
assert!(n_agents >= 1, "a market needs at least one agent");
let symbols = data.symbols();
let n_sym = symbols.len();
let n_bars = data.len();
assert!(
n_sym >= 1 && n_bars >= 2,
"need at least one symbol and two bars"
);
let exo: Vec<Vec<f64>> = symbols
.iter()
.map(|s| data.closes.get(s).cloned().unwrap_or_default())
.collect();
let start_bar = WARMUP.min(n_bars.saturating_sub(1)).max(1);
let cleared_history: Vec<Vec<f64>> = exo
.iter()
.map(|series| series[..start_bar.min(series.len())].to_vec())
.collect();
let prev_mid: Vec<f64> = exo.iter().map(|s| s[start_bar.min(s.len() - 1)]).collect();
let vol: Vec<VolTracker> = cleared_history
.iter()
.map(|series| {
let mut tracker = VolTracker::new();
for w in series.windows(2) {
if w[0].abs() > EPS {
tracker.push((w[1] - w[0]) / w[0]);
}
}
tracker
})
.collect();
let agents = (0..n_agents)
.map(|_| AgentBook {
cash: capital,
shares: vec![0.0; n_sym],
cost_basis: vec![0.0; n_sym],
prev_weight: vec![0.0; n_sym],
})
.collect();
MarketClearing {
symbols,
dates: data.dates.clone(),
exo,
capital,
impact_mult: vec![1.0; n_sym],
prev_mid,
cleared_history,
vol,
agents,
cursor: start_bar,
start_bar,
n_bars,
}
}
pub fn symbols(&self) -> &[String] {
&self.symbols
}
pub fn dates(&self) -> &[String] {
&self.dates
}
pub fn n_agents(&self) -> usize {
self.agents.len()
}
pub fn n_bars(&self) -> usize {
self.n_bars
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn start_bar(&self) -> usize {
self.start_bar
}
pub fn capital(&self) -> f64 {
self.capital
}
pub fn is_done(&self) -> bool {
self.cursor >= self.n_bars
}
pub fn exo_mid_at_cursor(&self) -> Vec<f64> {
let bar = self.cursor.min(self.n_bars - 1);
self.exo.iter().map(|s| s[bar.min(s.len() - 1)]).collect()
}
pub fn initial_observations(&self) -> Vec<MarketObservation> {
let date = self.dates.get(self.start_bar).cloned().unwrap_or_default();
(0..self.agents.len())
.map(|agent| {
let symbols = self
.symbols
.iter()
.enumerate()
.map(|(s, sym)| {
let mut hist = self.cleared_history[s].clone();
hist.push(self.exo[s][self.start_bar.min(self.exo[s].len() - 1)]);
SymbolSnapshot {
symbol: sym.clone(),
close_history: trailing(&hist),
fundamentals: BTreeMap::new(),
news: Vec::new(),
}
})
.collect();
self.observation(agent, &date, symbols)
})
.collect()
}
pub fn step(&mut self, agent_orders: &[Vec<f64>], params: &MarketParams) -> ClearResult {
let exo_mid = self.exo_mid_at_cursor();
clear_bar(&exo_mid, agent_orders, params, self)
}
fn observation(
&self,
agent: usize,
date: &str,
symbols: Vec<SymbolSnapshot>,
) -> MarketObservation {
let book = &self.agents[agent];
let portfolio = self
.symbols
.iter()
.enumerate()
.map(|(s, sym)| {
let shares = book.shares[s];
let avg_price = if shares.abs() > EPS {
(book.cost_basis[s] / shares).abs()
} else {
0.0
};
PositionState {
symbol: sym.clone(),
shares,
avg_price,
}
})
.collect();
MarketObservation {
date: date.to_string(),
cash: book.cash,
symbols,
portfolio,
}
}
}
fn trailing(series: &[f64]) -> Vec<f64> {
let start = series.len().saturating_sub(LOOKBACK);
series[start..].to_vec()
}
pub fn clear_bar(
exo_mid: &[f64],
agent_orders: &[Vec<f64>],
params: &MarketParams,
state: &mut MarketClearing,
) -> ClearResult {
let n_sym = state.symbols.len();
let n_agents = state.agents.len();
assert_eq!(exo_mid.len(), n_sym, "exo_mid must cover every symbol");
assert_eq!(agent_orders.len(), n_agents, "one order vector per agent");
for orders in agent_orders {
assert_eq!(
orders.len(),
n_sym,
"each order vector must cover every symbol"
);
}
let v = params.volume_scale;
let cleared_mid: Vec<f64> = exo_mid
.iter()
.zip(&state.impact_mult)
.map(|(m, mult)| m * mult)
.collect();
let vol_factor: Vec<f64> = (0..n_sym)
.map(|s| {
if params.vol_scale > 0.0 {
let f = 1.0 + params.vol_scale * state.vol[s].proxy();
if f > VOL_FACTOR_CAP {
VOL_FACTOR_CAP
} else {
f
}
} else {
1.0
}
})
.collect();
let q: Vec<Vec<f64>> = agent_orders
.iter()
.enumerate()
.map(|(i, orders)| {
let prev = &state.agents[i].prev_weight;
orders
.iter()
.zip(prev)
.zip(&cleared_mid)
.map(|((w, pw), mid)| state.capital * (w - pw) / mid)
.collect()
})
.collect();
let mut net_flow = vec![0.0_f64; n_sym];
for agent_q in &q {
for (s, qis) in agent_q.iter().enumerate() {
net_flow[s] += qis;
}
}
let mut fills: Vec<Vec<AgentFill>> = Vec::with_capacity(n_agents);
let mut rewards = vec![0.0_f64; n_agents];
let mut navs = vec![0.0_f64; n_agents];
for i in 0..n_agents {
let nav_prev = {
let book = &state.agents[i];
book.cash
+ book
.shares
.iter()
.zip(&state.prev_mid)
.map(|(sh, m)| sh * m)
.sum::<f64>()
};
let mut agent_fills = Vec::with_capacity(n_sym);
for s in 0..n_sym {
let qi = q[i][s];
let mid = cleared_mid[s];
let fill =
mid * (1.0 + vol_factor[s] * (params.lambda * net_flow[s] + params.eta * qi) / v);
let sym = state.symbols[s].clone();
let book = &mut state.agents[i];
book.cash -= qi * fill;
let new_shares = book.shares[s] + qi;
if new_shares.abs() < EPS {
book.cost_basis[s] = 0.0;
} else {
book.cost_basis[s] += qi * fill;
}
book.shares[s] = new_shares;
book.prev_weight[s] = agent_orders[i][s];
agent_fills.push(AgentFill {
symbol: sym,
size: qi,
fill_price: fill,
});
}
let nav_post = {
let book = &state.agents[i];
book.cash
+ book
.shares
.iter()
.zip(&cleared_mid)
.map(|(sh, m)| sh * m)
.sum::<f64>()
};
navs[i] = nav_post;
rewards[i] = if nav_prev.abs() > EPS {
(nav_post - nav_prev) / nav_prev
} else {
0.0
};
fills.push(agent_fills);
}
for (hist, mid) in state.cleared_history.iter_mut().zip(&cleared_mid) {
hist.push(*mid);
}
let date = state.dates.get(state.cursor).cloned().unwrap_or_default();
let observations: Vec<MarketObservation> = (0..n_agents)
.map(|agent| {
let symbols = state
.symbols
.iter()
.enumerate()
.map(|(s, sym)| SymbolSnapshot {
symbol: sym.clone(),
close_history: trailing(&state.cleared_history[s]),
fundamentals: BTreeMap::new(),
news: Vec::new(),
})
.collect();
state.observation(agent, &date, symbols)
})
.collect();
for (mult, flow) in state.impact_mult.iter_mut().zip(&net_flow) {
*mult *= 1.0 + params.lambda * flow / v;
}
for (s, mid) in cleared_mid.iter().enumerate() {
let prev = state.prev_mid[s];
if prev.abs() > EPS {
state.vol[s].push((mid - prev) / prev);
}
}
state.prev_mid.copy_from_slice(&cleared_mid);
state.cursor += 1;
let done = state.cursor >= state.n_bars;
ClearResult {
cleared_mids: cleared_mid,
net_flow,
rewards,
navs,
fills,
observations,
done,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn block(n_agents: usize, n_sym: usize, w: f64) -> Vec<Vec<f64>> {
vec![vec![w; n_sym]; n_agents]
}
#[test]
fn zero_flow_reproduces_the_exogenous_path() {
let data = Dataset::synthetic(3, 50, 4);
let params = MarketParams::default();
let mut m = MarketClearing::from_dataset(&data, 3, 1.0);
let flat = block(3, 3, 0.0);
loop {
let bar = m.cursor();
let r = m.step(&flat, ¶ms);
for (s, mid) in r.cleared_mids.iter().enumerate() {
let exo = data.close_at(&m.symbols()[s], bar).unwrap();
assert_eq!(
*mid, exo,
"flat flow must leave the cleared price == exogenous"
);
}
assert!(r.net_flow.iter().all(|f| *f == 0.0));
assert!(r.rewards.iter().all(|x| *x == 0.0));
if r.done {
break;
}
}
}
#[test]
fn a_coordinated_buy_lifts_the_cleared_price_above_exogenous() {
let data = Dataset::synthetic(2, 40, 6);
let params = MarketParams {
lambda: 0.5,
eta: 0.0,
volume_scale: 1.0,
vol_scale: 0.0,
};
let mut m = MarketClearing::from_dataset(&data, 4, 1.0);
let buy = block(4, 2, 0.8);
let entry = m.step(&buy, ¶ms);
assert!(
entry.net_flow.iter().all(|f| *f > 0.0),
"the entry bar must show positive net buy flow"
);
let bar = m.cursor();
let hold = m.step(&buy, ¶ms);
assert!(
hold.net_flow.iter().all(|f| f.abs() < EPS),
"no new flow once the target weight is reached"
);
for (s, mid) in hold.cleared_mids.iter().enumerate() {
let exo = data.close_at(&m.symbols()[s], bar).unwrap();
assert!(
*mid > exo,
"permanent impact must keep cleared {mid} above exogenous {exo}"
);
}
}
#[test]
fn permanent_impact_accumulates_under_sustained_flow() {
let data = Dataset::synthetic(1, 60, 5);
let params = MarketParams {
lambda: 0.2,
eta: 0.0,
volume_scale: 5.0,
vol_scale: 0.0,
};
let mut m = MarketClearing::from_dataset(&data, 2, 1.0);
let mut w = 0.0;
let mut ratios = Vec::new();
loop {
w += 0.2;
let bar = m.cursor();
let r = m.step(&vec![vec![w]; 2], ¶ms);
let exo = data.close_at(&m.symbols()[0], bar).unwrap();
ratios.push(r.cleared_mids[0] / exo);
if r.done || ratios.len() >= 20 {
break;
}
}
for win in ratios.windows(2) {
assert!(
win[1] >= win[0] - EPS,
"the impact multiplier must not shrink under sustained buying: {ratios:?}"
);
}
assert!(
*ratios.last().unwrap() > ratios[0] + 1e-9,
"sustained buying must lift the multiplier: {ratios:?}"
);
}
#[test]
fn identical_inputs_yield_identical_results() {
let data = Dataset::synthetic(3, 45, 12);
let params = MarketParams {
lambda: 0.3,
eta: 0.15,
volume_scale: 2.0,
vol_scale: 0.0,
};
let run = |weight: f64| {
let mut m = MarketClearing::from_dataset(&data, 3, 1.0);
let orders = block(3, 3, weight);
let mut log: Vec<(String, Vec<f64>, Vec<f64>)> = Vec::new();
loop {
let r = m.step(&orders, ¶ms);
log.push((
serde_json::to_string(&r.observations).unwrap(),
r.rewards.clone(),
r.cleared_mids.clone(),
));
if r.done {
break;
}
}
log
};
assert_eq!(run(0.5), run(0.5), "identical inputs must be identical");
assert_ne!(run(0.5), run(0.2), "different actions must diverge");
}
#[test]
fn aggregation_is_canonical_order_independent() {
let data = Dataset::synthetic(2, 40, 3);
let params = MarketParams {
lambda: 0.4,
eta: 0.1,
volume_scale: 1.0,
vol_scale: 0.0,
};
let agent_orders: Vec<Vec<f64>> = (0..4)
.map(|i| vec![0.1 * (i as f64 + 1.0), -0.05 * (i as f64)])
.collect();
let mut direct = MarketClearing::from_dataset(&data, 4, 1.0);
let rd = direct.step(&agent_orders, ¶ms);
let mut map: BTreeMap<usize, Vec<f64>> = BTreeMap::new();
for i in (0..4).rev() {
map.insert(i, agent_orders[i].clone());
}
let reassembled: Vec<Vec<f64>> = map.into_values().collect();
let mut shuffled = MarketClearing::from_dataset(&data, 4, 1.0);
let rs = shuffled.step(&reassembled, ¶ms);
assert_eq!(rd.net_flow, rs.net_flow);
assert_eq!(rd.cleared_mids, rs.cleared_mids);
assert_eq!(
serde_json::to_string(&rd.observations).unwrap(),
serde_json::to_string(&rs.observations).unwrap()
);
}
#[test]
fn peer_order_does_not_leak_into_own_sizing_or_cleared_price() {
let data = Dataset::synthetic(2, 40, 8);
let params = MarketParams {
lambda: 0.5,
eta: 0.2,
volume_scale: 1.0,
vol_scale: 0.0,
};
let mut m1 = MarketClearing::from_dataset(&data, 2, 1.0);
let mut m2 = MarketClearing::from_dataset(&data, 2, 1.0);
let with_flat_peer = vec![vec![0.3, 0.0], vec![0.0, 0.0]];
let with_buying_peer = vec![vec![0.3, 0.0], vec![0.9, 0.5]];
let r1 = m1.step(&with_flat_peer, ¶ms);
let r2 = m2.step(&with_buying_peer, ¶ms);
assert_eq!(
r1.cleared_mids, r2.cleared_mids,
"the cleared mid at t embeds only prior-bar flow, so a peer's t-order can't move it"
);
let sizes1: Vec<f64> = r1.fills[0].iter().map(|f| f.size).collect();
let sizes2: Vec<f64> = r2.fills[0].iter().map(|f| f.size).collect();
assert_eq!(
sizes1, sizes2,
"agent 0's traded size depends only on its own weights and the cleared mid"
);
let px1: Vec<f64> = r1.fills[0].iter().map(|f| f.fill_price).collect();
let px2: Vec<f64> = r2.fills[0].iter().map(|f| f.fill_price).collect();
assert_ne!(
px1, px2,
"the realized fill price reflects aggregate flow — impact, not a leak"
);
}
#[test]
fn initial_observation_has_warmup_history_and_no_positions() {
let data = Dataset::synthetic(3, 60, 1);
let m = MarketClearing::from_dataset(&data, 2, 1.0);
let obs = m.initial_observations();
assert_eq!(obs.len(), 2);
for o in &obs {
assert_eq!(o.cash, 1.0);
assert!(o.portfolio.iter().all(|p| p.shares == 0.0));
for snap in &o.symbols {
assert!(
!snap.close_history.is_empty(),
"warm-up history must be present"
);
let last = *snap.close_history.last().unwrap();
assert_eq!(last, data.close_at(&snap.symbol, m.start_bar()).unwrap());
}
}
}
#[test]
fn done_flips_on_the_final_bar() {
let data = Dataset::synthetic(2, 24, 2);
let mut m = MarketClearing::from_dataset(&data, 2, 1.0);
let flat = block(2, 2, 0.0);
let mut steps = 0;
loop {
let r = m.step(&flat, ¶ms_default());
steps += 1;
assert_eq!(r.observations.len(), 2);
if r.done {
break;
}
}
assert_eq!(steps, m.n_bars() - m.start_bar());
assert!(m.is_done());
}
fn params_default() -> MarketParams {
MarketParams::default()
}
fn dataset_from_closes(closes: Vec<f64>) -> Dataset {
let dates = (0..closes.len()).map(|i| format!("t{i}")).collect();
let mut map = BTreeMap::new();
map.insert("AAA".to_string(), closes);
Dataset {
dates,
closes: map,
dividends: BTreeMap::new(),
}
}
#[test]
fn vol_scale_zero_matches_the_legacy_fill_formula() {
let data = Dataset::synthetic(2, 40, 3);
let params = MarketParams {
lambda: 0.5,
eta: 0.25,
volume_scale: 2.0,
vol_scale: 0.0,
};
let mut m = MarketClearing::from_dataset(&data, 3, 1.0);
let orders = block(3, 2, 0.6);
loop {
let r = m.step(&orders, ¶ms);
for fills in &r.fills {
for (s, f) in fills.iter().enumerate() {
let expected = r.cleared_mids[s]
* (1.0
+ (params.lambda * r.net_flow[s] + params.eta * f.size)
/ params.volume_scale);
assert_eq!(
f.fill_price, expected,
"vol_scale=0 must be the legacy fill"
);
}
}
if r.done {
break;
}
}
}
#[test]
fn vol_scaling_widens_fills_more_in_a_high_vol_stretch() {
let calm = dataset_from_closes((0..30).map(|i| 100.0 + i as f64 * 0.01).collect());
let volatile = dataset_from_closes(
(0..30)
.map(|i| if i % 2 == 0 { 100.0 } else { 125.0 })
.collect(),
);
let base = MarketParams {
lambda: 0.4,
eta: 0.2,
volume_scale: 1.0,
vol_scale: 0.0,
};
let scaled = MarketParams {
vol_scale: 10.0,
..base
};
let buy = block(2, 1, 0.8);
let widening = |data: &Dataset| {
let mut m0 = MarketClearing::from_dataset(data, 2, 1.0);
let mut mv = MarketClearing::from_dataset(data, 2, 1.0);
let r0 = m0.step(&buy, &base);
let rv = mv.step(&buy, &scaled);
let mid = r0.cleared_mids[0];
assert_eq!(
mid, rv.cleared_mids[0],
"vol scaling must not move the cleared mid"
);
let base_impact = r0.fills[0][0].fill_price - mid;
assert!(base_impact.abs() > EPS, "the entry bar must actually trade");
(rv.fills[0][0].fill_price - mid) / base_impact
};
let calm_factor = widening(&calm);
let vol_factor = widening(&volatile);
assert!(
calm_factor >= 1.0 - EPS,
"the factor never shrinks impact: {calm_factor}"
);
assert!(
vol_factor > calm_factor + 1e-6,
"a high-vol stretch must widen fills more than a calm one: \
vol={vol_factor} calm={calm_factor}"
);
}
#[test]
fn the_vol_factor_is_capped() {
let volatile = dataset_from_closes(
(0..30)
.map(|i| if i % 2 == 0 { 100.0 } else { 140.0 })
.collect(),
);
let base = MarketParams {
lambda: 0.4,
eta: 0.2,
volume_scale: 1.0,
vol_scale: 0.0,
};
let huge = MarketParams {
vol_scale: 1.0e9,
..base
};
let buy = block(2, 1, 0.8);
let mut m0 = MarketClearing::from_dataset(&volatile, 2, 1.0);
let mut mh = MarketClearing::from_dataset(&volatile, 2, 1.0);
let r0 = m0.step(&buy, &base);
let rh = mh.step(&buy, &huge);
let mid = r0.cleared_mids[0];
let factor = (rh.fills[0][0].fill_price - mid) / (r0.fills[0][0].fill_price - mid);
assert!(
factor <= VOL_FACTOR_CAP + 1e-9,
"the widening factor must be capped at {VOL_FACTOR_CAP}: {factor}"
);
assert!(
(factor - VOL_FACTOR_CAP).abs() < 1e-6,
"an extreme vol_scale must saturate the cap: {factor}"
);
}
#[test]
fn vol_scaled_clearing_is_deterministic() {
let data = Dataset::synthetic(2, 45, 9);
let params = MarketParams {
lambda: 0.3,
eta: 0.15,
volume_scale: 2.0,
vol_scale: 4.0,
};
let run = || {
let mut m = MarketClearing::from_dataset(&data, 3, 1.0);
let orders = block(3, 2, 0.4);
let mut log: Vec<(Vec<f64>, String)> = Vec::new();
loop {
let r = m.step(&orders, ¶ms);
let px: Vec<f64> = r.fills.iter().flatten().map(|f| f.fill_price).collect();
log.push((px, serde_json::to_string(&r.observations).unwrap()));
if r.done {
break;
}
}
log
};
assert_eq!(run(), run(), "vol-scaled clearing must be deterministic");
}
}