use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fmt;
use std::sync::Arc;
use wickra_core::{
CrossSection as CoreCrossSection, DerivativesTick as CoreDerivativesTick,
OrderBook as CoreOrderBook, Trade as CoreTrade,
};
use crate::data::{Candle, CrossSection, DerivativesTick, OrderBook, TradePrint};
use crate::error::{BacktestError, Result};
use crate::metrics;
use crate::portfolio::Portfolio;
use crate::registry::{self, BarInput, EvalIndicator};
use crate::report::{BacktestReport, EquityPoint, REPORT_SCHEMA_VERSION};
use crate::rules::{condition_lookback, eval_condition, BarRow, RuleState};
use crate::spec::{Execution, FillTiming, OrderType, Risk, Sizing, Slippage, StrategySpec};
pub const DEFAULT_CAPITAL: f64 = 10_000.0;
#[derive(Debug, Clone, Copy)]
enum Side {
Long,
Short,
}
#[derive(Debug, Clone, Copy)]
enum LevelKind {
Limit,
Stop,
StopLimit {
limit: f64,
},
}
#[derive(Debug)]
enum Action {
Enter {
side: Side,
trigger: Option<(f64, LevelKind)>,
},
Exit(&'static str),
}
#[derive(Debug)]
struct Pending {
action: Action,
delay: u32,
}
fn level_fill(side: Side, trigger: f64, kind: LevelKind, c: &Candle) -> Option<f64> {
let is_buy = matches!(side, Side::Long);
match (is_buy, kind) {
(true, LevelKind::Limit) => (c.low <= trigger).then(|| c.open.min(trigger)),
(true, LevelKind::Stop) => (c.high >= trigger).then(|| c.open.max(trigger)),
(false, LevelKind::Limit) => (c.high >= trigger).then(|| c.open.max(trigger)),
(false, LevelKind::Stop) => (c.low <= trigger).then(|| c.open.min(trigger)),
(true, LevelKind::StopLimit { limit }) => {
(c.high >= trigger && c.low <= limit).then(|| limit.min(c.open.max(trigger)))
}
(false, LevelKind::StopLimit { limit }) => {
(c.low <= trigger && c.high >= limit).then(|| limit.max(c.open.min(trigger)))
}
}
}
fn entry_trigger(exec: &Execution, signal_close: f64) -> Option<(f64, LevelKind)> {
match exec.order_type {
OrderType::Limit => Some((
signal_close * (1.0 + exec.limit_offset_pct.unwrap_or(0.0) / 100.0),
LevelKind::Limit,
)),
OrderType::Stop => Some((
signal_close * (1.0 + exec.stop_offset_pct.unwrap_or(0.0) / 100.0),
LevelKind::Stop,
)),
OrderType::StopLimit => Some((
signal_close * (1.0 + exec.stop_offset_pct.unwrap_or(0.0) / 100.0),
LevelKind::StopLimit {
limit: signal_close * (1.0 + exec.limit_offset_pct.unwrap_or(0.0) / 100.0),
},
)),
OrderType::Market => None,
}
}
fn realized_vol(history: &[BarRow], lookback: usize) -> Option<f64> {
if lookback < 2 || history.len() < lookback {
return None;
}
let closes: Vec<f64> = history[history.len() - lookback..]
.iter()
.map(|row| row.candle.close)
.collect();
let rets: Vec<f64> = closes
.windows(2)
.filter(|w| w[0].abs() > f64::EPSILON)
.map(|w| (w[1] - w[0]) / w[0])
.collect();
if rets.is_empty() {
return None;
}
let mean = rets.iter().sum::<f64>() / rets.len() as f64;
let var = rets.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / rets.len() as f64;
let sd = var.sqrt();
(sd > 0.0).then_some(sd)
}
fn slippage_rate(
slippage: Slippage,
orderbook: Option<&CoreOrderBook>,
qty: f64,
volume: f64,
) -> f64 {
match slippage {
Slippage::FixedBps { bps } => bps / 10_000.0,
Slippage::Spread => orderbook.map_or(0.0, |ob| match (ob.best_bid(), ob.best_ask()) {
(Some(bid), Some(ask)) => {
let mid = f64::midpoint(ask.price, bid.price);
if mid > 0.0 {
(ask.price - bid.price) / 2.0 / mid
} else {
0.0
}
}
_ => 0.0,
}),
Slippage::VolumeImpact { coef } => {
if volume > 0.0 {
coef * qty.abs() / volume
} else {
0.0
}
}
}
}
struct FillCtx<'a> {
spec: &'a StrategySpec,
candle: &'a Candle,
history: &'a [BarRow],
orderbook: Option<&'a CoreOrderBook>,
maker: f64,
taker: f64,
bar: usize,
}
fn execute_entry(
side: Side,
raw_price: f64,
maker_fill: bool,
ctx: &FillCtx,
pf: &mut Portfolio,
entry_bar: &mut Option<usize>,
extreme: &mut f64,
) -> Result<()> {
let dir = match side {
Side::Long => 1.0,
Side::Short => -1.0,
};
let rv = match ctx.spec.sizing {
Sizing::VolTarget { lookback, .. } => realized_vol(ctx.history, lookback as usize),
_ => None,
};
let probe_qty = match ctx.spec.costs.slippage {
Slippage::VolumeImpact { .. } => {
size(ctx.spec.sizing, &ctx.spec.risk, pf.cash, raw_price, rv)?.unwrap_or(0.0)
}
_ => 0.0,
};
let slip = slippage_rate(
ctx.spec.costs.slippage,
ctx.orderbook,
probe_qty,
ctx.candle.volume,
);
let fill = raw_price * (1.0 + dir * slip);
if let Some(base) = size(ctx.spec.sizing, &ctx.spec.risk, pf.cash, fill, rv)? {
let base = if ctx.spec.execution.partial_fills {
let cap = ctx.spec.execution.max_participation.unwrap_or(0.0) * ctx.candle.volume;
base.min(cap)
} else {
base
};
if base > 0.0 {
let rate = if maker_fill { ctx.maker } else { ctx.taker };
let fee = base * fill * rate;
pf.enter(dir * base, fill, ctx.candle.time, fee);
*entry_bar = Some(ctx.bar);
*extreme = fill;
}
}
Ok(())
}
fn execute_exit(
reason: &'static str,
raw_price: f64,
ctx: &FillCtx,
pf: &mut Portfolio,
entry_bar: &mut Option<usize>,
) {
if !pf.in_position() {
return;
}
let dir = if pf.is_long() { -1.0 } else { 1.0 };
let slip = slippage_rate(
ctx.spec.costs.slippage,
ctx.orderbook,
pf.qty,
ctx.candle.volume,
);
let fill = raw_price * (1.0 + dir * slip);
let fee = pf.qty.abs() * fill * ctx.taker;
pf.exit(fill, ctx.candle.time, fee, reason);
*entry_bar = None;
}
#[derive(Debug, Default)]
pub struct Feeds<'a> {
pub reference: Option<f64>,
pub deriv: Option<&'a DerivativesTick>,
pub orderbook: Option<&'a OrderBook>,
pub trades: Option<&'a [TradePrint]>,
pub cross_section: Option<&'a CrossSection>,
}
#[derive(Debug)]
struct Bar<'a> {
candle: &'a Candle,
reference: Option<f64>,
deriv: Option<CoreDerivativesTick>,
orderbook: Option<CoreOrderBook>,
cross_section: Option<CoreCrossSection>,
trades: Vec<CoreTrade>,
index: usize,
}
struct Indicator {
name: Arc<str>,
field_keys: Vec<(&'static str, Arc<str>)>,
eval: Box<dyn EvalIndicator>,
}
fn history_depth(spec: &StrategySpec) -> usize {
let mut back = condition_lookback(&spec.entry).max(condition_lookback(&spec.exit));
if let Some(cond) = &spec.short_entry {
back = back.max(condition_lookback(cond));
}
if let Some(cond) = &spec.short_exit {
back = back.max(condition_lookback(cond));
}
if let Sizing::VolTarget { lookback, .. } = spec.sizing {
back = back.max(lookback as usize);
}
back + 1
}
pub(crate) fn require_feeds(
spec: &StrategySpec,
has_orderbook: bool,
has_deriv: bool,
) -> Result<()> {
if matches!(spec.costs.slippage, Slippage::Spread) && !has_orderbook {
return Err(BacktestError::InvalidSpec(
"costs.slippage spread needs an order-book feed; without one every fill would be priced at zero slippage"
.into(),
));
}
if spec.costs.funding && !has_deriv {
return Err(BacktestError::InvalidSpec(
"costs.funding needs a derivatives feed; without one no funding would be charged at all"
.into(),
));
}
Ok(())
}
pub fn run(spec: &StrategySpec, candles: &[Candle]) -> Result<BacktestReport> {
run_with_capital(spec, candles, DEFAULT_CAPITAL)
}
pub fn run_with_capital(
spec: &StrategySpec,
candles: &[Candle],
capital: f64,
) -> Result<BacktestReport> {
spec.validate()?;
require_feeds(spec, false, false)?;
if candles.is_empty() {
return Err(BacktestError::InvalidData("no candles".into()));
}
let mut bt = StreamingBacktest::new(spec, capital)?;
for candle in candles {
bt.step(candle)?;
}
Ok(bt.finish())
}
pub fn run_stream<F>(
spec: &StrategySpec,
candles: &[Candle],
capital: f64,
mut on_bar: F,
) -> Result<BacktestReport>
where
F: FnMut(usize, &StreamingBacktest),
{
spec.validate()?;
require_feeds(spec, false, false)?;
if candles.is_empty() {
return Err(BacktestError::InvalidData("no candles".into()));
}
let mut bt = StreamingBacktest::new(spec, capital)?;
for (i, candle) in candles.iter().enumerate() {
bt.step(candle)?;
on_bar(i, &bt);
}
Ok(bt.finish())
}
pub fn run_with_ref(
spec: &StrategySpec,
candles: &[Candle],
reference: &[Candle],
capital: f64,
) -> Result<BacktestReport> {
spec.validate()?;
require_feeds(spec, false, false)?;
if candles.is_empty() {
return Err(BacktestError::InvalidData("no candles".into()));
}
if reference.len() != candles.len() {
return Err(BacktestError::InvalidData(
"reference series must have the same length as the candles".into(),
));
}
let mut bt = StreamingBacktest::new(spec, capital)?;
for (candle, ref_candle) in candles.iter().zip(reference) {
bt.step_with_ref(candle, Some(ref_candle.close))?;
}
Ok(bt.finish())
}
pub fn run_with_deriv(
spec: &StrategySpec,
candles: &[Candle],
derivs: &[DerivativesTick],
capital: f64,
) -> Result<BacktestReport> {
spec.validate()?;
require_feeds(spec, false, true)?;
if candles.is_empty() {
return Err(BacktestError::InvalidData("no candles".into()));
}
if derivs.len() != candles.len() {
return Err(BacktestError::InvalidData(
"derivatives feed must have the same length as the candles".into(),
));
}
let mut bt = StreamingBacktest::new(spec, capital)?;
for (candle, d) in candles.iter().zip(derivs) {
bt.step_with_feeds(
candle,
&Feeds {
deriv: Some(d),
..Default::default()
},
)?;
}
Ok(bt.finish())
}
pub fn run_with_orderbook(
spec: &StrategySpec,
candles: &[Candle],
books: &[OrderBook],
capital: f64,
) -> Result<BacktestReport> {
spec.validate()?;
require_feeds(spec, true, false)?;
if candles.is_empty() {
return Err(BacktestError::InvalidData("no candles".into()));
}
if books.len() != candles.len() {
return Err(BacktestError::InvalidData(
"order-book feed must have the same length as the candles".into(),
));
}
let mut bt = StreamingBacktest::new(spec, capital)?;
for (candle, ob) in candles.iter().zip(books) {
bt.step_with_feeds(
candle,
&Feeds {
orderbook: Some(ob),
..Default::default()
},
)?;
}
Ok(bt.finish())
}
pub fn run_with_trades(
spec: &StrategySpec,
candles: &[Candle],
trades: &[Vec<TradePrint>],
capital: f64,
) -> Result<BacktestReport> {
spec.validate()?;
require_feeds(spec, false, false)?;
if candles.is_empty() {
return Err(BacktestError::InvalidData("no candles".into()));
}
if trades.len() != candles.len() {
return Err(BacktestError::InvalidData(
"trade feed must have one trade list per candle".into(),
));
}
let mut bt = StreamingBacktest::new(spec, capital)?;
for (candle, bar_trades) in candles.iter().zip(trades) {
bt.step_with_feeds(
candle,
&Feeds {
trades: Some(bar_trades.as_slice()),
..Default::default()
},
)?;
}
Ok(bt.finish())
}
pub fn run_with_cross_section(
spec: &StrategySpec,
candles: &[Candle],
sections: &[CrossSection],
capital: f64,
) -> Result<BacktestReport> {
spec.validate()?;
require_feeds(spec, false, false)?;
if candles.is_empty() {
return Err(BacktestError::InvalidData("no candles".into()));
}
if sections.len() != candles.len() {
return Err(BacktestError::InvalidData(
"cross-section feed must have one panel per candle".into(),
));
}
let mut bt = StreamingBacktest::new(spec, capital)?;
for (candle, cs) in candles.iter().zip(sections) {
bt.step_with_feeds(
candle,
&Feeds {
cross_section: Some(cs),
..Default::default()
},
)?;
}
Ok(bt.finish())
}
pub struct StreamingBacktest<'a> {
spec: Cow<'a, StrategySpec>,
capital: f64,
maker: f64,
taker: f64,
warmup: usize,
indicators: Vec<Indicator>,
pf: Portfolio,
history: Vec<BarRow>,
history_depth: usize,
bars_seen: usize,
equity: Vec<EquityPoint>,
pending: Option<Pending>,
entry_bar: Option<usize>,
extreme: f64,
last: Option<(i64, f64)>,
}
impl fmt::Debug for StreamingBacktest<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StreamingBacktest")
.field("capital", &self.capital)
.field("warmup", &self.warmup)
.field(
"indicators",
&self.indicators.iter().map(|i| &*i.name).collect::<Vec<_>>(),
)
.field("bars", &self.bars_seen)
.field("equity_points", &self.equity.len())
.field("trades", &self.pf.trades.len())
.field("pending", &self.pending)
.field("entry_bar", &self.entry_bar)
.finish_non_exhaustive()
}
}
impl<'a> StreamingBacktest<'a> {
pub fn new(spec: &'a StrategySpec, capital: f64) -> Result<Self> {
Self::from_spec(Cow::Borrowed(spec), capital)
}
fn from_spec(spec: Cow<'a, StrategySpec>, capital: f64) -> Result<Self> {
spec.validate()?;
let mut indicators: Vec<Indicator> = Vec::with_capacity(spec.indicators.len());
let mut max_warmup = 0usize;
for (name, ind) in &spec.indicators {
let built = registry::build(&ind.kind, &ind.params)?;
max_warmup = max_warmup.max(built.warmup());
indicators.push(Indicator {
name: Arc::from(name.as_str()),
field_keys: Vec::new(),
eval: built,
});
}
let warmup = spec.warmup.map_or(max_warmup, |w| w as usize);
let history_depth = history_depth(&spec);
let maker = spec.costs.maker_bps / 10_000.0;
let taker = spec.costs.taker_bps / 10_000.0;
Ok(Self {
spec,
capital,
maker,
taker,
warmup,
indicators,
pf: Portfolio::new(capital),
history: Vec::with_capacity(history_depth),
history_depth,
bars_seen: 0,
equity: Vec::new(),
pending: None,
entry_bar: None,
extreme: 0.0,
last: None,
})
}
pub fn step(&mut self, candle: &Candle) -> Result<()> {
self.step_with_feeds(candle, &Feeds::default())
}
pub fn equity(&self) -> &[EquityPoint] {
&self.equity
}
pub fn latest_equity(&self) -> Option<EquityPoint> {
self.equity.last().copied()
}
pub fn num_trades(&self) -> usize {
self.pf.trades.len()
}
pub fn step_with_ref(&mut self, candle: &Candle, reference: Option<f64>) -> Result<()> {
self.step_with_feeds(
candle,
&Feeds {
reference,
..Default::default()
},
)
}
pub fn step_with_feeds(&mut self, candle: &Candle, feeds: &Feeds) -> Result<()> {
require_feeds(&self.spec, feeds.orderbook.is_some(), feeds.deriv.is_some())?;
let bar = Bar {
candle,
reference: feeds.reference,
deriv: feeds.deriv.and_then(|d| d.to_core().ok()),
orderbook: feeds.orderbook.and_then(|ob| ob.to_core().ok()),
cross_section: feeds.cross_section.and_then(|cs| cs.to_core().ok()),
trades: feeds
.trades
.unwrap_or(&[])
.iter()
.filter_map(|tp| tp.to_core().ok())
.collect(),
index: self.bars_seen,
};
self.last = Some((candle.time, candle.close));
self.fill_working_order(&bar)?;
let idx = self.record_bar(&bar);
self.apply_intrabar_exits(&bar);
self.charge_funding(&bar);
self.mark_equity(&bar);
self.decide_next_action(&bar, idx)
}
fn fill_working_order(&mut self, bar: &Bar) -> Result<()> {
let candle = bar.candle;
let orderbook = &bar.orderbook;
let t = bar.index;
if let Some(mut order) = self.pending.take() {
if order.delay > 0 {
order.delay -= 1;
self.pending = Some(order); } else {
let ctx = FillCtx {
spec: &self.spec,
candle,
history: &self.history,
maker: self.maker,
taker: self.taker,
orderbook: orderbook.as_ref(),
bar: t,
};
let keep_working = match &order.action {
Action::Enter { side, trigger } => {
let side = *side;
let maker_fill = matches!(trigger, Some((_, LevelKind::Limit)));
let level = match trigger {
None => Some(candle.open),
Some((trig, kind)) => level_fill(side, *trig, *kind, candle),
};
match level {
Some(px) => {
execute_entry(
side,
px,
maker_fill,
&ctx,
&mut self.pf,
&mut self.entry_bar,
&mut self.extreme,
)?;
false
}
None => true, }
}
Action::Exit(reason) => {
execute_exit(reason, candle.open, &ctx, &mut self.pf, &mut self.entry_bar);
false
}
};
if keep_working {
self.pending = Some(order);
}
}
}
Ok(())
}
fn record_bar(&mut self, bar: &Bar) -> usize {
let candle = bar.candle;
let reference = bar.reference;
let deriv = bar.deriv;
let orderbook = &bar.orderbook;
let cross_section = &bar.cross_section;
let trades: &[CoreTrade] = &bar.trades;
let mut values = BTreeMap::new();
for ind in &mut self.indicators {
let input = BarInput {
candle,
reference,
deriv,
orderbook: orderbook.as_ref(),
trades,
cross_section: cross_section.as_ref(),
};
if let Some(v) = ind.eval.update(&input) {
values.insert(Arc::clone(&ind.name), v);
let fields = ind.eval.fields();
for (field, fv) in fields {
let key =
if let Some((_, key)) = ind.field_keys.iter().find(|(f, _)| *f == field) {
Arc::clone(key)
} else {
let key: Arc<str> = Arc::from(format!("{}.{field}", ind.name).as_str());
ind.field_keys.push((field, Arc::clone(&key)));
key
};
values.insert(key, fv);
}
}
}
let row = BarRow {
candle: *candle,
values,
};
if self.history.len() == self.history_depth {
self.history.rotate_left(1);
self.history[self.history_depth - 1] = row;
} else {
self.history.push(row);
}
self.bars_seen += 1;
self.history.len() - 1
}
fn apply_intrabar_exits(&mut self, bar: &Bar) {
let candle = bar.candle;
if self.pf.in_position() {
self.extreme = if self.pf.is_long() {
self.extreme.max(candle.high)
} else {
self.extreme.min(candle.low)
};
if let Some((price, reason)) = intrabar_exit(
candle,
&self.spec.risk,
self.pf.entry_price,
self.extreme,
self.pf.is_long(),
) {
let fee = self.pf.qty.abs() * price * self.taker;
self.pf.exit(price, candle.time, fee, reason);
self.entry_bar = None;
} else if self.spec.risk.liquidation {
let p_liq = -self.pf.cash / self.pf.qty;
let breached = if self.pf.is_long() {
candle.low <= p_liq
} else {
candle.high >= p_liq
};
if p_liq > 0.0 && breached {
let fee = self.pf.qty.abs() * p_liq * self.taker;
self.pf.exit(p_liq, candle.time, fee, "liquidation");
self.entry_bar = None;
}
}
}
}
fn charge_funding(&mut self, bar: &Bar) {
let deriv = bar.deriv;
if self.spec.costs.funding && self.pf.in_position() {
if let Some(d) = deriv {
let payment = self.pf.qty * d.mark_price * d.funding_rate;
self.pf.apply_funding(payment);
}
}
}
fn mark_equity(&mut self, bar: &Bar) {
let candle = bar.candle;
self.equity.push(EquityPoint {
time: candle.time,
equity: self.pf.equity(candle.close),
});
}
fn decide_next_action(&mut self, bar: &Bar, idx: usize) -> Result<()> {
let candle = bar.candle;
let orderbook = &bar.orderbook;
let t = bar.index;
if t < self.warmup {
return Ok(());
}
let bars_since_entry = self.entry_bar.map(|e| (t - e) as u32);
let state = RuleState {
in_position: self.pf.in_position(),
bars_since_entry,
};
let close_fill = matches!(self.spec.execution.fill_timing, FillTiming::Close);
if self.pf.in_position() {
let cond = if self.pf.is_long() {
&self.spec.exit
} else {
self.spec.short_exit.as_ref().unwrap_or(&self.spec.exit)
};
if eval_condition(cond, &self.history, idx, state) {
if close_fill {
let ctx = FillCtx {
spec: &self.spec,
candle,
history: &self.history,
maker: self.maker,
taker: self.taker,
orderbook: orderbook.as_ref(),
bar: t,
};
execute_exit(
"signal",
candle.close,
&ctx,
&mut self.pf,
&mut self.entry_bar,
);
} else {
self.pending = Some(Pending {
action: Action::Exit("signal"),
delay: self.spec.execution.latency_bars,
});
}
}
} else if self.pending.is_none() {
let entry_fires = eval_condition(&self.spec.entry, &self.history, idx, state);
let short_fires = !entry_fires
&& self
.spec
.short_entry
.as_ref()
.is_some_and(|c| eval_condition(c, &self.history, idx, state));
let side = if entry_fires {
Some(Side::Long)
} else if short_fires {
Some(Side::Short)
} else {
None
};
if let Some(side) = side {
if close_fill {
let ctx = FillCtx {
spec: &self.spec,
candle,
history: &self.history,
maker: self.maker,
taker: self.taker,
orderbook: orderbook.as_ref(),
bar: t,
};
execute_entry(
side,
candle.close,
false, &ctx,
&mut self.pf,
&mut self.entry_bar,
&mut self.extreme,
)?;
} else {
let trigger = entry_trigger(&self.spec.execution, candle.close);
self.pending = Some(Pending {
action: Action::Enter { side, trigger },
delay: self.spec.execution.latency_bars,
});
}
}
}
Ok(())
}
pub fn finish(mut self) -> BacktestReport {
if self.pf.in_position() {
if let Some((time, close)) = self.last {
let fee = self.pf.qty.abs() * close * self.taker;
self.pf.exit(close, time, fee, "end");
}
}
let series: Vec<f64> = self.equity.iter().map(|e| e.equity).collect();
let metrics = metrics::compute(self.capital, &series, &self.pf.trades);
BacktestReport {
schema_version: REPORT_SCHEMA_VERSION,
symbol: self.spec.symbol.clone(),
timeframe: self.spec.timeframe.clone(),
metrics,
trades: self.pf.trades,
equity: self.equity,
fees_paid: self.pf.fees_paid,
initial_capital: self.capital,
}
}
}
impl StreamingBacktest<'static> {
pub fn new_owned(spec: StrategySpec, capital: f64) -> Result<Self> {
Self::from_spec(Cow::Owned(spec), capital)
}
}
fn size(
sizing: Sizing,
risk: &Risk,
equity: f64,
price: f64,
realized_vol: Option<f64>,
) -> Result<Option<f64>> {
if price <= 0.0 || equity <= 0.0 {
return Ok(None);
}
let qty = match sizing {
Sizing::FixedFraction { fraction } => (equity * fraction) / price,
Sizing::FixedCash { cash: notional } => notional / price,
Sizing::FixedQty { qty } => qty,
Sizing::RiskPerTrade { risk_pct } => {
let stop = risk.stop_loss_pct.ok_or_else(|| {
BacktestError::InvalidSpec(
"risk_per_trade sizing requires risk.stop_loss_pct".into(),
)
})?;
if stop <= 0.0 {
return Ok(None);
}
(equity * risk_pct / 100.0) / (price * stop / 100.0)
}
Sizing::VolTarget { target_vol, .. } => {
let Some(rv) = realized_vol else {
return Ok(None);
};
(equity * target_vol / rv) / price
}
};
if qty <= 0.0 {
return Ok(None);
}
let max_leverage = risk.max_leverage.unwrap_or(1.0);
let mut max_notional = equity * max_leverage;
if let Some(max_pct) = risk.max_position_pct {
max_notional = max_notional.min(equity * max_pct / 100.0);
}
let capped = (qty * price).min(max_notional) / price;
Ok(Some(capped))
}
fn intrabar_exit(
candle: &Candle,
risk: &Risk,
entry: f64,
extreme: f64,
is_long: bool,
) -> Option<(f64, &'static str)> {
if entry <= 0.0 {
return None;
}
if is_long {
if let Some(p) = risk.stop_loss_pct {
let level = entry * (1.0 - p / 100.0);
if candle.low <= level {
return Some((level.min(candle.open), "stop_loss"));
}
}
if let Some(p) = risk.trailing_stop_pct {
let level = extreme * (1.0 - p / 100.0);
if candle.low <= level {
return Some((level.min(candle.open), "trailing_stop"));
}
}
if let Some(p) = risk.take_profit_pct {
let level = entry * (1.0 + p / 100.0);
if candle.high >= level {
return Some((level.max(candle.open), "take_profit"));
}
}
} else {
if let Some(p) = risk.stop_loss_pct {
let level = entry * (1.0 + p / 100.0);
if candle.high >= level {
return Some((level.max(candle.open), "stop_loss"));
}
}
if let Some(p) = risk.trailing_stop_pct {
let level = extreme * (1.0 + p / 100.0);
if candle.high >= level {
return Some((level.max(candle.open), "trailing_stop"));
}
}
if let Some(p) = risk.take_profit_pct {
let level = entry * (1.0 - p / 100.0);
if candle.low <= level {
return Some((level.min(candle.open), "take_profit"));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::Level;
use crate::spec::StrategySpec;
fn bar(time: i64, open: f64, high: f64, low: f64, close: f64) -> Candle {
Candle {
time,
open,
high,
low,
close,
volume: 0.0,
}
}
#[test]
fn buy_stop_limit_fills_at_the_stop_when_the_limit_is_above_it() {
let c = bar(0, 99.0, 100.5, 98.5, 100.2);
let fill = level_fill(Side::Long, 100.0, LevelKind::StopLimit { limit: 101.0 }, &c);
assert_eq!(fill, Some(100.0));
}
#[test]
fn buy_stop_limit_does_not_fill_when_the_bar_gaps_past_the_limit() {
let c = bar(0, 105.0, 106.0, 102.0, 105.5);
let fill = level_fill(Side::Long, 100.0, LevelKind::StopLimit { limit: 101.0 }, &c);
assert_eq!(fill, None);
assert_eq!(
level_fill(Side::Long, 100.0, LevelKind::Stop, &c),
Some(105.0)
);
}
#[test]
fn buy_stop_limit_fills_at_the_limit_when_price_comes_back() {
let c = bar(0, 105.0, 106.0, 100.5, 104.0);
let fill = level_fill(Side::Long, 100.0, LevelKind::StopLimit { limit: 101.0 }, &c);
assert_eq!(fill, Some(101.0));
}
#[test]
fn sell_stop_limit_mirrors_the_buy_side() {
let touched = bar(0, 101.0, 101.5, 99.0, 99.5);
assert_eq!(
level_fill(
Side::Short,
100.0,
LevelKind::StopLimit { limit: 99.0 },
&touched
),
Some(100.0)
);
let gapped = bar(0, 95.0, 98.0, 94.0, 96.0);
assert_eq!(
level_fill(
Side::Short,
100.0,
LevelKind::StopLimit { limit: 99.0 },
&gapped
),
None
);
assert_eq!(
level_fill(Side::Short, 100.0, LevelKind::Stop, &gapped),
Some(95.0)
);
}
#[test]
fn stop_limit_never_fills_worse_than_its_limit() {
for (o, h, l, c) in [
(99.0, 100.5, 98.5, 100.2),
(105.0, 106.0, 100.5, 104.0),
(100.2, 103.0, 100.1, 102.0),
] {
let candle = bar(0, o, h, l, c);
if let Some(px) = level_fill(
Side::Long,
100.0,
LevelKind::StopLimit { limit: 101.0 },
&candle,
) {
assert!(px <= 101.0, "buy filled above its limit: {px}");
}
}
}
fn oscillating(n: i64) -> Vec<Candle> {
(0..n)
.map(|i| {
let px = 100.0 + ((i as f64) * 0.4).sin() * 6.0;
bar(i, px, px + 0.5, px - 0.5, px)
})
.collect()
}
fn spec_with(costs: &str) -> StrategySpec {
StrategySpec::parse(&format!(
r#"{{"symbol":"x","timeframe":"1h",
"indicators":{{"a":{{"type":"Sma","params":[5]}}}},
"entry":{{"cross_above":[{{"price":"close"}},"a"]}},
"exit":{{"cross_below":[{{"price":"close"}},"a"]}},
"sizing":{{"type":"fixed_qty","qty":1}},
"costs":{costs}}}"#
))
.unwrap()
}
#[test]
fn the_report_says_what_it_is_a_report_of() {
let spec = StrategySpec::parse(
r#"{"symbol":"BTCUSDT","timeframe":"4h","indicators":{},
"entry":{"gt":[{"price":"close"},100]},
"exit":{"lt":[{"price":"close"},100]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let candles = oscillating(20);
let batch = run(&spec, &candles).unwrap();
assert_eq!(batch.symbol, "BTCUSDT");
assert_eq!(batch.timeframe, "4h");
let mut bt = StreamingBacktest::new(&spec, DEFAULT_CAPITAL).unwrap();
for candle in &candles {
bt.step(candle).unwrap();
}
let streamed = bt.finish();
assert_eq!(streamed.symbol, "BTCUSDT");
assert_eq!(streamed.timeframe, "4h");
}
#[test]
fn a_streaming_bar_without_its_required_feed_is_rejected() {
let spec = spec_with(r#"{"slippage":{"type":"spread"}}"#);
let candles = oscillating(10);
let mut blind = StreamingBacktest::new(&spec, 10_000.0).unwrap();
let err = blind.step(&candles[0]).unwrap_err();
let BacktestError::InvalidSpec(msg) = err else {
panic!("expected InvalidSpec, got {err:?}");
};
assert!(
msg.contains("order-book"),
"message should say what is missing: {msg}"
);
let mut fed = StreamingBacktest::new(&spec, 10_000.0).unwrap();
for candle in &candles {
let book = OrderBook {
bids: vec![Level {
price: candle.close - 0.01,
size: 1.0,
}],
asks: vec![Level {
price: candle.close + 0.01,
size: 1.0,
}],
};
let feeds = Feeds {
orderbook: Some(&book),
..Feeds::default()
};
fed.step_with_feeds(candle, &feeds).unwrap();
}
assert_eq!(fed.equity().len(), candles.len());
}
#[test]
fn spread_slippage_without_an_order_book_is_rejected() {
let spec = spec_with(r#"{"slippage":{"type":"spread"}}"#);
let candles = oscillating(60);
let err = run(&spec, &candles).unwrap_err();
let BacktestError::InvalidSpec(msg) = err else {
panic!("expected InvalidSpec, got {err:?}");
};
assert!(
msg.contains("order-book"),
"message should say what is missing: {msg}"
);
let books: Vec<OrderBook> = candles
.iter()
.map(|c| OrderBook {
bids: vec![Level {
price: c.close - 0.01,
size: 1.0,
}],
asks: vec![Level {
price: c.close + 0.01,
size: 1.0,
}],
})
.collect();
assert!(run_with_orderbook(&spec, &candles, &books, DEFAULT_CAPITAL).is_ok());
}
#[test]
fn funding_without_a_derivatives_feed_is_rejected() {
let spec = spec_with(r#"{"funding":true}"#);
let candles = oscillating(60);
let err = run(&spec, &candles).unwrap_err();
let BacktestError::InvalidSpec(msg) = err else {
panic!("expected InvalidSpec, got {err:?}");
};
assert!(
msg.contains("derivatives"),
"message should say what is missing: {msg}"
);
}
#[test]
fn a_spec_that_prices_nothing_special_needs_no_extra_feed() {
let spec = spec_with(r#"{"slippage":{"type":"fixed_bps","bps":1.0}}"#);
assert!(run(&spec, &oscillating(60)).is_ok());
}
#[test]
fn generated_indicator_drives_backtest() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h",
"indicators":{"a":{"type":"Alma","params":[9,0.85,6.0]}},
"entry":{"cross_above":[{"price":"close"},"a"]},
"exit":{"cross_below":[{"price":"close"},"a"]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
let px = 100.0 + ((i as f64) * 0.4).sin() * 6.0;
bar(i, px, px + 0.5, px - 0.5, px)
})
.collect();
let r = run(&spec, &candles).unwrap();
assert_eq!(r.equity.len(), candles.len());
assert!(r.metrics.num_trades >= 1);
}
#[test]
fn hand_computed_round_trip() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},100]},
"exit":{"lt":[{"price":"close"},100]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 101.0, 100.0, 101.0),
bar(1, 102.0, 103.0, 102.0, 103.0), bar(2, 104.0, 104.0, 99.0, 99.0),
bar(3, 98.0, 98.0, 97.0, 97.0), ];
let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
assert_eq!(r.trades.len(), 1);
let t = &r.trades[0];
assert!((t.entry_price - 102.0).abs() < 1e-9);
assert!((t.exit_price - 98.0).abs() < 1e-9);
assert!((t.pnl - (-4.0)).abs() < 1e-9);
assert!((r.equity.last().unwrap().equity - 996.0).abs() < 1e-9);
}
#[test]
fn short_round_trip() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"lt":[{"price":"close"},0]},
"exit":{"in_position":true},
"short_entry":{"lt":[{"price":"close"},100]},
"short_exit":{"gt":[{"price":"close"},100]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 100.0, 99.0, 99.0), bar(1, 98.0, 98.0, 98.0, 98.0), bar(2, 101.0, 101.0, 101.0, 101.0), bar(3, 102.0, 102.0, 102.0, 102.0), ];
let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
assert_eq!(r.trades.len(), 1);
let t = &r.trades[0];
assert!((t.entry_price - 98.0).abs() < 1e-9);
assert!((t.exit_price - 102.0).abs() < 1e-9);
assert!((t.pnl - (-4.0)).abs() < 1e-9);
assert_eq!(t.reason, "signal");
}
#[test]
fn intrabar_stop_loss() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"lt":[{"price":"close"},0]},
"sizing":{"type":"fixed_qty","qty":1},
"risk":{"stop_loss_pct":5.0}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0), bar(1, 100.0, 101.0, 100.0, 100.0), bar(2, 99.0, 99.0, 90.0, 92.0), ];
let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
assert_eq!(r.trades.len(), 1);
let t = &r.trades[0];
assert!((t.exit_price - 95.0).abs() < 1e-9);
assert_eq!(t.reason, "stop_loss");
assert!((t.pnl - (-5.0)).abs() < 1e-9); }
#[test]
fn intrabar_take_profit() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"lt":[{"price":"close"},0]},
"sizing":{"type":"fixed_qty","qty":1},
"risk":{"take_profit_pct":10.0}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0), bar(1, 100.0, 100.0, 100.0, 100.0), bar(2, 105.0, 115.0, 105.0, 112.0), ];
let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
assert_eq!(r.trades.len(), 1);
let t = &r.trades[0];
assert!((t.exit_price - 110.0).abs() < 1e-9);
assert_eq!(t.reason, "take_profit");
assert!((t.pnl - 10.0).abs() < 1e-9);
}
#[test]
fn simultaneous_stop_and_target_prefers_stop() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"lt":[{"price":"close"},0]},
"sizing":{"type":"fixed_qty","qty":1},
"risk":{"stop_loss_pct":5.0,"take_profit_pct":10.0}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0), bar(1, 100.0, 100.0, 100.0, 100.0), bar(2, 100.0, 115.0, 90.0, 100.0), ];
let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
assert_eq!(r.trades.len(), 1);
let t = &r.trades[0];
assert_eq!(t.reason, "stop_loss");
assert!((t.exit_price - 95.0).abs() < 1e-9);
assert!((t.pnl - (-5.0)).abs() < 1e-9);
}
#[test]
fn gap_down_through_stop_fills_at_open() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"lt":[{"price":"close"},0]},
"sizing":{"type":"fixed_qty","qty":1},
"risk":{"stop_loss_pct":5.0}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0), bar(1, 100.0, 100.0, 100.0, 100.0), bar(2, 90.0, 92.0, 88.0, 89.0), ];
let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
assert_eq!(r.trades.len(), 1);
let t = &r.trades[0];
assert_eq!(t.reason, "stop_loss");
assert!((t.exit_price - 90.0).abs() < 1e-9); assert!((t.pnl - (-10.0)).abs() < 1e-9); }
#[test]
fn gap_up_through_short_stop_fills_at_open() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"lt":[{"price":"close"},0]},"exit":{"in_position":false},
"short_entry":{"gt":[{"price":"close"},0]},
"short_exit":{"lt":[{"price":"close"},0]},
"sizing":{"type":"fixed_qty","qty":1},
"risk":{"stop_loss_pct":5.0}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0), bar(1, 100.0, 100.0, 100.0, 100.0), bar(2, 110.0, 112.0, 108.0, 111.0), ];
let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
assert_eq!(r.trades.len(), 1);
let t = &r.trades[0];
assert_eq!(t.reason, "stop_loss");
assert!((t.exit_price - 110.0).abs() < 1e-9); }
#[test]
fn trailing_stop() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"lt":[{"price":"close"},0]},
"sizing":{"type":"fixed_qty","qty":1},
"risk":{"trailing_stop_pct":10.0}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0), bar(1, 100.0, 100.0, 100.0, 100.0), bar(2, 100.0, 120.0, 119.0, 120.0), bar(3, 118.0, 118.0, 105.0, 106.0), ];
let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
assert_eq!(r.trades.len(), 1);
let t = &r.trades[0];
assert_eq!(t.reason, "trailing_stop");
assert!((t.exit_price - 108.0).abs() < 1e-9);
assert!((t.pnl - 8.0).abs() < 1e-9);
}
#[test]
fn no_signals_no_trades() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},1000000]},
"exit":{"in_position":true},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let candles = [
bar(0, 10.0, 10.0, 10.0, 10.0),
bar(1, 11.0, 11.0, 11.0, 11.0),
];
let r = run(&spec, &candles).unwrap();
assert!(r.trades.is_empty());
}
#[test]
fn open_position_closed_at_end() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"lt":[{"price":"close"},0]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let candles = [
bar(0, 10.0, 10.0, 10.0, 10.0),
bar(1, 11.0, 11.0, 11.0, 11.0),
];
let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
assert_eq!(r.trades.len(), 1);
assert_eq!(r.trades[0].reason, "end");
}
#[test]
fn sma_crossover_runs() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h",
"indicators":{"fast":{"type":"Sma","params":[2]},"slow":{"type":"Sma","params":[3]}},
"entry":{"cross_above":["fast","slow"]},
"exit":{"cross_below":["fast","slow"]},
"sizing":{"type":"fixed_fraction","fraction":0.5}}"#,
)
.unwrap();
let candles: Vec<Candle> = (0..20)
.map(|i| {
bar(
i,
100.0 + i as f64,
100.0 + i as f64,
100.0,
100.0 + i as f64,
)
})
.collect();
let r = run(&spec, &candles).unwrap();
assert_eq!(r.equity.len(), 20);
assert_eq!(r.schema_version, REPORT_SCHEMA_VERSION);
}
#[test]
fn multi_output_field_ref_runs() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h",
"indicators":{"bb":{"type":"Bollinger","params":[5,2]}},
"entry":{"gt":[{"price":"close"},"bb.upper"]},
"exit":{"lt":[{"price":"close"},"bb.lower"]},
"sizing":{"type":"fixed_fraction","fraction":0.5}}"#,
)
.unwrap();
let candles: Vec<Candle> = (0..30)
.map(|i| {
let p = 100.0 + (i as f64 * 0.5).sin() * 5.0;
bar(i, p, p + 1.0, p - 1.0, p)
})
.collect();
let r = run(&spec, &candles).unwrap();
assert_eq!(r.equity.len(), 30);
}
#[test]
fn vol_target_sizes_inversely_to_vol() {
let q = size(
Sizing::VolTarget {
target_vol: 0.01,
lookback: 5,
},
&Risk::default(),
10_000.0,
100.0,
Some(0.02),
)
.unwrap()
.unwrap();
assert!((q - 50.0).abs() < 1e-9);
}
#[test]
fn vol_target_takes_no_position_without_history() {
let none = size(
Sizing::VolTarget {
target_vol: 0.01,
lookback: 5,
},
&Risk::default(),
10_000.0,
100.0,
None,
)
.unwrap();
assert!(none.is_none());
}
#[test]
fn vol_target_trades_after_warmup() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"in_position":false},
"sizing":{"type":"vol_target","target_vol":0.02,"lookback":3}}"#,
)
.unwrap();
let closes = [100.0, 101.0, 102.0, 101.0, 103.0, 102.0];
let candles: Vec<Candle> = closes
.iter()
.enumerate()
.map(|(i, &c)| bar(i64::try_from(i).unwrap(), c, c + 0.5, c - 0.5, c))
.collect();
let r = run(&spec, &candles).unwrap();
assert!(!r.trades.is_empty());
assert!(r.trades[0].qty > 0.0);
}
#[test]
fn risk_per_trade_sizes_from_stop() {
let risk = Risk {
stop_loss_pct: Some(2.0),
..Default::default()
};
let q = size(
Sizing::RiskPerTrade { risk_pct: 1.0 },
&risk,
10_000.0,
100.0,
None,
)
.unwrap()
.unwrap();
assert!((q - 50.0).abs() < 1e-9);
}
#[test]
fn risk_per_trade_requires_stop() {
assert!(size(
Sizing::RiskPerTrade { risk_pct: 1.0 },
&Risk::default(),
10_000.0,
100.0,
None
)
.is_err());
}
#[test]
fn default_leverage_caps_at_equity() {
let q = size(
Sizing::FixedCash { cash: 50_000.0 },
&Risk::default(),
10_000.0,
100.0,
None,
)
.unwrap()
.unwrap();
assert!((q - 100.0).abs() < 1e-9);
}
#[test]
fn max_leverage_allows_more_than_equity() {
let risk = Risk {
max_leverage: Some(3.0),
..Default::default()
};
let q = size(
Sizing::FixedCash { cash: 50_000.0 },
&risk,
10_000.0,
100.0,
None,
)
.unwrap()
.unwrap();
assert!((q - 300.0).abs() < 1e-9); }
#[test]
fn max_position_pct_caps_notional() {
let risk = Risk {
max_leverage: Some(5.0),
max_position_pct: Some(20.0),
..Default::default()
};
let q = size(
Sizing::FixedCash { cash: 50_000.0 },
&risk,
10_000.0,
100.0,
None,
)
.unwrap()
.unwrap();
assert!((q - 20.0).abs() < 1e-9);
}
#[test]
fn leverage_flows_through_run() {
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0),
bar(1, 100.0, 100.0, 100.0, 100.0), bar(2, 100.0, 100.0, 100.0, 100.0),
];
let no_lev = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"in_position":false},
"sizing":{"type":"fixed_cash","cash":50000}}"#,
)
.unwrap();
let r0 = run_with_capital(&no_lev, &candles, 10_000.0).unwrap();
assert!((r0.trades[0].qty - 100.0).abs() < 1e-9);
let levered = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"in_position":false},
"sizing":{"type":"fixed_cash","cash":50000},
"risk":{"max_leverage":3}}"#,
)
.unwrap();
let r1 = run_with_capital(&levered, &candles, 10_000.0).unwrap();
assert!((r1.trades[0].qty - 300.0).abs() < 1e-9); }
#[test]
fn limit_entry_fills_on_dip() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"in_position":false},
"sizing":{"type":"fixed_qty","qty":1},
"execution":{"order_type":"limit","limit_offset_pct":-1.0}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0), bar(1, 100.0, 101.0, 100.0, 100.0), bar(2, 100.0, 100.0, 98.0, 99.0), ];
let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
assert_eq!(r.trades.len(), 1);
assert!((r.trades[0].entry_price - 99.0).abs() < 1e-9);
}
#[test]
fn limit_entry_never_fills_without_a_dip() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"in_position":false},
"sizing":{"type":"fixed_qty","qty":1},
"execution":{"order_type":"limit","limit_offset_pct":-1.0}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0),
bar(1, 100.0, 101.0, 100.0, 100.0),
bar(2, 100.0, 102.0, 100.0, 101.0), ];
let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
assert!(r.trades.is_empty());
}
#[test]
fn stop_entry_fills_on_breakout() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"in_position":false},
"sizing":{"type":"fixed_qty","qty":1},
"execution":{"order_type":"stop","stop_offset_pct":1.0}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0), bar(1, 100.0, 100.5, 100.0, 100.0), bar(2, 100.0, 102.0, 100.0, 101.0), ];
let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
assert_eq!(r.trades.len(), 1);
assert!((r.trades[0].entry_price - 101.0).abs() < 1e-9);
}
#[test]
fn limit_order_requires_offset() {
assert!(StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
"sizing":{"type":"fixed_qty","qty":1},
"execution":{"order_type":"limit"}}"#,
)
.is_err());
}
#[test]
fn stop_limit_is_unsupported() {
assert!(StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
"sizing":{"type":"fixed_qty","qty":1},
"execution":{"order_type":"stop_limit"}}"#,
)
.is_err());
}
#[test]
fn latency_delays_the_fill() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"in_position":false},
"sizing":{"type":"fixed_qty","qty":1},
"execution":{"latency_bars":1}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0), bar(1, 110.0, 110.0, 110.0, 110.0), bar(2, 120.0, 120.0, 120.0, 120.0), ];
let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
assert_eq!(r.trades.len(), 1);
assert!((r.trades[0].entry_price - 120.0).abs() < 1e-9);
}
#[test]
fn partial_fills_cap_entry_to_participation() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"in_position":false},
"sizing":{"type":"fixed_qty","qty":100},
"execution":{"partial_fills":true,"max_participation":0.05}}"#,
)
.unwrap();
let vbar = |time, volume| Candle {
time,
open: 100.0,
high: 100.0,
low: 100.0,
close: 100.0,
volume,
};
let candles = [vbar(0, 0.0), vbar(1, 1000.0), vbar(2, 1000.0)];
let r = run_with_capital(&spec, &candles, 1_000_000.0).unwrap();
assert_eq!(r.trades.len(), 1);
assert!((r.trades[0].qty - 50.0).abs() < 1e-9);
}
#[test]
fn partial_fills_requires_participation() {
assert!(StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
"sizing":{"type":"fixed_qty","qty":1},
"execution":{"partial_fills":true}}"#,
)
.is_err());
}
#[test]
fn fill_timing_close_fills_same_bar() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},100]},
"exit":{"lt":[{"price":"close"},100]},
"sizing":{"type":"fixed_qty","qty":1},
"execution":{"fill_timing":"close"}}"#,
)
.unwrap();
let candles = [
bar(0, 90.0, 90.0, 90.0, 90.0), bar(1, 95.0, 105.0, 95.0, 101.0), bar(2, 100.0, 100.0, 90.0, 99.0), ];
let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
assert_eq!(r.trades.len(), 1);
assert!((r.trades[0].entry_price - 101.0).abs() < 1e-9); assert!((r.trades[0].exit_price - 99.0).abs() < 1e-9);
}
#[test]
fn fill_timing_close_rejects_limit_and_latency() {
assert!(StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
"sizing":{"type":"fixed_qty","qty":1},
"execution":{"fill_timing":"close","order_type":"limit","limit_offset_pct":-1.0}}"#,
)
.is_err());
assert!(StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
"sizing":{"type":"fixed_qty","qty":1},
"execution":{"fill_timing":"close","latency_bars":1}}"#,
)
.is_err());
}
#[test]
fn history_depth_covers_every_backward_looking_form() {
let depth = |rules: &str| {
let spec = StrategySpec::parse(&format!(
r#"{{"symbol":"x","timeframe":"1h","indicators":{{}},{rules},
"sizing":{{"type":"fixed_qty","qty":1}}}}"#
))
.unwrap();
history_depth(&spec)
};
assert_eq!(
depth(r#""entry":{"gt":[{"price":"close"},1]},"exit":{"lt":[{"price":"close"},1]}"#),
1
);
assert_eq!(
depth(
r#""entry":{"cross_above":[{"price":"close"},{"price":"open"}]},
"exit":{"lt":[{"price":"close"},1]}"#
),
2
);
assert_eq!(
depth(
r#""entry":{"rising":[{"price":"close"},9]},"exit":{"lt":[{"price":"close"},1]}"#
),
10
);
assert_eq!(
depth(
r#""entry":{"gt":[{"prev":[{"prev":[{"price":"close"},2]},3]},1]},
"exit":{"lt":[{"price":"close"},1]}"#
),
6
);
let vol = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},1]},
"exit":{"lt":[{"price":"close"},1]},
"sizing":{"type":"vol_target","target_vol":0.02,"lookback":20}}"#,
)
.unwrap();
assert_eq!(history_depth(&vol), 21);
}
#[test]
fn history_stays_bounded_over_a_long_run() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{"f":{"type":"Ema","params":[3]}},
"entry":{"cross_above":[{"price":"close"},"f"]},
"exit":{"cross_below":[{"price":"close"},"f"]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let mut bt = StreamingBacktest::new(&spec, 10_000.0).unwrap();
for i in 0..20_000i64 {
let px = 100.0 + ((i as f64) * 0.05).sin() * 5.0;
bt.step(&bar(i, px, px + 0.5, px - 0.5, px)).unwrap();
}
assert_eq!(bt.bars_seen, 20_000);
assert_eq!(bt.history_depth, 2);
assert_eq!(bt.history.len(), 2);
assert!(bt.num_trades() > 0);
}
#[test]
fn a_deep_lookback_still_sees_far_enough() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"rising":[{"price":"close"},40]},
"exit":{"falling":[{"price":"close"},40]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
assert_eq!(history_depth(&spec), 41);
let candles: Vec<Candle> = (0..400i64)
.map(|i| {
let px = 100.0 + ((i as f64) * 0.03).sin() * 10.0;
bar(i, px, px + 0.5, px - 0.5, px)
})
.collect();
let batch = run_with_capital(&spec, &candles, 10_000.0).unwrap();
assert!(batch.metrics.num_trades >= 1, "the fixture must trade");
let mut bt = StreamingBacktest::new(&spec, 10_000.0).unwrap();
for candle in &candles {
bt.step(candle).unwrap();
}
assert_eq!(bt.history.len(), 41);
let streamed = bt.finish();
assert_eq!(streamed.metrics.num_trades, batch.metrics.num_trades);
assert!((streamed.metrics.pnl - batch.metrics.pnl).abs() < 1e-9);
}
#[test]
fn streaming_matches_batch() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h",
"indicators":{"f":{"type":"Ema","params":[3]}},
"entry":{"cross_above":[{"price":"close"},"f"]},
"exit":{"cross_below":[{"price":"close"},"f"]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let candles: Vec<Candle> = (0..30i64)
.map(|i| {
let px = 100.0 + ((i as f64) * 0.5).sin() * 5.0;
bar(i, px, px + 0.5, px - 0.5, px)
})
.collect();
let batch = run_with_capital(&spec, &candles, 10_000.0).unwrap();
let mut bt = StreamingBacktest::new(&spec, 10_000.0).unwrap();
for c in &candles {
bt.step(c).unwrap();
}
let streamed = bt.finish();
assert!(batch.metrics.num_trades >= 1);
assert_eq!(batch.metrics.num_trades, streamed.metrics.num_trades);
assert_eq!(batch.trades.len(), streamed.trades.len());
assert_eq!(batch.equity.len(), streamed.equity.len());
assert!(
(batch.equity.last().unwrap().equity - streamed.equity.last().unwrap().equity).abs()
< 1e-12
);
}
#[test]
fn run_stream_matches_batch_and_tails_equity() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h",
"indicators":{"f":{"type":"Ema","params":[3]}},
"entry":{"cross_above":[{"price":"close"},"f"]},
"exit":{"cross_below":[{"price":"close"},"f"]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let candles: Vec<Candle> = (0..30i64)
.map(|i| {
let px = 100.0 + ((i as f64) * 0.5).sin() * 5.0;
bar(i, px, px + 0.5, px - 0.5, px)
})
.collect();
let batch = run_with_capital(&spec, &candles, 10_000.0).unwrap();
let mut tail: Vec<EquityPoint> = Vec::new();
let streamed = run_stream(&spec, &candles, 10_000.0, |i, bt| {
assert_eq!(bt.equity().len(), i + 1);
tail.push(bt.latest_equity().expect("a bar was marked"));
})
.unwrap();
assert_eq!(tail.len(), candles.len());
assert_eq!(streamed.equity.len(), batch.equity.len());
for (got, want) in tail.iter().zip(&streamed.equity) {
assert_eq!(got.time, want.time);
assert!((got.equity - want.equity).abs() < 1e-12);
}
assert_eq!(streamed.metrics.num_trades, batch.metrics.num_trades);
}
#[test]
fn pairwise_indicator_uses_reference_series() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h",
"indicators":{"c":{"type":"PearsonCorrelation","params":[3]}},
"entry":{"gt":["c",0.5]},
"exit":{"lt":["c",-2.0]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let primary: Vec<Candle> = [100.0, 101.0, 102.0, 101.0, 103.0, 102.0, 104.0, 103.0]
.iter()
.zip(0i64..)
.map(|(&c, i)| bar(i, c, c + 0.5, c - 0.5, c))
.collect();
let reference: Vec<Candle> = [50.0, 50.5, 51.0, 50.5, 51.5, 51.0, 52.0, 51.5]
.iter()
.zip(0i64..)
.map(|(&c, i)| bar(i, c, c + 0.2, c - 0.2, c))
.collect();
let with_ref = run_with_ref(&spec, &primary, &reference, 10_000.0).unwrap();
assert!(with_ref.metrics.num_trades >= 1);
let without_ref = run_with_capital(&spec, &primary, 10_000.0).unwrap();
assert_eq!(without_ref.metrics.num_trades, 0);
}
#[test]
fn pairwise_multi_output_exposes_fields() {
let mut ind = registry::build("RelativeStrengthAB", &[3.0, 3.0]).unwrap();
let mut names: Vec<&str> = Vec::new();
let prices = [
100.0, 102.0, 104.0, 103.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0,
];
for (i, &px) in prices.iter().enumerate() {
let c = Candle {
time: i64::try_from(i).unwrap(),
open: px,
high: px,
low: px,
close: px,
volume: 0.0,
};
let input = BarInput {
candle: &c,
reference: Some(px * 0.9),
deriv: None,
orderbook: None,
trades: &[],
cross_section: None,
};
if ind.update(&input).is_some() {
names = ind.fields().iter().map(|(n, _)| *n).collect();
}
}
assert!(names.contains(&"ratio"), "fields: {names:?}");
}
#[test]
fn run_with_ref_rejects_length_mismatch() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let a = [bar(0, 1.0, 1.0, 1.0, 1.0), bar(1, 1.0, 1.0, 1.0, 1.0)];
let b = [bar(0, 1.0, 1.0, 1.0, 1.0)];
assert!(run_with_ref(&spec, &a, &b, 10_000.0).is_err());
}
fn sample_tick(funding_rate: f64) -> DerivativesTick {
DerivativesTick {
funding_rate,
mark_price: 100.0,
index_price: 100.0,
futures_price: 100.0,
open_interest: 1000.0,
long_size: 600.0,
short_size: 400.0,
taker_buy_volume: 50.0,
taker_sell_volume: 40.0,
long_liquidation: 0.0,
short_liquidation: 0.0,
timestamp: 0,
}
}
#[test]
fn derivatives_indicator_uses_feed() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h",
"indicators":{"f":{"type":"FundingRate","params":[]}},
"entry":{"gt":["f",0.0]},
"exit":{"lt":["f",-1.0]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let candles: Vec<Candle> = (0i64..5)
.map(|i| bar(i, 100.0, 100.0, 100.0, 100.0))
.collect();
let derivs = vec![sample_tick(0.01); 5];
let with_feed = run_with_deriv(&spec, &candles, &derivs, 10_000.0).unwrap();
assert!(with_feed.metrics.num_trades >= 1);
let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
assert_eq!(without_feed.metrics.num_trades, 0);
}
#[test]
fn order_book_indicator_uses_feed() {
use crate::data::Level;
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h",
"indicators":{"i":{"type":"OrderBookImbalanceTop1","params":[]}},
"entry":{"gt":["i",0.0]},
"exit":{"lt":["i",-2.0]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let candles: Vec<Candle> = (0i64..5)
.map(|t| bar(t, 100.0, 100.0, 100.0, 100.0))
.collect();
let book = OrderBook {
bids: vec![Level {
price: 100.0,
size: 9.0,
}],
asks: vec![Level {
price: 101.0,
size: 1.0,
}],
};
let books = vec![book; 5];
let with_feed = run_with_orderbook(&spec, &candles, &books, 10_000.0).unwrap();
assert!(with_feed.metrics.num_trades >= 1);
let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
assert_eq!(without_feed.metrics.num_trades, 0);
}
#[test]
fn trade_indicator_replays_bar_trades() {
use crate::data::{TradePrint, TradeSide};
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h",
"indicators":{"cvd":{"type":"CumulativeVolumeDelta","params":[]}},
"entry":{"gt":["cvd",0.0]},
"exit":{"lt":["cvd",-1.0]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let candles: Vec<Candle> = (0i64..5)
.map(|t| bar(t, 100.0, 100.0, 100.0, 100.0))
.collect();
let buy = TradePrint {
price: 100.0,
size: 5.0,
side: TradeSide::Buy,
timestamp: 0,
};
let trades: Vec<Vec<TradePrint>> = (0..5).map(|_| vec![buy, buy]).collect();
let with_feed = run_with_trades(&spec, &candles, &trades, 10_000.0).unwrap();
assert!(with_feed.metrics.num_trades >= 1);
let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
assert_eq!(without_feed.metrics.num_trades, 0);
}
#[test]
fn funding_charges_an_open_long() {
let with_funding = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"in_position":false},
"sizing":{"type":"fixed_qty","qty":1},
"costs":{"funding":true}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0),
bar(1, 100.0, 100.0, 100.0, 100.0),
bar(2, 100.0, 100.0, 100.0, 100.0),
];
let derivs = vec![sample_tick(0.01); 3];
let funded = run_with_deriv(&with_funding, &candles, &derivs, 10_000.0).unwrap();
assert!(funded.fees_paid > 0.0);
let no_funding = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"in_position":false},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let unfunded = run_with_deriv(&no_funding, &candles, &derivs, 10_000.0).unwrap();
assert!(funded.equity.last().unwrap().equity < unfunded.equity.last().unwrap().equity);
}
#[test]
fn leverage_liquidation_closes_at_bankruptcy() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},
"exit":{"in_position":false},
"sizing":{"type":"fixed_cash","cash":5000},
"risk":{"max_leverage":5,"liquidation":true}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0), bar(1, 100.0, 100.0, 95.0, 98.0), bar(2, 90.0, 90.0, 70.0, 75.0), ];
let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
assert_eq!(r.trades.len(), 1);
assert_eq!(r.trades[0].reason, "liquidation");
assert!((r.trades[0].exit_price - 80.0).abs() < 1e-9);
assert!(r.equity.last().unwrap().equity.abs() < 1e-6); }
#[test]
fn limit_entry_pays_maker_fee() {
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0),
bar(1, 100.0, 100.0, 100.0, 100.0),
bar(2, 100.0, 100.0, 100.0, 100.0),
];
let market = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":false},
"sizing":{"type":"fixed_qty","qty":1},
"costs":{"maker_bps":0,"taker_bps":200}}"#,
)
.unwrap();
let limit = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":false},
"sizing":{"type":"fixed_qty","qty":1},
"costs":{"maker_bps":0,"taker_bps":200},
"execution":{"order_type":"limit","limit_offset_pct":0.0}}"#,
)
.unwrap();
let market_fees = run_with_capital(&market, &candles, 10_000.0)
.unwrap()
.fees_paid;
let limit_fees = run_with_capital(&limit, &candles, 10_000.0)
.unwrap()
.fees_paid;
assert!(limit_fees < market_fees); }
#[test]
fn volume_impact_slippage_worsens_the_fill() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":false},
"sizing":{"type":"fixed_qty","qty":10},
"costs":{"slippage":{"type":"volume_impact","coef":0.5}}}"#,
)
.unwrap();
let vbar = |t, vol| Candle {
time: t,
open: 100.0,
high: 100.0,
low: 100.0,
close: 100.0,
volume: vol,
};
let candles = [vbar(0, 1000.0), vbar(1, 1000.0), vbar(2, 1000.0)];
let r = run_with_capital(&spec, &candles, 1_000_000.0).unwrap();
assert!((r.trades[0].entry_price - 100.5).abs() < 1e-9);
}
#[test]
fn spread_slippage_uses_the_order_book() {
use crate::data::Level;
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":false},
"sizing":{"type":"fixed_qty","qty":1},
"costs":{"slippage":{"type":"spread"}}}"#,
)
.unwrap();
let candles = [
bar(0, 100.0, 100.0, 100.0, 100.0),
bar(1, 100.0, 100.0, 100.0, 100.0),
bar(2, 100.0, 100.0, 100.0, 100.0),
];
let book = OrderBook {
bids: vec![Level {
price: 99.0,
size: 1.0,
}],
asks: vec![Level {
price: 101.0,
size: 1.0,
}],
};
let books = vec![book; 3];
let r = run_with_orderbook(&spec, &candles, &books, 10_000.0).unwrap();
assert!((r.trades[0].entry_price - 101.0).abs() < 1e-9);
}
#[test]
fn trade_quote_indicator_uses_trades_and_mid() {
use crate::data::{TradePrint, TradeSide};
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h",
"indicators":{"es":{"type":"EffectiveSpread","params":[]}},
"entry":{"gt":["es",0.0]},
"exit":{"lt":["es",-1.0]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let candles: Vec<Candle> = (0i64..5)
.map(|t| bar(t, 100.0, 100.0, 100.0, 100.0))
.collect();
let trade = TradePrint {
price: 102.0,
size: 1.0,
side: TradeSide::Buy,
timestamp: 0,
};
let trades: Vec<Vec<TradePrint>> = (0..5).map(|_| vec![trade]).collect();
let with_feed = run_with_trades(&spec, &candles, &trades, 10_000.0).unwrap();
assert!(with_feed.metrics.num_trades >= 1);
let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
assert_eq!(without_feed.metrics.num_trades, 0);
}
#[test]
fn cross_section_breadth_indicator_uses_feed() {
use crate::data::{CrossSection, CrossSectionMember};
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h",
"indicators":{"ad":{"type":"AdvanceDecline","params":[]}},
"entry":{"gt":["ad",0.0]},
"exit":{"lt":["ad",-100.0]},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let candles: Vec<Candle> = (0i64..4)
.map(|t| bar(t, 100.0, 100.0, 100.0, 100.0))
.collect();
let advancer = CrossSectionMember {
change: 1.0,
volume: 100.0,
new_high: false,
new_low: false,
};
let decliner = CrossSectionMember {
change: -1.0,
volume: 100.0,
new_high: false,
new_low: false,
};
let section = CrossSection {
members: vec![advancer, advancer, advancer, decliner],
timestamp: 0,
};
let sections = vec![section; 4];
let with_feed = run_with_cross_section(&spec, &candles, §ions, 10_000.0).unwrap();
assert!(with_feed.metrics.num_trades >= 1);
let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
assert_eq!(without_feed.metrics.num_trades, 0);
}
#[test]
fn run_with_deriv_rejects_length_mismatch() {
let spec = StrategySpec::parse(
r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
"sizing":{"type":"fixed_qty","qty":1}}"#,
)
.unwrap();
let candles = [bar(0, 1.0, 1.0, 1.0, 1.0), bar(1, 1.0, 1.0, 1.0, 1.0)];
let derivs = [sample_tick(0.0)];
assert!(run_with_deriv(&spec, &candles, &derivs, 10_000.0).is_err());
}
#[test]
fn new_owned_matches_the_borrowing_constructor() {
let json = r#"{"symbol":"x","timeframe":"1h","indicators":{},
"entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
"sizing":{"type":"fixed_qty","qty":1}}"#;
let spec = StrategySpec::parse(json).unwrap();
let candles = [
bar(0, 1.0, 1.0, 1.0, 1.0),
bar(1, 1.0, 2.0, 1.0, 2.0),
bar(2, 2.0, 3.0, 2.0, 3.0),
];
let mut borrowed = StreamingBacktest::new(&spec, 10_000.0).unwrap();
for candle in &candles {
borrowed.step(candle).unwrap();
}
let borrowed_report = borrowed.finish();
let mut owned = StreamingBacktest::new_owned(spec, 10_000.0).unwrap();
for candle in &candles {
owned.step(candle).unwrap();
}
let owned_report = owned.finish();
assert_eq!(
serde_json::to_string(&owned_report).unwrap(),
serde_json::to_string(&borrowed_report).unwrap()
);
}
}