1use std::borrow::Cow;
26use std::collections::BTreeMap;
27use std::fmt;
28use std::sync::Arc;
29
30use wickra_core::{
31 CrossSection as CoreCrossSection, DerivativesTick as CoreDerivativesTick,
32 OrderBook as CoreOrderBook, Trade as CoreTrade,
33};
34
35use crate::data::{Candle, CrossSection, DerivativesTick, OrderBook, TradePrint};
36use crate::error::{BacktestError, Result};
37use crate::metrics;
38use crate::portfolio::Portfolio;
39use crate::registry::{self, BarInput, EvalIndicator};
40use crate::report::{BacktestReport, EquityPoint, REPORT_SCHEMA_VERSION};
41use crate::rules::{condition_lookback, eval_condition, BarRow, RuleState};
42use crate::spec::{Execution, FillTiming, OrderType, Risk, Sizing, Slippage, StrategySpec};
43
44pub const DEFAULT_CAPITAL: f64 = 10_000.0;
46
47#[derive(Debug, Clone, Copy)]
48enum Side {
49 Long,
50 Short,
51}
52
53#[derive(Debug, Clone, Copy)]
55enum LevelKind {
56 Limit,
57 Stop,
58 StopLimit {
62 limit: f64,
63 },
64}
65
66#[derive(Debug)]
68enum Action {
69 Enter {
72 side: Side,
73 trigger: Option<(f64, LevelKind)>,
74 },
75 Exit(&'static str),
77}
78
79#[derive(Debug)]
82struct Pending {
83 action: Action,
84 delay: u32,
85}
86
87fn level_fill(side: Side, trigger: f64, kind: LevelKind, c: &Candle) -> Option<f64> {
91 let is_buy = matches!(side, Side::Long);
92 match (is_buy, kind) {
93 (true, LevelKind::Limit) => (c.low <= trigger).then(|| c.open.min(trigger)),
94 (true, LevelKind::Stop) => (c.high >= trigger).then(|| c.open.max(trigger)),
95 (false, LevelKind::Limit) => (c.high >= trigger).then(|| c.open.max(trigger)),
96 (false, LevelKind::Stop) => (c.low <= trigger).then(|| c.open.min(trigger)),
97 (true, LevelKind::StopLimit { limit }) => {
105 (c.high >= trigger && c.low <= limit).then(|| limit.min(c.open.max(trigger)))
106 }
107 (false, LevelKind::StopLimit { limit }) => {
108 (c.low <= trigger && c.high >= limit).then(|| limit.max(c.open.min(trigger)))
109 }
110 }
111}
112
113fn entry_trigger(exec: &Execution, signal_close: f64) -> Option<(f64, LevelKind)> {
116 match exec.order_type {
117 OrderType::Limit => Some((
118 signal_close * (1.0 + exec.limit_offset_pct.unwrap_or(0.0) / 100.0),
119 LevelKind::Limit,
120 )),
121 OrderType::Stop => Some((
122 signal_close * (1.0 + exec.stop_offset_pct.unwrap_or(0.0) / 100.0),
123 LevelKind::Stop,
124 )),
125 OrderType::StopLimit => Some((
126 signal_close * (1.0 + exec.stop_offset_pct.unwrap_or(0.0) / 100.0),
127 LevelKind::StopLimit {
128 limit: signal_close * (1.0 + exec.limit_offset_pct.unwrap_or(0.0) / 100.0),
129 },
130 )),
131 OrderType::Market => None,
132 }
133}
134
135fn realized_vol(history: &[BarRow], lookback: usize) -> Option<f64> {
139 if lookback < 2 || history.len() < lookback {
140 return None;
141 }
142 let closes: Vec<f64> = history[history.len() - lookback..]
143 .iter()
144 .map(|row| row.candle.close)
145 .collect();
146 let rets: Vec<f64> = closes
147 .windows(2)
148 .filter(|w| w[0].abs() > f64::EPSILON)
149 .map(|w| (w[1] - w[0]) / w[0])
150 .collect();
151 if rets.is_empty() {
152 return None;
153 }
154 let mean = rets.iter().sum::<f64>() / rets.len() as f64;
155 let var = rets.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / rets.len() as f64;
156 let sd = var.sqrt();
157 (sd > 0.0).then_some(sd)
158}
159
160fn slippage_rate(
164 slippage: Slippage,
165 orderbook: Option<&CoreOrderBook>,
166 qty: f64,
167 volume: f64,
168) -> f64 {
169 match slippage {
170 Slippage::FixedBps { bps } => bps / 10_000.0,
171 Slippage::Spread => orderbook.map_or(0.0, |ob| match (ob.best_bid(), ob.best_ask()) {
172 (Some(bid), Some(ask)) => {
173 let mid = f64::midpoint(ask.price, bid.price);
174 if mid > 0.0 {
175 (ask.price - bid.price) / 2.0 / mid
176 } else {
177 0.0
178 }
179 }
180 _ => 0.0,
181 }),
182 Slippage::VolumeImpact { coef } => {
183 if volume > 0.0 {
184 coef * qty.abs() / volume
185 } else {
186 0.0
187 }
188 }
189 }
190}
191
192struct FillCtx<'a> {
194 spec: &'a StrategySpec,
195 candle: &'a Candle,
196 history: &'a [BarRow],
197 orderbook: Option<&'a CoreOrderBook>,
198 maker: f64,
199 taker: f64,
200 bar: usize,
201}
202
203fn execute_entry(
207 side: Side,
208 raw_price: f64,
209 maker_fill: bool,
210 ctx: &FillCtx,
211 pf: &mut Portfolio,
212 entry_bar: &mut Option<usize>,
213 extreme: &mut f64,
214) -> Result<()> {
215 let dir = match side {
216 Side::Long => 1.0,
217 Side::Short => -1.0,
218 };
219 let rv = match ctx.spec.sizing {
220 Sizing::VolTarget { lookback, .. } => realized_vol(ctx.history, lookback as usize),
221 _ => None,
222 };
223 let probe_qty = match ctx.spec.costs.slippage {
225 Slippage::VolumeImpact { .. } => {
226 size(ctx.spec.sizing, &ctx.spec.risk, pf.cash, raw_price, rv)?.unwrap_or(0.0)
227 }
228 _ => 0.0,
229 };
230 let slip = slippage_rate(
231 ctx.spec.costs.slippage,
232 ctx.orderbook,
233 probe_qty,
234 ctx.candle.volume,
235 );
236 let fill = raw_price * (1.0 + dir * slip);
237 if let Some(base) = size(ctx.spec.sizing, &ctx.spec.risk, pf.cash, fill, rv)? {
238 let base = if ctx.spec.execution.partial_fills {
241 let cap = ctx.spec.execution.max_participation.unwrap_or(0.0) * ctx.candle.volume;
242 base.min(cap)
243 } else {
244 base
245 };
246 if base > 0.0 {
247 let rate = if maker_fill { ctx.maker } else { ctx.taker };
248 let fee = base * fill * rate;
249 pf.enter(dir * base, fill, ctx.candle.time, fee);
250 *entry_bar = Some(ctx.bar);
251 *extreme = fill;
252 }
253 }
254 Ok(())
255}
256
257fn execute_exit(
259 reason: &'static str,
260 raw_price: f64,
261 ctx: &FillCtx,
262 pf: &mut Portfolio,
263 entry_bar: &mut Option<usize>,
264) {
265 if !pf.in_position() {
266 return;
267 }
268 let dir = if pf.is_long() { -1.0 } else { 1.0 };
270 let slip = slippage_rate(
271 ctx.spec.costs.slippage,
272 ctx.orderbook,
273 pf.qty,
274 ctx.candle.volume,
275 );
276 let fill = raw_price * (1.0 + dir * slip);
277 let fee = pf.qty.abs() * fill * ctx.taker;
278 pf.exit(fill, ctx.candle.time, fee, reason);
279 *entry_bar = None;
280}
281
282#[derive(Debug, Default)]
286pub struct Feeds<'a> {
287 pub reference: Option<f64>,
289 pub deriv: Option<&'a DerivativesTick>,
291 pub orderbook: Option<&'a OrderBook>,
293 pub trades: Option<&'a [TradePrint]>,
295 pub cross_section: Option<&'a CrossSection>,
297}
298
299#[derive(Debug)]
305struct Bar<'a> {
306 candle: &'a Candle,
307 reference: Option<f64>,
308 deriv: Option<CoreDerivativesTick>,
309 orderbook: Option<CoreOrderBook>,
310 cross_section: Option<CoreCrossSection>,
311 trades: Vec<CoreTrade>,
312 index: usize,
313}
314
315struct Indicator {
322 name: Arc<str>,
323 field_keys: Vec<(&'static str, Arc<str>)>,
326 eval: Box<dyn EvalIndicator>,
327}
328
329fn history_depth(spec: &StrategySpec) -> usize {
336 let mut back = condition_lookback(&spec.entry).max(condition_lookback(&spec.exit));
337 if let Some(cond) = &spec.short_entry {
338 back = back.max(condition_lookback(cond));
339 }
340 if let Some(cond) = &spec.short_exit {
341 back = back.max(condition_lookback(cond));
342 }
343 if let Sizing::VolTarget { lookback, .. } = spec.sizing {
345 back = back.max(lookback as usize);
346 }
347 back + 1
348}
349
350pub(crate) fn require_feeds(
362 spec: &StrategySpec,
363 has_orderbook: bool,
364 has_deriv: bool,
365) -> Result<()> {
366 if matches!(spec.costs.slippage, Slippage::Spread) && !has_orderbook {
367 return Err(BacktestError::InvalidSpec(
368 "costs.slippage spread needs an order-book feed; without one every fill would be priced at zero slippage"
369 .into(),
370 ));
371 }
372 if spec.costs.funding && !has_deriv {
373 return Err(BacktestError::InvalidSpec(
374 "costs.funding needs a derivatives feed; without one no funding would be charged at all"
375 .into(),
376 ));
377 }
378 Ok(())
379}
380
381pub fn run(spec: &StrategySpec, candles: &[Candle]) -> Result<BacktestReport> {
413 run_with_capital(spec, candles, DEFAULT_CAPITAL)
414}
415
416pub fn run_with_capital(
418 spec: &StrategySpec,
419 candles: &[Candle],
420 capital: f64,
421) -> Result<BacktestReport> {
422 spec.validate()?;
423 require_feeds(spec, false, false)?;
424 if candles.is_empty() {
425 return Err(BacktestError::InvalidData("no candles".into()));
426 }
427 let mut bt = StreamingBacktest::new(spec, capital)?;
428 for candle in candles {
429 bt.step(candle)?;
430 }
431 Ok(bt.finish())
432}
433
434pub fn run_stream<F>(
443 spec: &StrategySpec,
444 candles: &[Candle],
445 capital: f64,
446 mut on_bar: F,
447) -> Result<BacktestReport>
448where
449 F: FnMut(usize, &StreamingBacktest),
450{
451 spec.validate()?;
452 require_feeds(spec, false, false)?;
453 if candles.is_empty() {
454 return Err(BacktestError::InvalidData("no candles".into()));
455 }
456 let mut bt = StreamingBacktest::new(spec, capital)?;
457 for (i, candle) in candles.iter().enumerate() {
458 bt.step(candle)?;
459 on_bar(i, &bt);
460 }
461 Ok(bt.finish())
462}
463
464pub fn run_with_ref(
469 spec: &StrategySpec,
470 candles: &[Candle],
471 reference: &[Candle],
472 capital: f64,
473) -> Result<BacktestReport> {
474 spec.validate()?;
475 require_feeds(spec, false, false)?;
476 if candles.is_empty() {
477 return Err(BacktestError::InvalidData("no candles".into()));
478 }
479 if reference.len() != candles.len() {
480 return Err(BacktestError::InvalidData(
481 "reference series must have the same length as the candles".into(),
482 ));
483 }
484 let mut bt = StreamingBacktest::new(spec, capital)?;
485 for (candle, ref_candle) in candles.iter().zip(reference) {
486 bt.step_with_ref(candle, Some(ref_candle.close))?;
487 }
488 Ok(bt.finish())
489}
490
491pub fn run_with_deriv(
495 spec: &StrategySpec,
496 candles: &[Candle],
497 derivs: &[DerivativesTick],
498 capital: f64,
499) -> Result<BacktestReport> {
500 spec.validate()?;
501 require_feeds(spec, false, true)?;
502 if candles.is_empty() {
503 return Err(BacktestError::InvalidData("no candles".into()));
504 }
505 if derivs.len() != candles.len() {
506 return Err(BacktestError::InvalidData(
507 "derivatives feed must have the same length as the candles".into(),
508 ));
509 }
510 let mut bt = StreamingBacktest::new(spec, capital)?;
511 for (candle, d) in candles.iter().zip(derivs) {
512 bt.step_with_feeds(
513 candle,
514 &Feeds {
515 deriv: Some(d),
516 ..Default::default()
517 },
518 )?;
519 }
520 Ok(bt.finish())
521}
522
523pub fn run_with_orderbook(
527 spec: &StrategySpec,
528 candles: &[Candle],
529 books: &[OrderBook],
530 capital: f64,
531) -> Result<BacktestReport> {
532 spec.validate()?;
533 require_feeds(spec, true, false)?;
534 if candles.is_empty() {
535 return Err(BacktestError::InvalidData("no candles".into()));
536 }
537 if books.len() != candles.len() {
538 return Err(BacktestError::InvalidData(
539 "order-book feed must have the same length as the candles".into(),
540 ));
541 }
542 let mut bt = StreamingBacktest::new(spec, capital)?;
543 for (candle, ob) in candles.iter().zip(books) {
544 bt.step_with_feeds(
545 candle,
546 &Feeds {
547 orderbook: Some(ob),
548 ..Default::default()
549 },
550 )?;
551 }
552 Ok(bt.finish())
553}
554
555pub fn run_with_trades(
559 spec: &StrategySpec,
560 candles: &[Candle],
561 trades: &[Vec<TradePrint>],
562 capital: f64,
563) -> Result<BacktestReport> {
564 spec.validate()?;
565 require_feeds(spec, false, false)?;
566 if candles.is_empty() {
567 return Err(BacktestError::InvalidData("no candles".into()));
568 }
569 if trades.len() != candles.len() {
570 return Err(BacktestError::InvalidData(
571 "trade feed must have one trade list per candle".into(),
572 ));
573 }
574 let mut bt = StreamingBacktest::new(spec, capital)?;
575 for (candle, bar_trades) in candles.iter().zip(trades) {
576 bt.step_with_feeds(
577 candle,
578 &Feeds {
579 trades: Some(bar_trades.as_slice()),
580 ..Default::default()
581 },
582 )?;
583 }
584 Ok(bt.finish())
585}
586
587pub fn run_with_cross_section(
591 spec: &StrategySpec,
592 candles: &[Candle],
593 sections: &[CrossSection],
594 capital: f64,
595) -> Result<BacktestReport> {
596 spec.validate()?;
597 require_feeds(spec, false, false)?;
598 if candles.is_empty() {
599 return Err(BacktestError::InvalidData("no candles".into()));
600 }
601 if sections.len() != candles.len() {
602 return Err(BacktestError::InvalidData(
603 "cross-section feed must have one panel per candle".into(),
604 ));
605 }
606 let mut bt = StreamingBacktest::new(spec, capital)?;
607 for (candle, cs) in candles.iter().zip(sections) {
608 bt.step_with_feeds(
609 candle,
610 &Feeds {
611 cross_section: Some(cs),
612 ..Default::default()
613 },
614 )?;
615 }
616 Ok(bt.finish())
617}
618
619pub struct StreamingBacktest<'a> {
637 spec: Cow<'a, StrategySpec>,
638 capital: f64,
639 maker: f64,
640 taker: f64,
641 warmup: usize,
642 indicators: Vec<Indicator>,
643 pf: Portfolio,
644 history: Vec<BarRow>,
649 history_depth: usize,
650 bars_seen: usize,
653 equity: Vec<EquityPoint>,
654 pending: Option<Pending>,
655 entry_bar: Option<usize>,
656 extreme: f64,
659 last: Option<(i64, f64)>,
661}
662
663impl fmt::Debug for StreamingBacktest<'_> {
668 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
669 f.debug_struct("StreamingBacktest")
670 .field("capital", &self.capital)
671 .field("warmup", &self.warmup)
672 .field(
673 "indicators",
674 &self.indicators.iter().map(|i| &*i.name).collect::<Vec<_>>(),
675 )
676 .field("bars", &self.bars_seen)
677 .field("equity_points", &self.equity.len())
678 .field("trades", &self.pf.trades.len())
679 .field("pending", &self.pending)
680 .field("entry_bar", &self.entry_bar)
681 .finish_non_exhaustive()
682 }
683}
684
685impl<'a> StreamingBacktest<'a> {
686 pub fn new(spec: &'a StrategySpec, capital: f64) -> Result<Self> {
725 Self::from_spec(Cow::Borrowed(spec), capital)
726 }
727
728 fn from_spec(spec: Cow<'a, StrategySpec>, capital: f64) -> Result<Self> {
731 spec.validate()?;
732 let mut indicators: Vec<Indicator> = Vec::with_capacity(spec.indicators.len());
734 let mut max_warmup = 0usize;
735 for (name, ind) in &spec.indicators {
736 let built = registry::build(&ind.kind, &ind.params)?;
737 max_warmup = max_warmup.max(built.warmup());
738 indicators.push(Indicator {
739 name: Arc::from(name.as_str()),
740 field_keys: Vec::new(),
741 eval: built,
742 });
743 }
744 let warmup = spec.warmup.map_or(max_warmup, |w| w as usize);
745 let history_depth = history_depth(&spec);
746 let maker = spec.costs.maker_bps / 10_000.0;
747 let taker = spec.costs.taker_bps / 10_000.0;
748 Ok(Self {
749 spec,
750 capital,
751 maker,
752 taker,
753 warmup,
754 indicators,
755 pf: Portfolio::new(capital),
756 history: Vec::with_capacity(history_depth),
757 history_depth,
758 bars_seen: 0,
759 equity: Vec::new(),
760 pending: None,
761 entry_bar: None,
762 extreme: 0.0,
763 last: None,
764 })
765 }
766
767 pub fn step(&mut self, candle: &Candle) -> Result<()> {
770 self.step_with_feeds(candle, &Feeds::default())
771 }
772
773 pub fn equity(&self) -> &[EquityPoint] {
776 &self.equity
777 }
778
779 pub fn latest_equity(&self) -> Option<EquityPoint> {
782 self.equity.last().copied()
783 }
784
785 pub fn num_trades(&self) -> usize {
787 self.pf.trades.len()
788 }
789
790 pub fn step_with_ref(&mut self, candle: &Candle, reference: Option<f64>) -> Result<()> {
794 self.step_with_feeds(
795 candle,
796 &Feeds {
797 reference,
798 ..Default::default()
799 },
800 )
801 }
802
803 pub fn step_with_feeds(&mut self, candle: &Candle, feeds: &Feeds) -> Result<()> {
812 require_feeds(&self.spec, feeds.orderbook.is_some(), feeds.deriv.is_some())?;
820 let bar = Bar {
821 candle,
822 reference: feeds.reference,
823 deriv: feeds.deriv.and_then(|d| d.to_core().ok()),
824 orderbook: feeds.orderbook.and_then(|ob| ob.to_core().ok()),
825 cross_section: feeds.cross_section.and_then(|cs| cs.to_core().ok()),
826 trades: feeds
827 .trades
828 .unwrap_or(&[])
829 .iter()
830 .filter_map(|tp| tp.to_core().ok())
831 .collect(),
832 index: self.bars_seen,
833 };
834 self.last = Some((candle.time, candle.close));
835
836 self.fill_working_order(&bar)?;
845 let idx = self.record_bar(&bar);
846 self.apply_intrabar_exits(&bar);
847 self.charge_funding(&bar);
848 self.mark_equity(&bar);
849 self.decide_next_action(&bar, idx)
850 }
851
852 fn fill_working_order(&mut self, bar: &Bar) -> Result<()> {
854 let candle = bar.candle;
855 let orderbook = &bar.orderbook;
856 let t = bar.index;
857 if let Some(mut order) = self.pending.take() {
862 if order.delay > 0 {
863 order.delay -= 1;
864 self.pending = Some(order); } else {
866 let ctx = FillCtx {
867 spec: &self.spec,
868 candle,
869 history: &self.history,
870 maker: self.maker,
871 taker: self.taker,
872 orderbook: orderbook.as_ref(),
873 bar: t,
874 };
875 let keep_working = match &order.action {
876 Action::Enter { side, trigger } => {
877 let side = *side;
878 let maker_fill = matches!(trigger, Some((_, LevelKind::Limit)));
880 let level = match trigger {
881 None => Some(candle.open),
882 Some((trig, kind)) => level_fill(side, *trig, *kind, candle),
883 };
884 match level {
885 Some(px) => {
886 execute_entry(
887 side,
888 px,
889 maker_fill,
890 &ctx,
891 &mut self.pf,
892 &mut self.entry_bar,
893 &mut self.extreme,
894 )?;
895 false
896 }
897 None => true, }
899 }
900 Action::Exit(reason) => {
901 execute_exit(reason, candle.open, &ctx, &mut self.pf, &mut self.entry_bar);
902 false
903 }
904 };
905 if keep_working {
906 self.pending = Some(order);
907 }
908 }
909 }
910
911 Ok(())
912 }
913
914 fn record_bar(&mut self, bar: &Bar) -> usize {
919 let candle = bar.candle;
920 let reference = bar.reference;
921 let deriv = bar.deriv;
922 let orderbook = &bar.orderbook;
923 let cross_section = &bar.cross_section;
924 let trades: &[CoreTrade] = &bar.trades;
925 let mut values = BTreeMap::new();
927 for ind in &mut self.indicators {
928 let input = BarInput {
929 candle,
930 reference,
931 deriv,
932 orderbook: orderbook.as_ref(),
933 trades,
934 cross_section: cross_section.as_ref(),
935 };
936 if let Some(v) = ind.eval.update(&input) {
937 values.insert(Arc::clone(&ind.name), v);
938 let fields = ind.eval.fields();
939 for (field, fv) in fields {
940 let key =
943 if let Some((_, key)) = ind.field_keys.iter().find(|(f, _)| *f == field) {
944 Arc::clone(key)
945 } else {
946 let key: Arc<str> = Arc::from(format!("{}.{field}", ind.name).as_str());
947 ind.field_keys.push((field, Arc::clone(&key)));
948 key
949 };
950 values.insert(key, fv);
951 }
952 }
953 }
954 let row = BarRow {
955 candle: *candle,
956 values,
957 };
958 if self.history.len() == self.history_depth {
959 self.history.rotate_left(1);
962 self.history[self.history_depth - 1] = row;
963 } else {
964 self.history.push(row);
965 }
966 self.bars_seen += 1;
967 self.history.len() - 1
969 }
970
971 fn apply_intrabar_exits(&mut self, bar: &Bar) {
973 let candle = bar.candle;
974 if self.pf.in_position() {
976 self.extreme = if self.pf.is_long() {
978 self.extreme.max(candle.high)
979 } else {
980 self.extreme.min(candle.low)
981 };
982 if let Some((price, reason)) = intrabar_exit(
983 candle,
984 &self.spec.risk,
985 self.pf.entry_price,
986 self.extreme,
987 self.pf.is_long(),
988 ) {
989 let fee = self.pf.qty.abs() * price * self.taker;
990 self.pf.exit(price, candle.time, fee, reason);
991 self.entry_bar = None;
992 } else if self.spec.risk.liquidation {
993 let p_liq = -self.pf.cash / self.pf.qty;
995 let breached = if self.pf.is_long() {
996 candle.low <= p_liq
997 } else {
998 candle.high >= p_liq
999 };
1000 if p_liq > 0.0 && breached {
1001 let fee = self.pf.qty.abs() * p_liq * self.taker;
1002 self.pf.exit(p_liq, candle.time, fee, "liquidation");
1003 self.entry_bar = None;
1004 }
1005 }
1006 }
1007 }
1008
1009 fn charge_funding(&mut self, bar: &Bar) {
1011 let deriv = bar.deriv;
1012 if self.spec.costs.funding && self.pf.in_position() {
1014 if let Some(d) = deriv {
1015 let payment = self.pf.qty * d.mark_price * d.funding_rate;
1017 self.pf.apply_funding(payment);
1018 }
1019 }
1020 }
1021
1022 fn mark_equity(&mut self, bar: &Bar) {
1024 let candle = bar.candle;
1025 self.equity.push(EquityPoint {
1027 time: candle.time,
1028 equity: self.pf.equity(candle.close),
1029 });
1030 }
1031
1032 fn decide_next_action(&mut self, bar: &Bar, idx: usize) -> Result<()> {
1034 let candle = bar.candle;
1035 let orderbook = &bar.orderbook;
1036 let t = bar.index;
1037 if t < self.warmup {
1044 return Ok(());
1045 }
1046 let bars_since_entry = self.entry_bar.map(|e| (t - e) as u32);
1047 let state = RuleState {
1048 in_position: self.pf.in_position(),
1049 bars_since_entry,
1050 };
1051 let close_fill = matches!(self.spec.execution.fill_timing, FillTiming::Close);
1054
1055 if self.pf.in_position() {
1056 let cond = if self.pf.is_long() {
1057 &self.spec.exit
1058 } else {
1059 self.spec.short_exit.as_ref().unwrap_or(&self.spec.exit)
1060 };
1061 if eval_condition(cond, &self.history, idx, state) {
1062 if close_fill {
1063 let ctx = FillCtx {
1064 spec: &self.spec,
1065 candle,
1066 history: &self.history,
1067 maker: self.maker,
1068 taker: self.taker,
1069 orderbook: orderbook.as_ref(),
1070 bar: t,
1071 };
1072 execute_exit(
1073 "signal",
1074 candle.close,
1075 &ctx,
1076 &mut self.pf,
1077 &mut self.entry_bar,
1078 );
1079 } else {
1080 self.pending = Some(Pending {
1081 action: Action::Exit("signal"),
1082 delay: self.spec.execution.latency_bars,
1083 });
1084 }
1085 }
1086 } else if self.pending.is_none() {
1087 let entry_fires = eval_condition(&self.spec.entry, &self.history, idx, state);
1090 let short_fires = !entry_fires
1091 && self
1092 .spec
1093 .short_entry
1094 .as_ref()
1095 .is_some_and(|c| eval_condition(c, &self.history, idx, state));
1096 let side = if entry_fires {
1097 Some(Side::Long)
1098 } else if short_fires {
1099 Some(Side::Short)
1100 } else {
1101 None
1102 };
1103 if let Some(side) = side {
1104 if close_fill {
1105 let ctx = FillCtx {
1106 spec: &self.spec,
1107 candle,
1108 history: &self.history,
1109 maker: self.maker,
1110 taker: self.taker,
1111 orderbook: orderbook.as_ref(),
1112 bar: t,
1113 };
1114 execute_entry(
1115 side,
1116 candle.close,
1117 false, &ctx,
1119 &mut self.pf,
1120 &mut self.entry_bar,
1121 &mut self.extreme,
1122 )?;
1123 } else {
1124 let trigger = entry_trigger(&self.spec.execution, candle.close);
1125 self.pending = Some(Pending {
1126 action: Action::Enter { side, trigger },
1127 delay: self.spec.execution.latency_bars,
1128 });
1129 }
1130 }
1131 }
1132 Ok(())
1133 }
1134
1135 pub fn finish(mut self) -> BacktestReport {
1137 if self.pf.in_position() {
1138 if let Some((time, close)) = self.last {
1139 let fee = self.pf.qty.abs() * close * self.taker;
1140 self.pf.exit(close, time, fee, "end");
1141 }
1142 }
1143 let series: Vec<f64> = self.equity.iter().map(|e| e.equity).collect();
1144 let metrics = metrics::compute(self.capital, &series, &self.pf.trades);
1145 BacktestReport {
1146 schema_version: REPORT_SCHEMA_VERSION,
1147 symbol: self.spec.symbol.clone(),
1148 timeframe: self.spec.timeframe.clone(),
1149 metrics,
1150 trades: self.pf.trades,
1151 equity: self.equity,
1152 fees_paid: self.pf.fees_paid,
1153 initial_capital: self.capital,
1154 }
1155 }
1156}
1157
1158impl StreamingBacktest<'static> {
1159 pub fn new_owned(spec: StrategySpec, capital: f64) -> Result<Self> {
1169 Self::from_spec(Cow::Owned(spec), capital)
1170 }
1171}
1172
1173fn size(
1180 sizing: Sizing,
1181 risk: &Risk,
1182 equity: f64,
1183 price: f64,
1184 realized_vol: Option<f64>,
1185) -> Result<Option<f64>> {
1186 if price <= 0.0 || equity <= 0.0 {
1187 return Ok(None);
1188 }
1189 let qty = match sizing {
1190 Sizing::FixedFraction { fraction } => (equity * fraction) / price,
1191 Sizing::FixedCash { cash: notional } => notional / price,
1192 Sizing::FixedQty { qty } => qty,
1193 Sizing::RiskPerTrade { risk_pct } => {
1194 let stop = risk.stop_loss_pct.ok_or_else(|| {
1197 BacktestError::InvalidSpec(
1198 "risk_per_trade sizing requires risk.stop_loss_pct".into(),
1199 )
1200 })?;
1201 if stop <= 0.0 {
1202 return Ok(None);
1203 }
1204 (equity * risk_pct / 100.0) / (price * stop / 100.0)
1205 }
1206 Sizing::VolTarget { target_vol, .. } => {
1207 let Some(rv) = realized_vol else {
1210 return Ok(None);
1211 };
1212 (equity * target_vol / rv) / price
1213 }
1214 };
1215 if qty <= 0.0 {
1216 return Ok(None);
1217 }
1218 let max_leverage = risk.max_leverage.unwrap_or(1.0);
1220 let mut max_notional = equity * max_leverage;
1221 if let Some(max_pct) = risk.max_position_pct {
1222 max_notional = max_notional.min(equity * max_pct / 100.0);
1223 }
1224 let capped = (qty * price).min(max_notional) / price;
1225 Ok(Some(capped))
1226}
1227
1228fn intrabar_exit(
1242 candle: &Candle,
1243 risk: &Risk,
1244 entry: f64,
1245 extreme: f64,
1246 is_long: bool,
1247) -> Option<(f64, &'static str)> {
1248 if entry <= 0.0 {
1249 return None;
1250 }
1251 if is_long {
1252 if let Some(p) = risk.stop_loss_pct {
1253 let level = entry * (1.0 - p / 100.0);
1254 if candle.low <= level {
1255 return Some((level.min(candle.open), "stop_loss"));
1256 }
1257 }
1258 if let Some(p) = risk.trailing_stop_pct {
1259 let level = extreme * (1.0 - p / 100.0);
1260 if candle.low <= level {
1261 return Some((level.min(candle.open), "trailing_stop"));
1262 }
1263 }
1264 if let Some(p) = risk.take_profit_pct {
1265 let level = entry * (1.0 + p / 100.0);
1266 if candle.high >= level {
1267 return Some((level.max(candle.open), "take_profit"));
1268 }
1269 }
1270 } else {
1271 if let Some(p) = risk.stop_loss_pct {
1272 let level = entry * (1.0 + p / 100.0);
1273 if candle.high >= level {
1274 return Some((level.max(candle.open), "stop_loss"));
1275 }
1276 }
1277 if let Some(p) = risk.trailing_stop_pct {
1278 let level = extreme * (1.0 + p / 100.0);
1279 if candle.high >= level {
1280 return Some((level.max(candle.open), "trailing_stop"));
1281 }
1282 }
1283 if let Some(p) = risk.take_profit_pct {
1284 let level = entry * (1.0 - p / 100.0);
1285 if candle.low <= level {
1286 return Some((level.min(candle.open), "take_profit"));
1287 }
1288 }
1289 }
1290 None
1291}
1292
1293#[cfg(test)]
1294mod tests {
1295 use super::*;
1296 use crate::data::Level;
1297 use crate::spec::StrategySpec;
1298
1299 fn bar(time: i64, open: f64, high: f64, low: f64, close: f64) -> Candle {
1300 Candle {
1301 time,
1302 open,
1303 high,
1304 low,
1305 close,
1306 volume: 0.0,
1307 }
1308 }
1309
1310 #[test]
1319 fn buy_stop_limit_fills_at_the_stop_when_the_limit_is_above_it() {
1320 let c = bar(0, 99.0, 100.5, 98.5, 100.2);
1324 let fill = level_fill(Side::Long, 100.0, LevelKind::StopLimit { limit: 101.0 }, &c);
1325 assert_eq!(fill, Some(100.0));
1326 }
1327
1328 #[test]
1329 fn buy_stop_limit_does_not_fill_when_the_bar_gaps_past_the_limit() {
1330 let c = bar(0, 105.0, 106.0, 102.0, 105.5);
1333 let fill = level_fill(Side::Long, 100.0, LevelKind::StopLimit { limit: 101.0 }, &c);
1334 assert_eq!(fill, None);
1335 assert_eq!(
1338 level_fill(Side::Long, 100.0, LevelKind::Stop, &c),
1339 Some(105.0)
1340 );
1341 }
1342
1343 #[test]
1344 fn buy_stop_limit_fills_at_the_limit_when_price_comes_back() {
1345 let c = bar(0, 105.0, 106.0, 100.5, 104.0);
1348 let fill = level_fill(Side::Long, 100.0, LevelKind::StopLimit { limit: 101.0 }, &c);
1349 assert_eq!(fill, Some(101.0));
1350 }
1351
1352 #[test]
1353 fn sell_stop_limit_mirrors_the_buy_side() {
1354 let touched = bar(0, 101.0, 101.5, 99.0, 99.5);
1357 assert_eq!(
1358 level_fill(
1359 Side::Short,
1360 100.0,
1361 LevelKind::StopLimit { limit: 99.0 },
1362 &touched
1363 ),
1364 Some(100.0)
1365 );
1366 let gapped = bar(0, 95.0, 98.0, 94.0, 96.0);
1369 assert_eq!(
1370 level_fill(
1371 Side::Short,
1372 100.0,
1373 LevelKind::StopLimit { limit: 99.0 },
1374 &gapped
1375 ),
1376 None
1377 );
1378 assert_eq!(
1379 level_fill(Side::Short, 100.0, LevelKind::Stop, &gapped),
1380 Some(95.0)
1381 );
1382 }
1383
1384 #[test]
1385 fn stop_limit_never_fills_worse_than_its_limit() {
1386 for (o, h, l, c) in [
1389 (99.0, 100.5, 98.5, 100.2),
1390 (105.0, 106.0, 100.5, 104.0),
1391 (100.2, 103.0, 100.1, 102.0),
1392 ] {
1393 let candle = bar(0, o, h, l, c);
1394 if let Some(px) = level_fill(
1395 Side::Long,
1396 100.0,
1397 LevelKind::StopLimit { limit: 101.0 },
1398 &candle,
1399 ) {
1400 assert!(px <= 101.0, "buy filled above its limit: {px}");
1401 }
1402 }
1403 }
1404
1405 fn oscillating(n: i64) -> Vec<Candle> {
1413 (0..n)
1414 .map(|i| {
1415 let px = 100.0 + ((i as f64) * 0.4).sin() * 6.0;
1416 bar(i, px, px + 0.5, px - 0.5, px)
1417 })
1418 .collect()
1419 }
1420
1421 fn spec_with(costs: &str) -> StrategySpec {
1422 StrategySpec::parse(&format!(
1423 r#"{{"symbol":"x","timeframe":"1h",
1424 "indicators":{{"a":{{"type":"Sma","params":[5]}}}},
1425 "entry":{{"cross_above":[{{"price":"close"}},"a"]}},
1426 "exit":{{"cross_below":[{{"price":"close"}},"a"]}},
1427 "sizing":{{"type":"fixed_qty","qty":1}},
1428 "costs":{costs}}}"#
1429 ))
1430 .unwrap()
1431 }
1432
1433 #[test]
1434 fn the_report_says_what_it_is_a_report_of() {
1435 let spec = StrategySpec::parse(
1438 r#"{"symbol":"BTCUSDT","timeframe":"4h","indicators":{},
1439 "entry":{"gt":[{"price":"close"},100]},
1440 "exit":{"lt":[{"price":"close"},100]},
1441 "sizing":{"type":"fixed_qty","qty":1}}"#,
1442 )
1443 .unwrap();
1444 let candles = oscillating(20);
1445
1446 let batch = run(&spec, &candles).unwrap();
1447 assert_eq!(batch.symbol, "BTCUSDT");
1448 assert_eq!(batch.timeframe, "4h");
1449
1450 let mut bt = StreamingBacktest::new(&spec, DEFAULT_CAPITAL).unwrap();
1453 for candle in &candles {
1454 bt.step(candle).unwrap();
1455 }
1456 let streamed = bt.finish();
1457 assert_eq!(streamed.symbol, "BTCUSDT");
1458 assert_eq!(streamed.timeframe, "4h");
1459 }
1460
1461 #[test]
1462 fn a_streaming_bar_without_its_required_feed_is_rejected() {
1463 let spec = spec_with(r#"{"slippage":{"type":"spread"}}"#);
1469 let candles = oscillating(10);
1470
1471 let mut blind = StreamingBacktest::new(&spec, 10_000.0).unwrap();
1472 let err = blind.step(&candles[0]).unwrap_err();
1473 let BacktestError::InvalidSpec(msg) = err else {
1474 panic!("expected InvalidSpec, got {err:?}");
1475 };
1476 assert!(
1477 msg.contains("order-book"),
1478 "message should say what is missing: {msg}"
1479 );
1480
1481 let mut fed = StreamingBacktest::new(&spec, 10_000.0).unwrap();
1484 for candle in &candles {
1485 let book = OrderBook {
1486 bids: vec![Level {
1487 price: candle.close - 0.01,
1488 size: 1.0,
1489 }],
1490 asks: vec![Level {
1491 price: candle.close + 0.01,
1492 size: 1.0,
1493 }],
1494 };
1495 let feeds = Feeds {
1496 orderbook: Some(&book),
1497 ..Feeds::default()
1498 };
1499 fed.step_with_feeds(candle, &feeds).unwrap();
1500 }
1501 assert_eq!(fed.equity().len(), candles.len());
1502 }
1503
1504 #[test]
1505 fn spread_slippage_without_an_order_book_is_rejected() {
1506 let spec = spec_with(r#"{"slippage":{"type":"spread"}}"#);
1507 let candles = oscillating(60);
1508 let err = run(&spec, &candles).unwrap_err();
1509 let BacktestError::InvalidSpec(msg) = err else {
1510 panic!("expected InvalidSpec, got {err:?}");
1511 };
1512 assert!(
1513 msg.contains("order-book"),
1514 "message should say what is missing: {msg}"
1515 );
1516
1517 let books: Vec<OrderBook> = candles
1520 .iter()
1521 .map(|c| OrderBook {
1522 bids: vec![Level {
1523 price: c.close - 0.01,
1524 size: 1.0,
1525 }],
1526 asks: vec![Level {
1527 price: c.close + 0.01,
1528 size: 1.0,
1529 }],
1530 })
1531 .collect();
1532 assert!(run_with_orderbook(&spec, &candles, &books, DEFAULT_CAPITAL).is_ok());
1533 }
1534
1535 #[test]
1536 fn funding_without_a_derivatives_feed_is_rejected() {
1537 let spec = spec_with(r#"{"funding":true}"#);
1538 let candles = oscillating(60);
1539 let err = run(&spec, &candles).unwrap_err();
1540 let BacktestError::InvalidSpec(msg) = err else {
1541 panic!("expected InvalidSpec, got {err:?}");
1542 };
1543 assert!(
1544 msg.contains("derivatives"),
1545 "message should say what is missing: {msg}"
1546 );
1547 }
1548
1549 #[test]
1550 fn a_spec_that_prices_nothing_special_needs_no_extra_feed() {
1551 let spec = spec_with(r#"{"slippage":{"type":"fixed_bps","bps":1.0}}"#);
1554 assert!(run(&spec, &oscillating(60)).is_ok());
1555 }
1556
1557 #[test]
1561 fn generated_indicator_drives_backtest() {
1562 let spec = StrategySpec::parse(
1564 r#"{"symbol":"x","timeframe":"1h",
1565 "indicators":{"a":{"type":"Alma","params":[9,0.85,6.0]}},
1566 "entry":{"cross_above":[{"price":"close"},"a"]},
1567 "exit":{"cross_below":[{"price":"close"},"a"]},
1568 "sizing":{"type":"fixed_qty","qty":1}}"#,
1569 )
1570 .unwrap();
1571 let candles: Vec<Candle> = (0..60)
1572 .map(|i| {
1573 let px = 100.0 + ((i as f64) * 0.4).sin() * 6.0;
1574 bar(i, px, px + 0.5, px - 0.5, px)
1575 })
1576 .collect();
1577 let r = run(&spec, &candles).unwrap();
1578 assert_eq!(r.equity.len(), candles.len());
1580 assert!(r.metrics.num_trades >= 1);
1582 }
1583
1584 #[test]
1586 fn hand_computed_round_trip() {
1587 let spec = StrategySpec::parse(
1588 r#"{"symbol":"x","timeframe":"1h","indicators":{},
1589 "entry":{"gt":[{"price":"close"},100]},
1590 "exit":{"lt":[{"price":"close"},100]},
1591 "sizing":{"type":"fixed_qty","qty":1}}"#,
1592 )
1593 .unwrap();
1594 let candles = [
1595 bar(0, 100.0, 101.0, 100.0, 101.0),
1596 bar(1, 102.0, 103.0, 102.0, 103.0), bar(2, 104.0, 104.0, 99.0, 99.0),
1598 bar(3, 98.0, 98.0, 97.0, 97.0), ];
1600 let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1601 assert_eq!(r.trades.len(), 1);
1602 let t = &r.trades[0];
1603 assert!((t.entry_price - 102.0).abs() < 1e-9);
1604 assert!((t.exit_price - 98.0).abs() < 1e-9);
1605 assert!((t.pnl - (-4.0)).abs() < 1e-9);
1606 assert!((r.equity.last().unwrap().equity - 996.0).abs() < 1e-9);
1607 }
1608
1609 #[test]
1611 fn short_round_trip() {
1612 let spec = StrategySpec::parse(
1613 r#"{"symbol":"x","timeframe":"1h","indicators":{},
1614 "entry":{"lt":[{"price":"close"},0]},
1615 "exit":{"in_position":true},
1616 "short_entry":{"lt":[{"price":"close"},100]},
1617 "short_exit":{"gt":[{"price":"close"},100]},
1618 "sizing":{"type":"fixed_qty","qty":1}}"#,
1619 )
1620 .unwrap();
1621 let candles = [
1622 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), ];
1627 let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1628 assert_eq!(r.trades.len(), 1);
1629 let t = &r.trades[0];
1630 assert!((t.entry_price - 98.0).abs() < 1e-9);
1631 assert!((t.exit_price - 102.0).abs() < 1e-9);
1632 assert!((t.pnl - (-4.0)).abs() < 1e-9);
1634 assert_eq!(t.reason, "signal");
1635 }
1636
1637 #[test]
1639 fn intrabar_stop_loss() {
1640 let spec = StrategySpec::parse(
1641 r#"{"symbol":"x","timeframe":"1h","indicators":{},
1642 "entry":{"gt":[{"price":"close"},0]},
1643 "exit":{"lt":[{"price":"close"},0]},
1644 "sizing":{"type":"fixed_qty","qty":1},
1645 "risk":{"stop_loss_pct":5.0}}"#,
1646 )
1647 .unwrap();
1648 let candles = [
1649 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), ];
1653 let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1654 assert_eq!(r.trades.len(), 1);
1655 let t = &r.trades[0];
1656 assert!((t.exit_price - 95.0).abs() < 1e-9);
1657 assert_eq!(t.reason, "stop_loss");
1658 assert!((t.pnl - (-5.0)).abs() < 1e-9); }
1660
1661 #[test]
1663 fn intrabar_take_profit() {
1664 let spec = StrategySpec::parse(
1665 r#"{"symbol":"x","timeframe":"1h","indicators":{},
1666 "entry":{"gt":[{"price":"close"},0]},
1667 "exit":{"lt":[{"price":"close"},0]},
1668 "sizing":{"type":"fixed_qty","qty":1},
1669 "risk":{"take_profit_pct":10.0}}"#,
1670 )
1671 .unwrap();
1672 let candles = [
1673 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), ];
1677 let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1678 assert_eq!(r.trades.len(), 1);
1679 let t = &r.trades[0];
1680 assert!((t.exit_price - 110.0).abs() < 1e-9);
1681 assert_eq!(t.reason, "take_profit");
1682 assert!((t.pnl - 10.0).abs() < 1e-9);
1683 }
1684
1685 #[test]
1689 fn simultaneous_stop_and_target_prefers_stop() {
1690 let spec = StrategySpec::parse(
1691 r#"{"symbol":"x","timeframe":"1h","indicators":{},
1692 "entry":{"gt":[{"price":"close"},0]},
1693 "exit":{"lt":[{"price":"close"},0]},
1694 "sizing":{"type":"fixed_qty","qty":1},
1695 "risk":{"stop_loss_pct":5.0,"take_profit_pct":10.0}}"#,
1696 )
1697 .unwrap();
1698 let candles = [
1699 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), ];
1703 let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1704 assert_eq!(r.trades.len(), 1);
1705 let t = &r.trades[0];
1706 assert_eq!(t.reason, "stop_loss");
1707 assert!((t.exit_price - 95.0).abs() < 1e-9);
1708 assert!((t.pnl - (-5.0)).abs() < 1e-9);
1709 }
1710
1711 #[test]
1715 fn gap_down_through_stop_fills_at_open() {
1716 let spec = StrategySpec::parse(
1717 r#"{"symbol":"x","timeframe":"1h","indicators":{},
1718 "entry":{"gt":[{"price":"close"},0]},
1719 "exit":{"lt":[{"price":"close"},0]},
1720 "sizing":{"type":"fixed_qty","qty":1},
1721 "risk":{"stop_loss_pct":5.0}}"#,
1722 )
1723 .unwrap();
1724 let candles = [
1725 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), ];
1729 let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1730 assert_eq!(r.trades.len(), 1);
1731 let t = &r.trades[0];
1732 assert_eq!(t.reason, "stop_loss");
1733 assert!((t.exit_price - 90.0).abs() < 1e-9); assert!((t.pnl - (-10.0)).abs() < 1e-9); }
1736
1737 #[test]
1740 fn gap_up_through_short_stop_fills_at_open() {
1741 let spec = StrategySpec::parse(
1742 r#"{"symbol":"x","timeframe":"1h","indicators":{},
1743 "entry":{"lt":[{"price":"close"},0]},"exit":{"in_position":false},
1744 "short_entry":{"gt":[{"price":"close"},0]},
1745 "short_exit":{"lt":[{"price":"close"},0]},
1746 "sizing":{"type":"fixed_qty","qty":1},
1747 "risk":{"stop_loss_pct":5.0}}"#,
1748 )
1749 .unwrap();
1750 let candles = [
1751 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), ];
1755 let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1756 assert_eq!(r.trades.len(), 1);
1757 let t = &r.trades[0];
1758 assert_eq!(t.reason, "stop_loss");
1759 assert!((t.exit_price - 110.0).abs() < 1e-9); }
1761
1762 #[test]
1764 fn trailing_stop() {
1765 let spec = StrategySpec::parse(
1766 r#"{"symbol":"x","timeframe":"1h","indicators":{},
1767 "entry":{"gt":[{"price":"close"},0]},
1768 "exit":{"lt":[{"price":"close"},0]},
1769 "sizing":{"type":"fixed_qty","qty":1},
1770 "risk":{"trailing_stop_pct":10.0}}"#,
1771 )
1772 .unwrap();
1773 let candles = [
1774 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), ];
1779 let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1780 assert_eq!(r.trades.len(), 1);
1781 let t = &r.trades[0];
1782 assert_eq!(t.reason, "trailing_stop");
1783 assert!((t.exit_price - 108.0).abs() < 1e-9);
1784 assert!((t.pnl - 8.0).abs() < 1e-9);
1785 }
1786
1787 #[test]
1788 fn no_signals_no_trades() {
1789 let spec = StrategySpec::parse(
1790 r#"{"symbol":"x","timeframe":"1h","indicators":{},
1791 "entry":{"gt":[{"price":"close"},1000000]},
1792 "exit":{"in_position":true},
1793 "sizing":{"type":"fixed_qty","qty":1}}"#,
1794 )
1795 .unwrap();
1796 let candles = [
1797 bar(0, 10.0, 10.0, 10.0, 10.0),
1798 bar(1, 11.0, 11.0, 11.0, 11.0),
1799 ];
1800 let r = run(&spec, &candles).unwrap();
1801 assert!(r.trades.is_empty());
1802 }
1803
1804 #[test]
1805 fn open_position_closed_at_end() {
1806 let spec = StrategySpec::parse(
1807 r#"{"symbol":"x","timeframe":"1h","indicators":{},
1808 "entry":{"gt":[{"price":"close"},0]},
1809 "exit":{"lt":[{"price":"close"},0]},
1810 "sizing":{"type":"fixed_qty","qty":1}}"#,
1811 )
1812 .unwrap();
1813 let candles = [
1814 bar(0, 10.0, 10.0, 10.0, 10.0),
1815 bar(1, 11.0, 11.0, 11.0, 11.0),
1816 ];
1817 let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1818 assert_eq!(r.trades.len(), 1);
1819 assert_eq!(r.trades[0].reason, "end");
1820 }
1821
1822 #[test]
1823 fn sma_crossover_runs() {
1824 let spec = StrategySpec::parse(
1825 r#"{"symbol":"x","timeframe":"1h",
1826 "indicators":{"fast":{"type":"Sma","params":[2]},"slow":{"type":"Sma","params":[3]}},
1827 "entry":{"cross_above":["fast","slow"]},
1828 "exit":{"cross_below":["fast","slow"]},
1829 "sizing":{"type":"fixed_fraction","fraction":0.5}}"#,
1830 )
1831 .unwrap();
1832 let candles: Vec<Candle> = (0..20)
1833 .map(|i| {
1834 bar(
1835 i,
1836 100.0 + i as f64,
1837 100.0 + i as f64,
1838 100.0,
1839 100.0 + i as f64,
1840 )
1841 })
1842 .collect();
1843 let r = run(&spec, &candles).unwrap();
1844 assert_eq!(r.equity.len(), 20);
1845 assert_eq!(r.schema_version, REPORT_SCHEMA_VERSION);
1846 }
1847
1848 #[test]
1851 fn multi_output_field_ref_runs() {
1852 let spec = StrategySpec::parse(
1853 r#"{"symbol":"x","timeframe":"1h",
1854 "indicators":{"bb":{"type":"Bollinger","params":[5,2]}},
1855 "entry":{"gt":[{"price":"close"},"bb.upper"]},
1856 "exit":{"lt":[{"price":"close"},"bb.lower"]},
1857 "sizing":{"type":"fixed_fraction","fraction":0.5}}"#,
1858 )
1859 .unwrap();
1860 let candles: Vec<Candle> = (0..30)
1861 .map(|i| {
1862 let p = 100.0 + (i as f64 * 0.5).sin() * 5.0;
1863 bar(i, p, p + 1.0, p - 1.0, p)
1864 })
1865 .collect();
1866 let r = run(&spec, &candles).unwrap();
1867 assert_eq!(r.equity.len(), 30);
1868 }
1869
1870 #[test]
1871 fn vol_target_sizes_inversely_to_vol() {
1872 let q = size(
1874 Sizing::VolTarget {
1875 target_vol: 0.01,
1876 lookback: 5,
1877 },
1878 &Risk::default(),
1879 10_000.0,
1880 100.0,
1881 Some(0.02),
1882 )
1883 .unwrap()
1884 .unwrap();
1885 assert!((q - 50.0).abs() < 1e-9);
1886 }
1887
1888 #[test]
1889 fn vol_target_takes_no_position_without_history() {
1890 let none = size(
1891 Sizing::VolTarget {
1892 target_vol: 0.01,
1893 lookback: 5,
1894 },
1895 &Risk::default(),
1896 10_000.0,
1897 100.0,
1898 None,
1899 )
1900 .unwrap();
1901 assert!(none.is_none());
1902 }
1903
1904 #[test]
1905 fn vol_target_trades_after_warmup() {
1906 let spec = StrategySpec::parse(
1907 r#"{"symbol":"x","timeframe":"1h","indicators":{},
1908 "entry":{"gt":[{"price":"close"},0]},
1909 "exit":{"in_position":false},
1910 "sizing":{"type":"vol_target","target_vol":0.02,"lookback":3}}"#,
1911 )
1912 .unwrap();
1913 let closes = [100.0, 101.0, 102.0, 101.0, 103.0, 102.0];
1914 let candles: Vec<Candle> = closes
1915 .iter()
1916 .enumerate()
1917 .map(|(i, &c)| bar(i64::try_from(i).unwrap(), c, c + 0.5, c - 0.5, c))
1918 .collect();
1919 let r = run(&spec, &candles).unwrap();
1920 assert!(!r.trades.is_empty());
1922 assert!(r.trades[0].qty > 0.0);
1923 }
1924
1925 #[test]
1926 fn risk_per_trade_sizes_from_stop() {
1927 let risk = Risk {
1930 stop_loss_pct: Some(2.0),
1931 ..Default::default()
1932 };
1933 let q = size(
1934 Sizing::RiskPerTrade { risk_pct: 1.0 },
1935 &risk,
1936 10_000.0,
1937 100.0,
1938 None,
1939 )
1940 .unwrap()
1941 .unwrap();
1942 assert!((q - 50.0).abs() < 1e-9);
1943 }
1944
1945 #[test]
1946 fn risk_per_trade_requires_stop() {
1947 assert!(size(
1948 Sizing::RiskPerTrade { risk_pct: 1.0 },
1949 &Risk::default(),
1950 10_000.0,
1951 100.0,
1952 None
1953 )
1954 .is_err());
1955 }
1956
1957 #[test]
1958 fn default_leverage_caps_at_equity() {
1959 let q = size(
1961 Sizing::FixedCash { cash: 50_000.0 },
1962 &Risk::default(),
1963 10_000.0,
1964 100.0,
1965 None,
1966 )
1967 .unwrap()
1968 .unwrap();
1969 assert!((q - 100.0).abs() < 1e-9);
1970 }
1971
1972 #[test]
1973 fn max_leverage_allows_more_than_equity() {
1974 let risk = Risk {
1975 max_leverage: Some(3.0),
1976 ..Default::default()
1977 };
1978 let q = size(
1979 Sizing::FixedCash { cash: 50_000.0 },
1980 &risk,
1981 10_000.0,
1982 100.0,
1983 None,
1984 )
1985 .unwrap()
1986 .unwrap();
1987 assert!((q - 300.0).abs() < 1e-9); }
1989
1990 #[test]
1991 fn max_position_pct_caps_notional() {
1992 let risk = Risk {
1993 max_leverage: Some(5.0),
1994 max_position_pct: Some(20.0),
1995 ..Default::default()
1996 };
1997 let q = size(
1999 Sizing::FixedCash { cash: 50_000.0 },
2000 &risk,
2001 10_000.0,
2002 100.0,
2003 None,
2004 )
2005 .unwrap()
2006 .unwrap();
2007 assert!((q - 20.0).abs() < 1e-9);
2008 }
2009
2010 #[test]
2011 fn leverage_flows_through_run() {
2012 let candles = [
2013 bar(0, 100.0, 100.0, 100.0, 100.0),
2014 bar(1, 100.0, 100.0, 100.0, 100.0), bar(2, 100.0, 100.0, 100.0, 100.0),
2016 ];
2017 let no_lev = StrategySpec::parse(
2018 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2019 "entry":{"gt":[{"price":"close"},0]},
2020 "exit":{"in_position":false},
2021 "sizing":{"type":"fixed_cash","cash":50000}}"#,
2022 )
2023 .unwrap();
2024 let r0 = run_with_capital(&no_lev, &candles, 10_000.0).unwrap();
2025 assert!((r0.trades[0].qty - 100.0).abs() < 1e-9); let levered = StrategySpec::parse(
2028 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2029 "entry":{"gt":[{"price":"close"},0]},
2030 "exit":{"in_position":false},
2031 "sizing":{"type":"fixed_cash","cash":50000},
2032 "risk":{"max_leverage":3}}"#,
2033 )
2034 .unwrap();
2035 let r1 = run_with_capital(&levered, &candles, 10_000.0).unwrap();
2036 assert!((r1.trades[0].qty - 300.0).abs() < 1e-9); }
2038
2039 #[test]
2040 fn limit_entry_fills_on_dip() {
2041 let spec = StrategySpec::parse(
2042 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2043 "entry":{"gt":[{"price":"close"},0]},
2044 "exit":{"in_position":false},
2045 "sizing":{"type":"fixed_qty","qty":1},
2046 "execution":{"order_type":"limit","limit_offset_pct":-1.0}}"#,
2047 )
2048 .unwrap();
2049 let candles = [
2050 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), ];
2054 let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2055 assert_eq!(r.trades.len(), 1);
2056 assert!((r.trades[0].entry_price - 99.0).abs() < 1e-9);
2057 }
2058
2059 #[test]
2060 fn limit_entry_never_fills_without_a_dip() {
2061 let spec = StrategySpec::parse(
2062 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2063 "entry":{"gt":[{"price":"close"},0]},
2064 "exit":{"in_position":false},
2065 "sizing":{"type":"fixed_qty","qty":1},
2066 "execution":{"order_type":"limit","limit_offset_pct":-1.0}}"#,
2067 )
2068 .unwrap();
2069 let candles = [
2070 bar(0, 100.0, 100.0, 100.0, 100.0),
2071 bar(1, 100.0, 101.0, 100.0, 100.0),
2072 bar(2, 100.0, 102.0, 100.0, 101.0), ];
2074 let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2075 assert!(r.trades.is_empty());
2076 }
2077
2078 #[test]
2079 fn stop_entry_fills_on_breakout() {
2080 let spec = StrategySpec::parse(
2081 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2082 "entry":{"gt":[{"price":"close"},0]},
2083 "exit":{"in_position":false},
2084 "sizing":{"type":"fixed_qty","qty":1},
2085 "execution":{"order_type":"stop","stop_offset_pct":1.0}}"#,
2086 )
2087 .unwrap();
2088 let candles = [
2089 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), ];
2093 let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2094 assert_eq!(r.trades.len(), 1);
2095 assert!((r.trades[0].entry_price - 101.0).abs() < 1e-9);
2096 }
2097
2098 #[test]
2099 fn limit_order_requires_offset() {
2100 assert!(StrategySpec::parse(
2102 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2103 "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2104 "sizing":{"type":"fixed_qty","qty":1},
2105 "execution":{"order_type":"limit"}}"#,
2106 )
2107 .is_err());
2108 }
2109
2110 #[test]
2111 fn stop_limit_is_unsupported() {
2112 assert!(StrategySpec::parse(
2113 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2114 "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2115 "sizing":{"type":"fixed_qty","qty":1},
2116 "execution":{"order_type":"stop_limit"}}"#,
2117 )
2118 .is_err());
2119 }
2120
2121 #[test]
2122 fn latency_delays_the_fill() {
2123 let spec = StrategySpec::parse(
2124 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2125 "entry":{"gt":[{"price":"close"},0]},
2126 "exit":{"in_position":false},
2127 "sizing":{"type":"fixed_qty","qty":1},
2128 "execution":{"latency_bars":1}}"#,
2129 )
2130 .unwrap();
2131 let candles = [
2132 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), ];
2136 let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2137 assert_eq!(r.trades.len(), 1);
2138 assert!((r.trades[0].entry_price - 120.0).abs() < 1e-9);
2139 }
2140
2141 #[test]
2142 fn partial_fills_cap_entry_to_participation() {
2143 let spec = StrategySpec::parse(
2144 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2145 "entry":{"gt":[{"price":"close"},0]},
2146 "exit":{"in_position":false},
2147 "sizing":{"type":"fixed_qty","qty":100},
2148 "execution":{"partial_fills":true,"max_participation":0.05}}"#,
2149 )
2150 .unwrap();
2151 let vbar = |time, volume| Candle {
2154 time,
2155 open: 100.0,
2156 high: 100.0,
2157 low: 100.0,
2158 close: 100.0,
2159 volume,
2160 };
2161 let candles = [vbar(0, 0.0), vbar(1, 1000.0), vbar(2, 1000.0)];
2162 let r = run_with_capital(&spec, &candles, 1_000_000.0).unwrap();
2163 assert_eq!(r.trades.len(), 1);
2164 assert!((r.trades[0].qty - 50.0).abs() < 1e-9);
2165 }
2166
2167 #[test]
2168 fn partial_fills_requires_participation() {
2169 assert!(StrategySpec::parse(
2170 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2171 "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2172 "sizing":{"type":"fixed_qty","qty":1},
2173 "execution":{"partial_fills":true}}"#,
2174 )
2175 .is_err());
2176 }
2177
2178 #[test]
2179 fn fill_timing_close_fills_same_bar() {
2180 let spec = StrategySpec::parse(
2181 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2182 "entry":{"gt":[{"price":"close"},100]},
2183 "exit":{"lt":[{"price":"close"},100]},
2184 "sizing":{"type":"fixed_qty","qty":1},
2185 "execution":{"fill_timing":"close"}}"#,
2186 )
2187 .unwrap();
2188 let candles = [
2189 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), ];
2193 let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2194 assert_eq!(r.trades.len(), 1);
2195 assert!((r.trades[0].entry_price - 101.0).abs() < 1e-9); assert!((r.trades[0].exit_price - 99.0).abs() < 1e-9);
2197 }
2198
2199 #[test]
2200 fn fill_timing_close_rejects_limit_and_latency() {
2201 assert!(StrategySpec::parse(
2203 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2204 "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2205 "sizing":{"type":"fixed_qty","qty":1},
2206 "execution":{"fill_timing":"close","order_type":"limit","limit_offset_pct":-1.0}}"#,
2207 )
2208 .is_err());
2209 assert!(StrategySpec::parse(
2210 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2211 "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2212 "sizing":{"type":"fixed_qty","qty":1},
2213 "execution":{"fill_timing":"close","latency_bars":1}}"#,
2214 )
2215 .is_err());
2216 }
2217
2218 #[test]
2219 fn history_depth_covers_every_backward_looking_form() {
2220 let depth = |rules: &str| {
2224 let spec = StrategySpec::parse(&format!(
2225 r#"{{"symbol":"x","timeframe":"1h","indicators":{{}},{rules},
2226 "sizing":{{"type":"fixed_qty","qty":1}}}}"#
2227 ))
2228 .unwrap();
2229 history_depth(&spec)
2230 };
2231
2232 assert_eq!(
2234 depth(r#""entry":{"gt":[{"price":"close"},1]},"exit":{"lt":[{"price":"close"},1]}"#),
2235 1
2236 );
2237 assert_eq!(
2239 depth(
2240 r#""entry":{"cross_above":[{"price":"close"},{"price":"open"}]},
2241 "exit":{"lt":[{"price":"close"},1]}"#
2242 ),
2243 2
2244 );
2245 assert_eq!(
2247 depth(
2248 r#""entry":{"rising":[{"price":"close"},9]},"exit":{"lt":[{"price":"close"},1]}"#
2249 ),
2250 10
2251 );
2252 assert_eq!(
2254 depth(
2255 r#""entry":{"gt":[{"prev":[{"prev":[{"price":"close"},2]},3]},1]},
2256 "exit":{"lt":[{"price":"close"},1]}"#
2257 ),
2258 6
2259 );
2260 let vol = StrategySpec::parse(
2262 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2263 "entry":{"gt":[{"price":"close"},1]},
2264 "exit":{"lt":[{"price":"close"},1]},
2265 "sizing":{"type":"vol_target","target_vol":0.02,"lookback":20}}"#,
2266 )
2267 .unwrap();
2268 assert_eq!(history_depth(&vol), 21);
2269 }
2270
2271 #[test]
2272 fn history_stays_bounded_over_a_long_run() {
2273 let spec = StrategySpec::parse(
2276 r#"{"symbol":"x","timeframe":"1h","indicators":{"f":{"type":"Ema","params":[3]}},
2277 "entry":{"cross_above":[{"price":"close"},"f"]},
2278 "exit":{"cross_below":[{"price":"close"},"f"]},
2279 "sizing":{"type":"fixed_qty","qty":1}}"#,
2280 )
2281 .unwrap();
2282 let mut bt = StreamingBacktest::new(&spec, 10_000.0).unwrap();
2283 for i in 0..20_000i64 {
2284 let px = 100.0 + ((i as f64) * 0.05).sin() * 5.0;
2285 bt.step(&bar(i, px, px + 0.5, px - 0.5, px)).unwrap();
2286 }
2287 assert_eq!(bt.bars_seen, 20_000);
2288 assert_eq!(bt.history_depth, 2);
2289 assert_eq!(bt.history.len(), 2);
2290 assert!(bt.num_trades() > 0);
2292 }
2293
2294 #[test]
2295 fn a_deep_lookback_still_sees_far_enough() {
2296 let spec = StrategySpec::parse(
2299 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2300 "entry":{"rising":[{"price":"close"},40]},
2301 "exit":{"falling":[{"price":"close"},40]},
2302 "sizing":{"type":"fixed_qty","qty":1}}"#,
2303 )
2304 .unwrap();
2305 assert_eq!(history_depth(&spec), 41);
2306
2307 let candles: Vec<Candle> = (0..400i64)
2308 .map(|i| {
2309 let px = 100.0 + ((i as f64) * 0.03).sin() * 10.0;
2310 bar(i, px, px + 0.5, px - 0.5, px)
2311 })
2312 .collect();
2313 let batch = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2314 assert!(batch.metrics.num_trades >= 1, "the fixture must trade");
2315
2316 let mut bt = StreamingBacktest::new(&spec, 10_000.0).unwrap();
2317 for candle in &candles {
2318 bt.step(candle).unwrap();
2319 }
2320 assert_eq!(bt.history.len(), 41);
2321 let streamed = bt.finish();
2322 assert_eq!(streamed.metrics.num_trades, batch.metrics.num_trades);
2323 assert!((streamed.metrics.pnl - batch.metrics.pnl).abs() < 1e-9);
2324 }
2325
2326 #[test]
2327 fn streaming_matches_batch() {
2328 let spec = StrategySpec::parse(
2331 r#"{"symbol":"x","timeframe":"1h",
2332 "indicators":{"f":{"type":"Ema","params":[3]}},
2333 "entry":{"cross_above":[{"price":"close"},"f"]},
2334 "exit":{"cross_below":[{"price":"close"},"f"]},
2335 "sizing":{"type":"fixed_qty","qty":1}}"#,
2336 )
2337 .unwrap();
2338 let candles: Vec<Candle> = (0..30i64)
2339 .map(|i| {
2340 let px = 100.0 + ((i as f64) * 0.5).sin() * 5.0;
2341 bar(i, px, px + 0.5, px - 0.5, px)
2342 })
2343 .collect();
2344
2345 let batch = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2346
2347 let mut bt = StreamingBacktest::new(&spec, 10_000.0).unwrap();
2348 for c in &candles {
2349 bt.step(c).unwrap();
2350 }
2351 let streamed = bt.finish();
2352
2353 assert!(batch.metrics.num_trades >= 1);
2354 assert_eq!(batch.metrics.num_trades, streamed.metrics.num_trades);
2355 assert_eq!(batch.trades.len(), streamed.trades.len());
2356 assert_eq!(batch.equity.len(), streamed.equity.len());
2357 assert!(
2358 (batch.equity.last().unwrap().equity - streamed.equity.last().unwrap().equity).abs()
2359 < 1e-12
2360 );
2361 }
2362
2363 #[test]
2364 fn run_stream_matches_batch_and_tails_equity() {
2365 let spec = StrategySpec::parse(
2369 r#"{"symbol":"x","timeframe":"1h",
2370 "indicators":{"f":{"type":"Ema","params":[3]}},
2371 "entry":{"cross_above":[{"price":"close"},"f"]},
2372 "exit":{"cross_below":[{"price":"close"},"f"]},
2373 "sizing":{"type":"fixed_qty","qty":1}}"#,
2374 )
2375 .unwrap();
2376 let candles: Vec<Candle> = (0..30i64)
2377 .map(|i| {
2378 let px = 100.0 + ((i as f64) * 0.5).sin() * 5.0;
2379 bar(i, px, px + 0.5, px - 0.5, px)
2380 })
2381 .collect();
2382
2383 let batch = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2384
2385 let mut tail: Vec<EquityPoint> = Vec::new();
2386 let streamed = run_stream(&spec, &candles, 10_000.0, |i, bt| {
2387 assert_eq!(bt.equity().len(), i + 1);
2389 tail.push(bt.latest_equity().expect("a bar was marked"));
2390 })
2391 .unwrap();
2392
2393 assert_eq!(tail.len(), candles.len());
2394 assert_eq!(streamed.equity.len(), batch.equity.len());
2395 for (got, want) in tail.iter().zip(&streamed.equity) {
2397 assert_eq!(got.time, want.time);
2398 assert!((got.equity - want.equity).abs() < 1e-12);
2399 }
2400 assert_eq!(streamed.metrics.num_trades, batch.metrics.num_trades);
2401 }
2402
2403 #[test]
2404 fn pairwise_indicator_uses_reference_series() {
2405 let spec = StrategySpec::parse(
2408 r#"{"symbol":"x","timeframe":"1h",
2409 "indicators":{"c":{"type":"PearsonCorrelation","params":[3]}},
2410 "entry":{"gt":["c",0.5]},
2411 "exit":{"lt":["c",-2.0]},
2412 "sizing":{"type":"fixed_qty","qty":1}}"#,
2413 )
2414 .unwrap();
2415 let primary: Vec<Candle> = [100.0, 101.0, 102.0, 101.0, 103.0, 102.0, 104.0, 103.0]
2416 .iter()
2417 .zip(0i64..)
2418 .map(|(&c, i)| bar(i, c, c + 0.5, c - 0.5, c))
2419 .collect();
2420 let reference: Vec<Candle> = [50.0, 50.5, 51.0, 50.5, 51.5, 51.0, 52.0, 51.5]
2422 .iter()
2423 .zip(0i64..)
2424 .map(|(&c, i)| bar(i, c, c + 0.2, c - 0.2, c))
2425 .collect();
2426
2427 let with_ref = run_with_ref(&spec, &primary, &reference, 10_000.0).unwrap();
2428 assert!(with_ref.metrics.num_trades >= 1);
2429
2430 let without_ref = run_with_capital(&spec, &primary, 10_000.0).unwrap();
2433 assert_eq!(without_ref.metrics.num_trades, 0);
2434 }
2435
2436 #[test]
2437 fn pairwise_multi_output_exposes_fields() {
2438 let mut ind = registry::build("RelativeStrengthAB", &[3.0, 3.0]).unwrap();
2441 let mut names: Vec<&str> = Vec::new();
2442 let prices = [
2443 100.0, 102.0, 104.0, 103.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0,
2444 ];
2445 for (i, &px) in prices.iter().enumerate() {
2446 let c = Candle {
2447 time: i64::try_from(i).unwrap(),
2448 open: px,
2449 high: px,
2450 low: px,
2451 close: px,
2452 volume: 0.0,
2453 };
2454 let input = BarInput {
2455 candle: &c,
2456 reference: Some(px * 0.9),
2457 deriv: None,
2458 orderbook: None,
2459 trades: &[],
2460 cross_section: None,
2461 };
2462 if ind.update(&input).is_some() {
2463 names = ind.fields().iter().map(|(n, _)| *n).collect();
2464 }
2465 }
2466 assert!(names.contains(&"ratio"), "fields: {names:?}");
2467 }
2468
2469 #[test]
2470 fn run_with_ref_rejects_length_mismatch() {
2471 let spec = StrategySpec::parse(
2472 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2473 "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2474 "sizing":{"type":"fixed_qty","qty":1}}"#,
2475 )
2476 .unwrap();
2477 let a = [bar(0, 1.0, 1.0, 1.0, 1.0), bar(1, 1.0, 1.0, 1.0, 1.0)];
2478 let b = [bar(0, 1.0, 1.0, 1.0, 1.0)];
2479 assert!(run_with_ref(&spec, &a, &b, 10_000.0).is_err());
2480 }
2481
2482 fn sample_tick(funding_rate: f64) -> DerivativesTick {
2483 DerivativesTick {
2484 funding_rate,
2485 mark_price: 100.0,
2486 index_price: 100.0,
2487 futures_price: 100.0,
2488 open_interest: 1000.0,
2489 long_size: 600.0,
2490 short_size: 400.0,
2491 taker_buy_volume: 50.0,
2492 taker_sell_volume: 40.0,
2493 long_liquidation: 0.0,
2494 short_liquidation: 0.0,
2495 timestamp: 0,
2496 }
2497 }
2498
2499 #[test]
2500 fn derivatives_indicator_uses_feed() {
2501 let spec = StrategySpec::parse(
2503 r#"{"symbol":"x","timeframe":"1h",
2504 "indicators":{"f":{"type":"FundingRate","params":[]}},
2505 "entry":{"gt":["f",0.0]},
2506 "exit":{"lt":["f",-1.0]},
2507 "sizing":{"type":"fixed_qty","qty":1}}"#,
2508 )
2509 .unwrap();
2510 let candles: Vec<Candle> = (0i64..5)
2511 .map(|i| bar(i, 100.0, 100.0, 100.0, 100.0))
2512 .collect();
2513 let derivs = vec![sample_tick(0.01); 5];
2514
2515 let with_feed = run_with_deriv(&spec, &candles, &derivs, 10_000.0).unwrap();
2516 assert!(with_feed.metrics.num_trades >= 1);
2517
2518 let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2520 assert_eq!(without_feed.metrics.num_trades, 0);
2521 }
2522
2523 #[test]
2524 fn order_book_indicator_uses_feed() {
2525 use crate::data::Level;
2526 let spec = StrategySpec::parse(
2527 r#"{"symbol":"x","timeframe":"1h",
2528 "indicators":{"i":{"type":"OrderBookImbalanceTop1","params":[]}},
2529 "entry":{"gt":["i",0.0]},
2530 "exit":{"lt":["i",-2.0]},
2531 "sizing":{"type":"fixed_qty","qty":1}}"#,
2532 )
2533 .unwrap();
2534 let candles: Vec<Candle> = (0i64..5)
2535 .map(|t| bar(t, 100.0, 100.0, 100.0, 100.0))
2536 .collect();
2537 let book = OrderBook {
2539 bids: vec![Level {
2540 price: 100.0,
2541 size: 9.0,
2542 }],
2543 asks: vec![Level {
2544 price: 101.0,
2545 size: 1.0,
2546 }],
2547 };
2548 let books = vec![book; 5];
2549
2550 let with_feed = run_with_orderbook(&spec, &candles, &books, 10_000.0).unwrap();
2551 assert!(with_feed.metrics.num_trades >= 1);
2552
2553 let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2554 assert_eq!(without_feed.metrics.num_trades, 0);
2555 }
2556
2557 #[test]
2558 fn trade_indicator_replays_bar_trades() {
2559 use crate::data::{TradePrint, TradeSide};
2560 let spec = StrategySpec::parse(
2561 r#"{"symbol":"x","timeframe":"1h",
2562 "indicators":{"cvd":{"type":"CumulativeVolumeDelta","params":[]}},
2563 "entry":{"gt":["cvd",0.0]},
2564 "exit":{"lt":["cvd",-1.0]},
2565 "sizing":{"type":"fixed_qty","qty":1}}"#,
2566 )
2567 .unwrap();
2568 let candles: Vec<Candle> = (0i64..5)
2569 .map(|t| bar(t, 100.0, 100.0, 100.0, 100.0))
2570 .collect();
2571 let buy = TradePrint {
2572 price: 100.0,
2573 size: 5.0,
2574 side: TradeSide::Buy,
2575 timestamp: 0,
2576 };
2577 let trades: Vec<Vec<TradePrint>> = (0..5).map(|_| vec![buy, buy]).collect();
2579
2580 let with_feed = run_with_trades(&spec, &candles, &trades, 10_000.0).unwrap();
2581 assert!(with_feed.metrics.num_trades >= 1);
2582
2583 let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2584 assert_eq!(without_feed.metrics.num_trades, 0);
2585 }
2586
2587 #[test]
2588 fn funding_charges_an_open_long() {
2589 let with_funding = StrategySpec::parse(
2590 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2591 "entry":{"gt":[{"price":"close"},0]},
2592 "exit":{"in_position":false},
2593 "sizing":{"type":"fixed_qty","qty":1},
2594 "costs":{"funding":true}}"#,
2595 )
2596 .unwrap();
2597 let candles = [
2598 bar(0, 100.0, 100.0, 100.0, 100.0),
2599 bar(1, 100.0, 100.0, 100.0, 100.0),
2600 bar(2, 100.0, 100.0, 100.0, 100.0),
2601 ];
2602 let derivs = vec![sample_tick(0.01); 3]; let funded = run_with_deriv(&with_funding, &candles, &derivs, 10_000.0).unwrap();
2605 assert!(funded.fees_paid > 0.0); let no_funding = StrategySpec::parse(
2608 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2609 "entry":{"gt":[{"price":"close"},0]},
2610 "exit":{"in_position":false},
2611 "sizing":{"type":"fixed_qty","qty":1}}"#,
2612 )
2613 .unwrap();
2614 let unfunded = run_with_deriv(&no_funding, &candles, &derivs, 10_000.0).unwrap();
2615 assert!(funded.equity.last().unwrap().equity < unfunded.equity.last().unwrap().equity);
2616 }
2617
2618 #[test]
2619 fn leverage_liquidation_closes_at_bankruptcy() {
2620 let spec = StrategySpec::parse(
2623 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2624 "entry":{"gt":[{"price":"close"},0]},
2625 "exit":{"in_position":false},
2626 "sizing":{"type":"fixed_cash","cash":5000},
2627 "risk":{"max_leverage":5,"liquidation":true}}"#,
2628 )
2629 .unwrap();
2630 let candles = [
2631 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), ];
2635 let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
2636 assert_eq!(r.trades.len(), 1);
2637 assert_eq!(r.trades[0].reason, "liquidation");
2638 assert!((r.trades[0].exit_price - 80.0).abs() < 1e-9);
2639 assert!(r.equity.last().unwrap().equity.abs() < 1e-6); }
2641
2642 #[test]
2643 fn limit_entry_pays_maker_fee() {
2644 let candles = [
2645 bar(0, 100.0, 100.0, 100.0, 100.0),
2646 bar(1, 100.0, 100.0, 100.0, 100.0),
2647 bar(2, 100.0, 100.0, 100.0, 100.0),
2648 ];
2649 let market = StrategySpec::parse(
2651 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2652 "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":false},
2653 "sizing":{"type":"fixed_qty","qty":1},
2654 "costs":{"maker_bps":0,"taker_bps":200}}"#,
2655 )
2656 .unwrap();
2657 let limit = StrategySpec::parse(
2658 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2659 "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":false},
2660 "sizing":{"type":"fixed_qty","qty":1},
2661 "costs":{"maker_bps":0,"taker_bps":200},
2662 "execution":{"order_type":"limit","limit_offset_pct":0.0}}"#,
2663 )
2664 .unwrap();
2665 let market_fees = run_with_capital(&market, &candles, 10_000.0)
2666 .unwrap()
2667 .fees_paid;
2668 let limit_fees = run_with_capital(&limit, &candles, 10_000.0)
2669 .unwrap()
2670 .fees_paid;
2671 assert!(limit_fees < market_fees); }
2673
2674 #[test]
2675 fn volume_impact_slippage_worsens_the_fill() {
2676 let spec = StrategySpec::parse(
2677 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2678 "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":false},
2679 "sizing":{"type":"fixed_qty","qty":10},
2680 "costs":{"slippage":{"type":"volume_impact","coef":0.5}}}"#,
2681 )
2682 .unwrap();
2683 let vbar = |t, vol| Candle {
2684 time: t,
2685 open: 100.0,
2686 high: 100.0,
2687 low: 100.0,
2688 close: 100.0,
2689 volume: vol,
2690 };
2691 let candles = [vbar(0, 1000.0), vbar(1, 1000.0), vbar(2, 1000.0)];
2692 let r = run_with_capital(&spec, &candles, 1_000_000.0).unwrap();
2693 assert!((r.trades[0].entry_price - 100.5).abs() < 1e-9);
2695 }
2696
2697 #[test]
2698 fn spread_slippage_uses_the_order_book() {
2699 use crate::data::Level;
2700 let spec = StrategySpec::parse(
2701 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2702 "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":false},
2703 "sizing":{"type":"fixed_qty","qty":1},
2704 "costs":{"slippage":{"type":"spread"}}}"#,
2705 )
2706 .unwrap();
2707 let candles = [
2708 bar(0, 100.0, 100.0, 100.0, 100.0),
2709 bar(1, 100.0, 100.0, 100.0, 100.0),
2710 bar(2, 100.0, 100.0, 100.0, 100.0),
2711 ];
2712 let book = OrderBook {
2713 bids: vec![Level {
2714 price: 99.0,
2715 size: 1.0,
2716 }],
2717 asks: vec![Level {
2718 price: 101.0,
2719 size: 1.0,
2720 }],
2721 };
2722 let books = vec![book; 3];
2723 let r = run_with_orderbook(&spec, &candles, &books, 10_000.0).unwrap();
2724 assert!((r.trades[0].entry_price - 101.0).abs() < 1e-9);
2726 }
2727
2728 #[test]
2729 fn trade_quote_indicator_uses_trades_and_mid() {
2730 use crate::data::{TradePrint, TradeSide};
2731 let spec = StrategySpec::parse(
2732 r#"{"symbol":"x","timeframe":"1h",
2733 "indicators":{"es":{"type":"EffectiveSpread","params":[]}},
2734 "entry":{"gt":["es",0.0]},
2735 "exit":{"lt":["es",-1.0]},
2736 "sizing":{"type":"fixed_qty","qty":1}}"#,
2737 )
2738 .unwrap();
2739 let candles: Vec<Candle> = (0i64..5)
2740 .map(|t| bar(t, 100.0, 100.0, 100.0, 100.0))
2741 .collect();
2742 let trade = TradePrint {
2744 price: 102.0,
2745 size: 1.0,
2746 side: TradeSide::Buy,
2747 timestamp: 0,
2748 };
2749 let trades: Vec<Vec<TradePrint>> = (0..5).map(|_| vec![trade]).collect();
2750
2751 let with_feed = run_with_trades(&spec, &candles, &trades, 10_000.0).unwrap();
2752 assert!(with_feed.metrics.num_trades >= 1);
2753
2754 let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2755 assert_eq!(without_feed.metrics.num_trades, 0);
2756 }
2757
2758 #[test]
2759 fn cross_section_breadth_indicator_uses_feed() {
2760 use crate::data::{CrossSection, CrossSectionMember};
2761 let spec = StrategySpec::parse(
2762 r#"{"symbol":"x","timeframe":"1h",
2763 "indicators":{"ad":{"type":"AdvanceDecline","params":[]}},
2764 "entry":{"gt":["ad",0.0]},
2765 "exit":{"lt":["ad",-100.0]},
2766 "sizing":{"type":"fixed_qty","qty":1}}"#,
2767 )
2768 .unwrap();
2769 let candles: Vec<Candle> = (0i64..4)
2770 .map(|t| bar(t, 100.0, 100.0, 100.0, 100.0))
2771 .collect();
2772 let advancer = CrossSectionMember {
2773 change: 1.0,
2774 volume: 100.0,
2775 new_high: false,
2776 new_low: false,
2777 };
2778 let decliner = CrossSectionMember {
2779 change: -1.0,
2780 volume: 100.0,
2781 new_high: false,
2782 new_low: false,
2783 };
2784 let section = CrossSection {
2786 members: vec![advancer, advancer, advancer, decliner],
2787 timestamp: 0,
2788 };
2789 let sections = vec![section; 4];
2790
2791 let with_feed = run_with_cross_section(&spec, &candles, §ions, 10_000.0).unwrap();
2792 assert!(with_feed.metrics.num_trades >= 1);
2793
2794 let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2795 assert_eq!(without_feed.metrics.num_trades, 0);
2796 }
2797
2798 #[test]
2799 fn run_with_deriv_rejects_length_mismatch() {
2800 let spec = StrategySpec::parse(
2801 r#"{"symbol":"x","timeframe":"1h","indicators":{},
2802 "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2803 "sizing":{"type":"fixed_qty","qty":1}}"#,
2804 )
2805 .unwrap();
2806 let candles = [bar(0, 1.0, 1.0, 1.0, 1.0), bar(1, 1.0, 1.0, 1.0, 1.0)];
2807 let derivs = [sample_tick(0.0)];
2808 assert!(run_with_deriv(&spec, &candles, &derivs, 10_000.0).is_err());
2809 }
2810
2811 #[test]
2812 fn new_owned_matches_the_borrowing_constructor() {
2813 let json = r#"{"symbol":"x","timeframe":"1h","indicators":{},
2814 "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2815 "sizing":{"type":"fixed_qty","qty":1}}"#;
2816 let spec = StrategySpec::parse(json).unwrap();
2817 let candles = [
2818 bar(0, 1.0, 1.0, 1.0, 1.0),
2819 bar(1, 1.0, 2.0, 1.0, 2.0),
2820 bar(2, 2.0, 3.0, 2.0, 3.0),
2821 ];
2822
2823 let mut borrowed = StreamingBacktest::new(&spec, 10_000.0).unwrap();
2825 for candle in &candles {
2826 borrowed.step(candle).unwrap();
2827 }
2828 let borrowed_report = borrowed.finish();
2829
2830 let mut owned = StreamingBacktest::new_owned(spec, 10_000.0).unwrap();
2832 for candle in &candles {
2833 owned.step(candle).unwrap();
2834 }
2835 let owned_report = owned.finish();
2836
2837 assert_eq!(
2839 serde_json::to_string(&owned_report).unwrap(),
2840 serde_json::to_string(&borrowed_report).unwrap()
2841 );
2842 }
2843}