wickra_backtest/lib.rs
1//! # wickra-backtest
2//!
3//! Streaming-native, event-driven backtester for the
4//! [Wickra](https://github.com/wickra-lib/wickra) technical-indicator library.
5//!
6//! This facade re-exports the engine ([`wickra_backtest_core`]) and the data
7//! loaders ([`wickra_backtest_data`]) behind one crate, plus the historical
8//! backtest runner and reports.
9//!
10//! The same engine, fed live instead of historical bars, becomes the live bot —
11//! so **backtest == live, byte-identical**, and (because the strategy is a JSON
12//! spec, not code) identical across every Wickra language binding.
13//!
14//! ```
15//! use wickra_backtest::{run_with_capital, Candle, StreamingBacktest, StrategySpec};
16//!
17//! // A strategy is data. This one buys above 100 and sells below it.
18//! let spec = StrategySpec::parse(
19//! r#"{"symbol":"BTCUSDT","timeframe":"1h","indicators":{},
20//! "entry":{"gt":[{"price":"close"},100]},
21//! "exit":{"lt":[{"price":"close"},100]},
22//! "sizing":{"type":"fixed_qty","qty":1}}"#,
23//! )?;
24//!
25//! let candles: Vec<Candle> = [(100.0, 101.0), (102.0, 103.0), (104.0, 99.0), (98.0, 97.0)]
26//! .iter()
27//! .enumerate()
28//! .map(|(i, &(open, close))| Candle {
29//! time: i as i64,
30//! open,
31//! high: open.max(close),
32//! low: open.min(close),
33//! close,
34//! volume: 0.0,
35//! })
36//! .collect();
37//!
38//! // The whole series at once.
39//! let batch = run_with_capital(&spec, &candles, 1_000.0)?;
40//!
41//! // The same spec, one bar at a time. Replace the loop with reads from a
42//! // socket and this is a live strategy; nothing else about it changes.
43//! let mut live = StreamingBacktest::new(&spec, 1_000.0)?;
44//! for candle in &candles {
45//! live.step(candle)?;
46//! }
47//! let streamed = live.finish();
48//!
49//! // That equality is the point of the crate, not a coincidence.
50//! assert_eq!(streamed.metrics.num_trades, batch.metrics.num_trades);
51//! assert_eq!(streamed.metrics.pnl, batch.metrics.pnl);
52//! # Ok::<(), wickra_backtest::BacktestError>(())
53//! ```
54
55// docs.rs builds on nightly with --cfg docsrs, which makes rustdoc annotate
56// feature-gated items with the feature that provides them. No job in this
57// repository runs nightly, so this line is the one thing here CI cannot check
58// -- the sibling repository lost a release to exactly that blind spot.
59#![cfg_attr(docsrs, feature(doc_cfg))]
60#![forbid(unsafe_code)]
61
62pub use wickra_backtest_core as core;
63pub use wickra_backtest_data as data;
64
65// A glob, not a name list. A hand-kept list is a list that goes stale: this one
66// had drifted to the point where `BacktestReport` was exported and `Metrics` --
67// the type of its own `.metrics` field -- was not, and `StreamingBacktest` was
68// exported while `Feeds`, which `step_with_feeds` takes, was not. Callers could
69// hold those values but not name their types. The explicit `core` alias above
70// still wins over this glob for the `data` module, which is Rust's precedence
71// rule for glob imports.
72pub use wickra_backtest_core::*;
73
74/// The crate version, surfaced for diagnostics.
75#[must_use]
76pub fn version() -> &'static str {
77 env!("CARGO_PKG_VERSION")
78}
79
80#[cfg(test)]
81mod tests {
82 //! The facade's job is to make the core's surface reachable under one name.
83 //! These name the types that were missing from the old hand-kept list; if the
84 //! glob is ever narrowed back to a list, this stops compiling.
85
86 use super::*;
87
88 #[test]
89 fn version_is_the_crate_version() {
90 assert_eq!(version(), env!("CARGO_PKG_VERSION"));
91 }
92
93 #[test]
94 fn a_report_and_the_type_of_its_metrics_field_are_both_reachable() {
95 fn takes(_: &BacktestReport, _: &Metrics) {}
96 let _ = takes;
97 let _: u32 = REPORT_SCHEMA_VERSION;
98 }
99
100 #[test]
101 fn the_streaming_handle_and_the_feeds_it_steps_with_are_both_reachable() {
102 fn steps(handle: &mut StreamingBacktest<'_>, candle: &Candle, feeds: &Feeds) -> Result<()> {
103 handle.step_with_feeds(candle, feeds)
104 }
105 let _ = steps;
106 }
107
108 #[test]
109 fn the_json_entry_point_and_its_request_type_are_reachable() {
110 fn takes(_: RunRequest) -> fn(&str) -> Result<String> {
111 run_json
112 }
113 let _ = takes;
114 }
115
116 #[test]
117 fn a_spec_can_be_built_from_typed_parts_not_only_parsed_from_json() {
118 // Every type named here comes from `spec`; none was re-exported before, so
119 // a caller could only reach a StrategySpec by parsing JSON.
120 let _: Option<Condition> = None;
121 let _: Option<Costs> = None;
122 let _: Option<Execution> = None;
123 let _: Option<Feed> = None;
124 let _: Option<FillTiming> = None;
125 let _: Option<IndicatorSpec> = None;
126 let _: Option<IntPredicate> = None;
127 let _: Option<Operand> = None;
128 let _: Option<OperandExpr> = None;
129 let _: Option<OrderType> = None;
130 let _: Option<PriceField> = None;
131 let _: Option<Risk> = None;
132 let _: Option<Sizing> = None;
133 let _: Option<Slippage> = None;
134 let _: u32 = SPEC_VERSION;
135 }
136
137 #[test]
138 fn the_data_types_the_engine_consumes_are_reachable() {
139 let _: Option<CrossSection> = None;
140 let _: Option<CrossSectionMember> = None;
141 let _: Option<Level> = None;
142 let _: Option<TradeSide> = None;
143 let _: Option<DerivativesTick> = None;
144 let _: Option<OrderBook> = None;
145 let _: Option<TradePrint> = None;
146 let _: Option<Trade> = None;
147 let _: f64 = DEFAULT_CAPITAL;
148 }
149}