Skip to main content

finance_query/backtesting/portfolio/engine/
mod.rs

1//! Multi-symbol portfolio backtesting engine.
2
3use 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// ── Public types ──────────────────────────────────────────────────────────────
16
17/// Input data for a single symbol in the portfolio backtest.
18#[non_exhaustive]
19#[derive(Debug, Clone)]
20pub struct SymbolData {
21    /// Ticker symbol (e.g. `"AAPL"`)
22    pub symbol: String,
23
24    /// OHLCV candles sorted by timestamp ascending.
25    pub candles: Vec<Candle>,
26
27    /// Dividend history sorted by timestamp ascending.
28    ///
29    /// An empty vec disables dividend processing for this symbol.
30    pub dividends: Vec<Dividend>,
31}
32
33impl SymbolData {
34    /// Convenience constructor with no dividends.
35    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    /// Attach dividends (sorted ascending by timestamp).
44    pub fn with_dividends(mut self, dividends: Vec<Dividend>) -> Self {
45        self.dividends = dividends;
46        self
47    }
48}
49
50/// Multi-symbol portfolio backtesting engine.
51///
52/// Runs all symbols on a shared capital pool, applying the configured
53/// allocation strategy and position constraints simultaneously.
54pub struct PortfolioEngine {
55    config: PortfolioConfig,
56}
57
58impl PortfolioEngine {
59    /// Create a new portfolio engine.
60    pub fn new(config: PortfolioConfig) -> Self {
61        Self { config }
62    }
63
64    /// Run a portfolio backtest.
65    ///
66    /// `factory` is called once per symbol to create an independent strategy
67    /// instance for that symbol. Use a closure that captures any shared
68    /// parameters:
69    ///
70    /// ```ignore
71    /// engine.run(&symbol_data, |sym| SmaCrossover::new(10, 50))
72    /// ```
73    ///
74    /// Entry signals across symbols are ranked by strength (descending); ties
75    /// broken alphabetically, giving deterministic results.
76    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        // ── Build per-symbol state ─────────────────────────────────────────────
87        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            // Pre-compute the expected per-symbol capital allocation so that
110            // per-symbol equity, return %, and Sharpe are relative to the
111            // actual amount deployed — not the full portfolio initial_capital.
112            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        // ── Build master timeline (union of all symbol timestamps) ─────────────
148        let master_timeline: BTreeSet<i64> = states
149            .values()
150            .flat_map(|s| s.candles.iter().map(|c| c.timestamp))
151            .collect();
152
153        // ── Shared portfolio state ─────────────────────────────────────────────
154        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        // ── Main simulation loop ───────────────────────────────────────────────
164        for &timestamp in &master_timeline {
165            // Collect present symbols for this bar (parallel mutable iteration
166            // is not possible, so we collect keys then iterate)
167            let mut active_symbols: Vec<String> = states
168                .keys()
169                .filter(|sym| states[*sym].ts_index.contains_key(&timestamp))
170                .cloned()
171                .collect();
172            // HashMap iteration order is unspecified; sort so ScaleIn/ScaleOut
173            // cash contention resolves the same way on every run.
174            active_symbols.sort();
175
176            // Margin interest on a debit cash balance, split across open
177            // positions by gross exposure so it exits through their trades.
178            // Skipped when flat, matching the single-symbol engine.
179            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            // --- Step 1: Update position values, dividends, trailing stops ----
204            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[&timestamp];
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                // Credit dividends ex-dated on or before this bar
229                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                // Check SL/TP/trailing stop against the hwm as of the prior bar,
249                // before this bar's own high/low is folded in below.
250                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            // Process auto-exits (SL/TP/trailing) — execute on the current bar at the
261            // fill price embedded in the signal (stop/TP level with gap guard).
262            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            // Account-level maintenance check, after the stops so an intrabar
279            // stop on the same bar outranks the liquidation. Positions close
280            // at the bar's close, largest exposure first, until equity covers
281            // the requirement. An unlevered long-only book is never checked.
282            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            // --- Step 2: strategy signals; exits/scales execute, entries queue ---
332            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            // --- Step 3: Open entry positions (highest strength first) ----------
342            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            // --- Step 4: Record portfolio equity and allocation snapshot --------
354            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            // Record per-symbol equity curves for symbols active this bar
372            for sym in &active_symbols {
373                let state = states.get_mut(sym).unwrap();
374                let candle_idx = state.ts_index[&timestamp];
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            // Record allocation snapshot
404            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        // ── Close any remaining open positions at end ──────────────────────────
426        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        // ── Final equity ───────────────────────────────────────────────────────
477        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;