use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use crate::richness::ObservationRichness;
use crate::{Dataset, MarketObservation, PositionState, SymbolSnapshot};
const WARMUP: usize = 20;
const EPS: f64 = 1e-12;
const NEWS_THRESHOLD: f64 = 0.02;
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, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ImpactCoefficients {
pub lambda: f64,
pub eta: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct EllipticUncertaintySet {
pub lambda_radius: f64,
pub eta_radius: f64,
pub correlation: f64,
}
impl EllipticUncertaintySet {
pub fn new(lambda_radius: f64, eta_radius: f64, correlation: f64) -> Self {
assert!(
lambda_radius >= 0.0 && eta_radius >= 0.0,
"uncertainty radii must be non-negative"
);
assert!(
(-1.0..=1.0).contains(&correlation),
"correlation must lie in [-1, 1]"
);
Self {
lambda_radius,
eta_radius,
correlation,
}
}
pub fn isotropic(radius: f64) -> Self {
Self::new(radius, radius, 0.0)
}
pub fn worst_case(
&self,
params: &MarketParams,
cost_lambda: f64,
cost_eta: f64,
) -> ImpactCoefficients {
let point = ImpactCoefficients {
lambda: params.lambda,
eta: params.eta,
};
let a = self.lambda_radius;
let b = self.eta_radius;
let cross = self.correlation * a * b;
let sc_lambda = a * a * cost_lambda + cross * cost_eta;
let sc_eta = cross * cost_lambda + b * b * cost_eta;
let quad = cost_lambda * sc_lambda + cost_eta * sc_eta;
if quad <= 0.0 {
return point;
}
let norm = quad.sqrt();
ImpactCoefficients {
lambda: floor_at_zero(point.lambda + sc_lambda / norm),
eta: floor_at_zero(point.eta + sc_eta / norm),
}
}
}
fn floor_at_zero(x: f64) -> f64 {
if x > 0.0 {
x
} else {
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,
#[serde(skip_serializing_if = "Option::is_none")]
pub robust_impact: Option<Vec<ImpactCoefficients>>,
}
#[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,
richness: ObservationRichness,
}
impl MarketClearing {
pub fn from_dataset(data: &Dataset, n_agents: usize, capital: f64) -> Self {
Self::from_dataset_with_richness(data, n_agents, capital, ObservationRichness::default())
}
pub fn from_dataset_with_richness(
data: &Dataset,
n_agents: usize,
capital: f64,
richness: ObservationRichness,
) -> 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,
richness,
}
}
pub fn richness(&self) -> ObservationRichness {
self.richness
}
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)]);
self.snapshot(sym, &hist)
})
.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)
}
pub fn step_robust(
&mut self,
agent_orders: &[Vec<f64>],
params: &MarketParams,
uncertainty: Option<&EllipticUncertaintySet>,
) -> ClearResult {
let exo_mid = self.exo_mid_at_cursor();
clear_bar_robust(&exo_mid, agent_orders, params, uncertainty, 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 snapshot(&self, symbol: &str, full_history: &[f64]) -> SymbolSnapshot {
let close_history = trailing(full_history, self.richness.lookback);
let fundamentals = if self.richness.fundamentals {
derive_fundamentals(&close_history)
} else {
BTreeMap::new()
};
let news = if self.richness.news {
derive_news(symbol, &close_history)
} else {
Vec::new()
};
SymbolSnapshot {
symbol: symbol.to_string(),
close_history,
fundamentals,
news,
}
}
}
fn trailing(series: &[f64], lookback: usize) -> Vec<f64> {
let start = series.len().saturating_sub(lookback);
series[start..].to_vec()
}
fn derive_fundamentals(closes: &[f64]) -> BTreeMap<String, f64> {
let mut map = BTreeMap::new();
if closes.len() < 2 {
return map;
}
let first = closes[0];
let last = closes[closes.len() - 1];
let trailing_return = if first.abs() > EPS {
last / first - 1.0
} else {
0.0
};
let mut high = closes[0];
let mut low = closes[0];
for &c in &closes[1..] {
if c > high {
high = c;
}
if c < low {
low = c;
}
}
map.insert("trailing_return".to_string(), trailing_return);
map.insert("window_high".to_string(), high);
map.insert("window_low".to_string(), low);
map
}
fn derive_news(symbol: &str, closes: &[f64]) -> Vec<String> {
if closes.len() < 2 {
return Vec::new();
}
let first = closes[0];
let last = closes[closes.len() - 1];
let ret = if first.abs() > EPS {
last / first - 1.0
} else {
0.0
};
let pct = ret * 100.0;
let bars = closes.len();
let headline = if ret > NEWS_THRESHOLD {
format!("{symbol}: uptrend, {pct:+.2}% over {bars} bars")
} else if ret < -NEWS_THRESHOLD {
format!("{symbol}: downtrend, {pct:+.2}% over {bars} bars")
} else {
format!("{symbol}: range-bound, {pct:+.2}% over {bars} bars")
};
vec![headline]
}
pub fn clear_bar(
exo_mid: &[f64],
agent_orders: &[Vec<f64>],
params: &MarketParams,
state: &mut MarketClearing,
) -> ClearResult {
clear_bar_robust(exo_mid, agent_orders, params, None, state)
}
pub fn clear_bar_robust(
exo_mid: &[f64],
agent_orders: &[Vec<f64>],
params: &MarketParams,
uncertainty: Option<&EllipticUncertaintySet>,
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 robust_impact: Option<Vec<ImpactCoefficients>> = uncertainty.map(|set| {
(0..n_sym)
.map(|s| {
let mut own_cost = 0.0_f64;
for agent_q in &q {
own_cost += agent_q[s] * agent_q[s];
}
set.worst_case(params, net_flow[s] * net_flow[s], own_cost)
})
.collect()
});
let impact: Vec<ImpactCoefficients> = match &robust_impact {
Some(resolved) => resolved.clone(),
None => vec![
ImpactCoefficients {
lambda: params.lambda,
eta: params.eta,
};
n_sym
],
};
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] * (impact[s].lambda * net_flow[s] + impact[s].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)| state.snapshot(sym, &state.cleared_history[s]))
.collect();
state.observation(agent, &date, symbols)
})
.collect();
for (s, (mult, flow)) in state.impact_mult.iter_mut().zip(&net_flow).enumerate() {
*mult *= 1.0 + impact[s].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,
robust_impact,
}
}
#[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");
}
fn result_blob(r: &ClearResult) -> String {
serde_json::to_string(r).unwrap()
}
fn robust_rollout(
data: &Dataset,
n_agents: usize,
params: &MarketParams,
uncertainty: Option<&EllipticUncertaintySet>,
orders: &[Vec<f64>],
) -> Vec<String> {
let mut m = MarketClearing::from_dataset(data, n_agents, 1.0);
let mut log = Vec::new();
loop {
let r = m.step_robust(orders, params, uncertainty);
log.push(result_blob(&r));
if r.done {
break;
}
}
log
}
#[test]
fn absent_uncertainty_set_is_byte_identical_to_the_point_estimate() {
let data = Dataset::synthetic(3, 60, 17);
let params = MarketParams {
lambda: 0.35,
eta: 0.18,
volume_scale: 2.0,
vol_scale: 3.0,
};
let orders: Vec<Vec<f64>> = (0..3)
.map(|i| vec![0.2 * (i as f64 + 1.0), -0.1 * (i as f64), 0.05])
.collect();
let mut legacy_market = MarketClearing::from_dataset(&data, 3, 1.0);
let mut legacy = Vec::new();
loop {
let r = legacy_market.step(&orders, ¶ms);
assert!(
r.robust_impact.is_none(),
"the point-estimate path reports no resolved coefficients"
);
legacy.push(result_blob(&r));
if r.done {
break;
}
}
let robust_none = robust_rollout(&data, 3, ¶ms, None, &orders);
assert_eq!(
legacy, robust_none,
"clear_bar_robust(None) must be byte-identical to clear_bar"
);
}
#[test]
fn the_point_estimate_wire_shape_omits_the_robust_field() {
let data = Dataset::synthetic(2, 30, 4);
let mut m = MarketClearing::from_dataset(&data, 2, 1.0);
let r = m.step(&block(2, 2, 0.4), &MarketParams::default());
assert!(!result_blob(&r).contains("robust_impact"));
}
#[test]
fn a_zero_radius_set_reproduces_the_point_estimate_path() {
let data = Dataset::synthetic(2, 40, 11);
let params = MarketParams {
lambda: 0.3,
eta: 0.12,
volume_scale: 1.5,
vol_scale: 0.0,
};
let orders = block(3, 2, 0.5);
let set = EllipticUncertaintySet::isotropic(0.0);
let point = robust_rollout(&data, 3, ¶ms, None, &orders);
let mut m = MarketClearing::from_dataset(&data, 3, 1.0);
let mut bar = 0;
loop {
let r = m.step_robust(&orders, ¶ms, Some(&set));
for coefficients in r.robust_impact.as_ref().unwrap() {
assert_eq!(coefficients.lambda, params.lambda);
assert_eq!(coefficients.eta, params.eta);
}
let mut value = serde_json::to_value(&r).unwrap();
value.as_object_mut().unwrap().remove("robust_impact");
let expected: serde_json::Value = serde_json::from_str(&point[bar]).unwrap();
assert_eq!(
value, expected,
"a zero-radius set must clear the point-estimate path"
);
bar += 1;
if r.done {
break;
}
}
}
#[test]
fn a_no_flow_bar_falls_back_to_the_point_estimate() {
let data = Dataset::synthetic(2, 30, 6);
let params = MarketParams::default();
let set = EllipticUncertaintySet::new(0.5, 0.25, 0.4);
let mut m = MarketClearing::from_dataset(&data, 2, 1.0);
let flat = block(2, 2, 0.0);
loop {
let bar = m.cursor();
let r = m.step_robust(&flat, ¶ms, Some(&set));
for coefficients in r.robust_impact.as_ref().unwrap() {
assert_eq!(coefficients.lambda, params.lambda);
assert_eq!(coefficients.eta, params.eta);
}
for (s, mid) in r.cleared_mids.iter().enumerate() {
assert_eq!(*mid, data.close_at(&m.symbols()[s], bar).unwrap());
}
if r.done {
break;
}
}
}
#[test]
fn the_worst_case_attains_the_support_function_of_the_ellipse() {
let params = MarketParams {
lambda: 0.4,
eta: 0.2,
volume_scale: 1.0,
vol_scale: 0.0,
};
let set = EllipticUncertaintySet::new(0.1, 0.06, 0.3);
let (cl, ce) = (4.0, 0.75);
let wc = set.worst_case(¶ms, cl, ce);
let (a, b, rho) = (set.lambda_radius, set.eta_radius, set.correlation);
let quad = a * a * cl * cl + 2.0 * rho * a * b * cl * ce + b * b * ce * ce;
let attained = cl * wc.lambda + ce * wc.eta;
let expected = cl * params.lambda + ce * params.eta + quad.sqrt();
assert!(
(attained - expected).abs() < 1e-12,
"attained {attained} != support {expected}"
);
}
#[test]
fn no_point_in_the_ellipse_costs_more_than_the_worst_case() {
let params = MarketParams {
lambda: 0.5,
eta: 0.25,
volume_scale: 1.0,
vol_scale: 0.0,
};
for &rho in &[-0.8_f64, -0.25, 0.0, 0.25, 0.8] {
let set = EllipticUncertaintySet::new(0.12, 0.05, rho);
let (cl, ce) = (2.5_f64, 0.4_f64);
let wc = set.worst_case(¶ms, cl, ce);
let best = cl * wc.lambda + ce * wc.eta;
let (a, b) = (set.lambda_radius, set.eta_radius);
let l10 = rho * b;
let l11 = b * (1.0 - rho * rho).sqrt();
for k in -200..=200 {
let t = k as f64 / 50.0;
let denom = 1.0 + t * t;
let u = (1.0 - t * t) / denom;
let v = 2.0 * t / denom;
let lambda = params.lambda + a * u;
let eta = params.eta + l10 * u + l11 * v;
let cost = cl * lambda + ce * eta;
assert!(
cost <= best + 1e-9,
"rho={rho}: boundary point costs {cost} > worst case {best}"
);
}
}
}
#[test]
fn a_positively_correlated_set_raises_both_coefficients() {
let params = MarketParams {
lambda: 0.3,
eta: 0.15,
volume_scale: 1.0,
vol_scale: 0.0,
};
let set = EllipticUncertaintySet::new(0.08, 0.04, 0.6);
let wc = set.worst_case(¶ms, 9.0, 2.0);
assert!(wc.lambda > params.lambda, "lambda must widen: {wc:?}");
assert!(wc.eta > params.eta, "eta must widen: {wc:?}");
}
#[test]
fn a_negative_correlation_is_not_a_box_corner() {
let params = MarketParams {
lambda: 0.3,
eta: 0.15,
volume_scale: 1.0,
vol_scale: 0.0,
};
let set = EllipticUncertaintySet::new(0.08, 0.04, -0.9);
let wc = set.worst_case(¶ms, 25.0, 0.5);
assert!(wc.lambda > params.lambda, "lambda still widens: {wc:?}");
assert!(
wc.eta < params.eta,
"a negatively correlated set must trade eta off against lambda: {wc:?}"
);
}
#[test]
fn resolved_coefficients_never_go_negative() {
let params = MarketParams {
lambda: 0.01,
eta: 0.01,
volume_scale: 1.0,
vol_scale: 0.0,
};
let set = EllipticUncertaintySet::new(5.0, 5.0, -1.0);
let wc = set.worst_case(¶ms, 1.0, 4.0);
assert!(wc.lambda >= 0.0 && wc.eta >= 0.0, "no rebates: {wc:?}");
}
#[test]
fn a_set_makes_the_market_strictly_more_expensive_to_trade() {
let data = Dataset::synthetic(2, 40, 21);
let params = MarketParams {
lambda: 0.3,
eta: 0.15,
volume_scale: 1.0,
vol_scale: 0.0,
};
let set = EllipticUncertaintySet::isotropic(0.05);
let buy = block(3, 2, 0.7);
let mut point_market = MarketClearing::from_dataset(&data, 3, 1.0);
let mut robust_market = MarketClearing::from_dataset(&data, 3, 1.0);
let point = point_market.step(&buy, ¶ms);
let robust = robust_market.step_robust(&buy, ¶ms, Some(&set));
assert_eq!(
point.cleared_mids, robust.cleared_mids,
"the set must not move the reference mid (it embeds only prior-bar flow)"
);
for (pf, rf) in point.fills.iter().zip(&robust.fills) {
for (p, r) in pf.iter().zip(rf) {
assert_eq!(p.size, r.size, "sizing is unchanged by the set");
assert!(
r.fill_price > p.fill_price,
"a buyer must pay strictly more under the worst case: {} vs {}",
r.fill_price,
p.fill_price
);
}
}
for (p, r) in point.rewards.iter().zip(&robust.rewards) {
assert!(r < p, "the robust bar return must be worse: {r} vs {p}");
}
}
#[test]
fn the_resolved_lambda_also_drives_the_permanent_multiplier() {
let data = Dataset::synthetic(1, 40, 13);
let params = MarketParams {
lambda: 0.2,
eta: 0.0,
volume_scale: 4.0,
vol_scale: 0.0,
};
let set = EllipticUncertaintySet::new(0.1, 0.0, 0.0);
let buy = block(2, 1, 0.9);
let mut point_market = MarketClearing::from_dataset(&data, 2, 1.0);
let mut robust_market = MarketClearing::from_dataset(&data, 2, 1.0);
point_market.step(&buy, ¶ms);
robust_market.step_robust(&buy, ¶ms, Some(&set));
let point = point_market.step(&buy, ¶ms);
let robust = robust_market.step_robust(&buy, ¶ms, Some(&set));
assert!(
robust.cleared_mids[0] > point.cleared_mids[0],
"the worst-case lambda must carry into the reference price: {} vs {}",
robust.cleared_mids[0],
point.cleared_mids[0]
);
}
#[test]
fn robust_impact_is_reported_per_symbol_and_varies_with_flow() {
let data = Dataset::synthetic(2, 30, 5);
let params = MarketParams {
lambda: 0.3,
eta: 0.15,
volume_scale: 1.0,
vol_scale: 0.0,
};
let set = EllipticUncertaintySet::new(0.09, 0.02, 0.0);
let mut m = MarketClearing::from_dataset(&data, 2, 1.0);
let orders = vec![vec![0.9, 0.5], vec![-0.9, 0.5]];
let r = m.step_robust(&orders, ¶ms, Some(&set));
let resolved = r.robust_impact.as_ref().unwrap();
assert_eq!(resolved.len(), 2, "one coefficient pair per symbol");
assert_eq!(r.net_flow[0], 0.0, "the crossed symbol has no net flow");
assert_eq!(
resolved[0].lambda, params.lambda,
"with no net flow the cost direction is pure eta, so lambda is not stressed"
);
assert!(
resolved[0].eta > params.eta,
"the crossed symbol still stresses eta: {resolved:?}"
);
assert!(
resolved[1].lambda > params.lambda,
"the one-sided symbol stresses lambda too: {resolved:?}"
);
assert_ne!(
resolved[0], resolved[1],
"different per-symbol flow must resolve differently: {resolved:?}"
);
}
#[test]
fn robust_clearing_is_deterministic() {
let data = Dataset::synthetic(3, 50, 8);
let params = MarketParams {
lambda: 0.25,
eta: 0.1,
volume_scale: 2.0,
vol_scale: 2.0,
};
let set = EllipticUncertaintySet::new(0.07, 0.03, -0.4);
let orders = block(3, 3, 0.45);
let run = || robust_rollout(&data, 3, ¶ms, Some(&set), &orders);
assert_eq!(run(), run(), "robust clearing must be deterministic");
}
#[test]
#[should_panic(expected = "correlation must lie in")]
fn an_out_of_range_correlation_is_rejected() {
EllipticUncertaintySet::new(0.1, 0.1, 1.5);
}
#[test]
#[should_panic(expected = "radii must be non-negative")]
fn a_negative_radius_is_rejected() {
EllipticUncertaintySet::new(-0.1, 0.1, 0.0);
}
use crate::richness::{ObservationRichness, RichnessTier};
fn rollout_observations(mut m: MarketClearing, orders: &[Vec<f64>]) -> Vec<String> {
let params = MarketParams::default();
let mut log = vec![serde_json::to_string(&m.initial_observations()).unwrap()];
loop {
let r = m.step(orders, ¶ms);
log.push(serde_json::to_string(&r.observations).unwrap());
if r.done {
break;
}
}
log
}
#[test]
fn default_richness_is_byte_identical_to_standard_tier() {
let data = Dataset::synthetic(3, 60, 4);
let orders = block(2, 3, 0.3);
let default_log =
rollout_observations(MarketClearing::from_dataset(&data, 2, 1.0), &orders);
let standard_log = rollout_observations(
MarketClearing::from_dataset_with_richness(
&data,
2,
1.0,
RichnessTier::Standard.richness(),
),
&orders,
);
assert_eq!(
default_log, standard_log,
"Standard richness must reproduce the default observation stream byte-for-byte"
);
}
#[test]
fn data_poor_shows_fewer_bars_and_withholds_optional_fields() {
let data = Dataset::synthetic(2, 60, 7);
let m = MarketClearing::from_dataset_with_richness(
&data,
2,
1.0,
RichnessTier::DataPoor.richness(),
);
for obs in m.initial_observations() {
for snap in &obs.symbols {
assert!(
snap.close_history.len() <= 3,
"DataPoor caps the trailing history at 3 bars, got {}",
snap.close_history.len()
);
assert!(
snap.fundamentals.is_empty(),
"DataPoor withholds fundamentals"
);
assert!(snap.news.is_empty(), "DataPoor withholds news");
}
}
}
#[test]
fn data_rich_shows_more_bars_and_populates_optional_fields() {
let data = Dataset::synthetic(2, 120, 9);
let rich = MarketClearing::from_dataset_with_richness(
&data,
2,
1.0,
RichnessTier::DataRich.richness(),
);
let standard = MarketClearing::from_dataset(&data, 2, 1.0);
let rich_obs = rich.initial_observations();
let std_obs = standard.initial_observations();
for (ro, so) in rich_obs.iter().zip(&std_obs) {
for (rs, ss) in ro.symbols.iter().zip(&so.symbols) {
assert!(
rs.close_history.len() > ss.close_history.len(),
"DataRich must surface strictly more history than Standard: {} vs {}",
rs.close_history.len(),
ss.close_history.len()
);
assert!(
rs.close_history.len() <= 50,
"DataRich caps the trailing history at 50 bars"
);
assert!(rs.fundamentals.contains_key("trailing_return"));
assert!(rs.fundamentals.contains_key("window_high"));
assert!(rs.fundamentals.contains_key("window_low"));
assert_eq!(rs.news.len(), 1);
assert!(rs.news[0].contains(&rs.symbol));
}
}
}
#[test]
fn every_tier_is_leak_free_never_surfaces_a_future_bar() {
let data = Dataset::synthetic(3, 80, 2);
let orders = block(2, 3, 0.25);
for tier in RichnessTier::all() {
let mut m = MarketClearing::from_dataset_with_richness(&data, 2, 1.0, tier.richness());
let params = MarketParams::default();
let mut cleared_bars = m.start_bar(); loop {
let r = m.step(&orders, ¶ms);
cleared_bars += 1;
for obs in &r.observations {
for (s, snap) in obs.symbols.iter().enumerate() {
assert_eq!(
*snap.close_history.last().unwrap(),
r.cleared_mids[s],
"{tier:?}: the last surfaced close must be this bar's cleared mid"
);
assert!(
snap.close_history.len() <= cleared_bars,
"{tier:?}: cannot surface more closes than have been cleared"
);
}
}
if r.done {
break;
}
}
}
}
#[test]
fn richness_clearing_stays_deterministic() {
let data = Dataset::synthetic(2, 50, 5);
let orders = block(2, 2, 0.4);
let run = || {
rollout_observations(
MarketClearing::from_dataset_with_richness(
&data,
2,
1.0,
RichnessTier::DataRich.richness(),
),
&orders,
)
};
assert_eq!(run(), run(), "DataRich clearing must be deterministic");
}
#[test]
fn custom_richness_overrides_lookback_independently_of_fields() {
let data = Dataset::synthetic(1, 60, 3);
let m = MarketClearing::from_dataset_with_richness(
&data,
1,
1.0,
ObservationRichness {
lookback: 7,
fundamentals: true,
news: false,
},
);
for snap in &m.initial_observations()[0].symbols {
assert!(snap.close_history.len() <= 7);
assert!(!snap.fundamentals.is_empty(), "fundamentals flag honored");
assert!(snap.news.is_empty(), "news flag honored independently");
}
}
}