finance_query/backtesting/portfolio/engine/
mod.rs1use std::collections::{BTreeSet, HashMap, HashSet};
4
5use crate::backtesting::engine::{BacktestEngine, update_position_extremes, update_trailing_hwm};
6use crate::backtesting::error::{BacktestError, Result};
7use crate::backtesting::result::EquityPoint;
8use crate::backtesting::signal::Signal;
9use crate::backtesting::strategy::Strategy;
10use crate::models::chart::{Candle, Dividend};
11
12use super::config::PortfolioConfig;
13use super::result::{AllocationSnapshot, PortfolioResult};
14
15#[non_exhaustive]
19#[derive(Debug, Clone)]
20pub struct SymbolData {
21 pub symbol: String,
23
24 pub candles: Vec<Candle>,
26
27 pub dividends: Vec<Dividend>,
31}
32
33impl SymbolData {
34 pub fn new(symbol: impl Into<String>, candles: Vec<Candle>) -> Self {
36 Self {
37 symbol: symbol.into(),
38 candles,
39 dividends: vec![],
40 }
41 }
42
43 pub fn with_dividends(mut self, dividends: Vec<Dividend>) -> Self {
45 self.dividends = dividends;
46 self
47 }
48}
49
50pub struct PortfolioEngine {
55 config: PortfolioConfig,
56}
57
58impl PortfolioEngine {
59 pub fn new(config: PortfolioConfig) -> Self {
61 Self { config }
62 }
63
64 pub fn run<S, F>(&self, symbol_data: &[SymbolData], factory: F) -> Result<PortfolioResult>
77 where
78 S: Strategy,
79 F: Fn(&str) -> S,
80 {
81 let n_symbols = symbol_data.len();
82 self.config.validate(n_symbols)?;
83
84 let initial_capital = self.config.base.initial_capital;
85
86 let helper_engine = BacktestEngine::new(self.config.base.clone());
88
89 let mut states: HashMap<String, SymbolState<S>> = HashMap::with_capacity(n_symbols);
90 for data in symbol_data {
91 let strategy = factory(&data.symbol);
92 let warmup = strategy
93 .warmup_period()
94 .max(self.config.base.sizing_warmup());
95 let track_extremes = strategy.tracks_position_extremes();
96 if data.candles.len() < warmup {
97 return Err(BacktestError::insufficient_data(warmup, data.candles.len()));
98 }
99 let strategy_name = strategy.name().to_string();
100 let indicators = helper_engine.compute_indicators(&data.candles, &strategy)?;
101 let sizing_series = helper_engine.compute_sizing_series(&data.candles);
102 let ts_index: HashMap<i64, usize> = data
103 .candles
104 .iter()
105 .enumerate()
106 .map(|(i, c)| (c.timestamp, i))
107 .collect();
108
109 let sym_initial_capital = self.config.allocation_target(
113 &data.symbol,
114 initial_capital,
115 initial_capital,
116 n_symbols,
117 self.config.base.position_size_pct * self.config.base.max_leverage,
118 );
119
120 states.insert(
121 data.symbol.clone(),
122 SymbolState {
123 candles: data.candles.clone(),
124 dividends: data.dividends.clone(),
125 ts_index,
126 indicators,
127 sizing_series,
128 strategy,
129 warmup,
130 position: None,
131 hwm: None,
132 extremes: None,
133 track_extremes,
134 div_idx: 0,
135 trades: vec![],
136 signals: vec![],
137 realized_pnl: 0.0,
138 equity_curve: vec![],
139 sym_peak: sym_initial_capital,
140 sym_max_leverage: 0.0,
141 sym_initial_capital,
142 strategy_name,
143 },
144 );
145 }
146
147 let master_timeline: BTreeSet<i64> = states
149 .values()
150 .flat_map(|s| s.candles.iter().map(|c| c.timestamp))
151 .collect();
152
153 let mut cash = initial_capital;
155 let mut portfolio_equity_curve: Vec<EquityPoint> = Vec::new();
156 let mut allocation_history: Vec<AllocationSnapshot> = Vec::new();
157 let mut portfolio_peak = initial_capital;
158 let financing_enabled =
159 self.config.base.short_borrow_rate > 0.0 || self.config.base.margin_interest_rate > 0.0;
160 let margin_enabled = self.config.base.max_leverage > 1.0 || self.config.base.allow_short;
161 let per_bar = 1.0 / self.config.base.bars_per_year;
162
163 for ×tamp in &master_timeline {
165 let mut active_symbols: Vec<String> = states
168 .keys()
169 .filter(|sym| states[*sym].ts_index.contains_key(×tamp))
170 .cloned()
171 .collect();
172 active_symbols.sort();
175
176 if financing_enabled && self.config.base.margin_interest_rate > 0.0 {
180 let interest = (-cash).max(0.0) * self.config.base.margin_interest_rate * per_bar;
181 if interest > 0.0 {
182 let grosses: Vec<(String, f64)> = states
183 .iter()
184 .filter_map(|(sym, s)| {
185 s.position.as_ref().and_then(|pos| {
186 close_at_or_before(s, timestamp)
187 .map(|close| (sym.clone(), pos.quantity * close))
188 })
189 })
190 .collect();
191 let gross_total: f64 = grosses.iter().map(|(_, g)| g).sum();
192 if gross_total > 0.0 {
193 cash -= interest;
194 for (sym, gross) in grosses {
195 if let Some(pos) = states.get_mut(&sym).unwrap().position.as_mut() {
196 pos.accrue_financing_cost(interest * gross / gross_total);
197 }
198 }
199 }
200 }
201 }
202
203 let mut auto_exits: Vec<(String, Signal)> = Vec::new();
205
206 for sym in &active_symbols {
207 let state = states.get_mut(sym).unwrap();
208 let candle_idx = state.ts_index[×tamp];
209 let close = state.candles[candle_idx].close;
210
211 if financing_enabled
212 && let Some(pos) = state.position.as_mut()
213 && pos.is_short()
214 {
215 let fee = pos.quantity * close * self.config.base.short_borrow_rate * per_bar;
216 if fee > 0.0 {
217 cash -= fee;
218 pos.accrue_financing_cost(fee);
219 }
220 }
221
222 let candle = &state.candles[candle_idx];
223
224 if state.track_extremes {
225 update_position_extremes(state.position.as_ref(), &mut state.extremes, candle);
226 }
227
228 while state.div_idx < state.dividends.len()
230 && state.dividends[state.div_idx].timestamp <= timestamp
231 {
232 if let Some(ref mut pos) = state.position {
233 let per_share = state.dividends[state.div_idx].amount;
234 let income = if pos.is_long() {
235 per_share * pos.quantity
236 } else {
237 -(per_share * pos.quantity)
238 };
239 pos.credit_dividend(
240 income,
241 candle.close,
242 self.config.base.reinvest_dividends,
243 );
244 }
245 state.div_idx += 1;
246 }
247
248 if let Some(ref pos) = state.position
251 && let Some(exit_signal) =
252 check_sl_tp(pos, candle, state.hwm, &self.config.base)
253 {
254 auto_exits.push((sym.clone(), exit_signal));
255 }
256
257 update_trailing_hwm(state.position.as_ref(), &mut state.hwm, candle);
258 }
259
260 let mut exited_this_bar: HashSet<String> = HashSet::new();
263 for (sym, exit_signal) in auto_exits {
264 let state = states.get_mut(&sym).unwrap();
265 let fill_price = exit_signal.price;
266 if execute_forced_exit(
267 &self.config.base,
268 state,
269 &mut cash,
270 timestamp,
271 fill_price,
272 exit_signal,
273 ) {
274 exited_this_bar.insert(sym);
275 }
276 }
277
278 if margin_enabled {
283 loop {
284 let equity = compute_portfolio_equity(cash, &states, timestamp);
285 let mut gross_total = 0.0;
286 let mut any_short = false;
287 let mut largest: Option<(String, f64, f64)> = None;
288 for (sym, s) in &states {
289 if let Some(pos) = s.position.as_ref()
290 && let Some(close) = close_at_or_before(s, timestamp)
291 {
292 let gross = pos.quantity * close;
293 gross_total += gross;
294 any_short |= pos.is_short();
295 let replace = match &largest {
296 Some((lsym, lgross, _)) => {
297 gross > *lgross || (gross == *lgross && sym < lsym)
298 }
299 None => true,
300 };
301 if replace {
302 largest = Some((sym.clone(), gross, close));
303 }
304 }
305 }
306 let checked = self.config.base.max_leverage > 1.0 || any_short;
307 if !checked
308 || gross_total <= 0.0
309 || equity >= self.config.base.maintenance_margin_pct * gross_total
310 {
311 break;
312 }
313 let Some((sym, _, close)) = largest else {
314 break;
315 };
316 let exit_signal = Signal::exit(timestamp, close)
317 .with_reason("Margin call: equity below maintenance margin requirement");
318 let state = states.get_mut(&sym).unwrap();
319 execute_forced_exit(
320 &self.config.base,
321 state,
322 &mut cash,
323 timestamp,
324 close,
325 exit_signal,
326 );
327 exited_this_bar.insert(sym);
328 }
329 }
330
331 let pending_entries = dispatch_bar_signals(
333 &self.config,
334 &mut states,
335 &active_symbols,
336 &exited_this_bar,
337 timestamp,
338 &mut cash,
339 );
340
341 open_pending_entries(
343 &self.config,
344 &helper_engine,
345 &mut states,
346 pending_entries,
347 timestamp,
348 initial_capital,
349 n_symbols,
350 &mut cash,
351 );
352
353 let portfolio_equity = compute_portfolio_equity(cash, &states, timestamp);
355
356 if portfolio_equity > portfolio_peak {
357 portfolio_peak = portfolio_equity;
358 }
359 let drawdown_pct = if portfolio_peak > 0.0 {
360 (portfolio_peak - portfolio_equity) / portfolio_peak
361 } else {
362 0.0
363 };
364
365 portfolio_equity_curve.push(EquityPoint {
366 timestamp,
367 equity: portfolio_equity,
368 drawdown_pct,
369 });
370
371 for sym in &active_symbols {
373 let state = states.get_mut(sym).unwrap();
374 let candle_idx = state.ts_index[×tamp];
375 let close = state.candles[candle_idx].close;
376 let unrealized = state
377 .position
378 .as_ref()
379 .map(|pos| pos.unrealized_pnl(close))
380 .unwrap_or(0.0);
381 let sym_equity = state.sym_initial_capital + state.realized_pnl + unrealized;
382 if sym_equity > state.sym_peak {
383 state.sym_peak = sym_equity;
384 }
385 if portfolio_equity > 0.0
386 && let Some(pos) = state.position.as_ref()
387 {
388 let leverage = pos.quantity * close / portfolio_equity;
389 state.sym_max_leverage = state.sym_max_leverage.max(leverage);
390 }
391 let sym_drawdown = if state.sym_peak > 0.0 {
392 (state.sym_peak - sym_equity) / state.sym_peak
393 } else {
394 0.0
395 };
396 state.equity_curve.push(EquityPoint {
397 timestamp,
398 equity: sym_equity,
399 drawdown_pct: sym_drawdown,
400 });
401 }
402
403 let position_values: HashMap<String, f64> = states
405 .iter()
406 .filter_map(|(sym, s)| {
407 s.position.as_ref().and_then(|pos| {
408 close_at_or_before(s, timestamp).map(|close| {
409 (
410 sym.clone(),
411 pos.current_value(close) + pos.unreinvested_dividends,
412 )
413 })
414 })
415 })
416 .collect();
417
418 allocation_history.push(AllocationSnapshot {
419 timestamp,
420 cash,
421 positions: position_values,
422 });
423 }
424
425 if self.config.base.close_at_end {
427 for state in states.values_mut() {
428 if let Some(pos) = state.position.take() {
429 let last_candle = state.candles.last().unwrap();
430 let exit_price_slipped = self
431 .config
432 .base
433 .apply_exit_slippage(last_candle.close, pos.is_long());
434 let exit_price = self
435 .config
436 .base
437 .apply_exit_spread(exit_price_slipped, pos.is_long());
438 let exit_comm = self
439 .config
440 .base
441 .calculate_commission(pos.quantity, exit_price);
442 let exit_tax = self
443 .config
444 .base
445 .calculate_transaction_tax(exit_price * pos.quantity, !pos.is_long());
446 let exit_signal = Signal::exit(last_candle.timestamp, last_candle.close)
447 .with_reason("End of backtest");
448 let trade = pos.close_with_tax(
449 last_candle.timestamp,
450 exit_price,
451 exit_comm,
452 exit_tax,
453 exit_signal,
454 );
455 if trade.is_long() {
456 cash += trade.exit_value() - exit_comm + trade.unreinvested_dividends;
457 } else {
458 cash -= trade.exit_value() + exit_comm + exit_tax
459 - trade.unreinvested_dividends;
460 }
461 state.realized_pnl += trade.pnl;
462 state.trades.push(trade);
463 state.hwm = None;
464 state.extremes = None;
465
466 let sym_equity = state.sym_initial_capital + state.realized_pnl;
467 sync_terminal_equity_point(
468 &mut state.equity_curve,
469 last_candle.timestamp,
470 sym_equity,
471 );
472 }
473 }
474 }
475
476 let final_equity: f64 = cash
478 + states
479 .values()
480 .map(|s| {
481 s.position
482 .as_ref()
483 .zip(s.candles.last())
484 .map(|(pos, c)| pos.current_value(c.close) + pos.unreinvested_dividends)
485 .unwrap_or(0.0)
486 })
487 .sum::<f64>();
488
489 if let Some(last_ts) = master_timeline.last().copied() {
490 sync_terminal_equity_point(&mut portfolio_equity_curve, last_ts, final_equity);
491 }
492
493 Ok(build_portfolio_result(
494 &self.config,
495 states,
496 portfolio_equity_curve,
497 allocation_history,
498 initial_capital,
499 final_equity,
500 ))
501 }
502}
503
504mod entries;
505mod exits;
506mod report;
507mod signals;
508mod state;
509
510use self::entries::open_pending_entries;
511use self::exits::execute_forced_exit;
512use self::report::{build_portfolio_result, sync_terminal_equity_point};
513use self::signals::dispatch_bar_signals;
514use self::state::{SymbolState, close_at_or_before, compute_portfolio_equity};
515use crate::backtesting::engine::check_sl_tp;
516
517#[cfg(test)]
518mod tests;
519#[cfg(test)]
520mod tests_margin;