Skip to main content

quantwave_backtest/
portfolio.rs

1//! Shared-capital portfolio simulation (quantwave-qzpi.6–10).
2//!
3//! Single cash pool across symbols; bar-by-bar processing at each timestamp.
4//! See `planning/SHARED_CAPITAL_PORTFOLIO_ADR.md`.
5
6use crate::{
7    BacktestConfig, BacktestError, BacktestResult, EquityPoint, ExecutionDelay, ExecutionModel,
8    InitialRiskPositionSizer, StopConfig, StrategySignal, Trade, apply_signal_modifiers, stops,
9};
10use chrono::{DateTime, Utc};
11use quantwave_core::traits::Next;
12use serde::{Deserialize, Serialize};
13use std::collections::{BTreeMap, HashMap};
14
15/// How multi-symbol runs allocate capital across symbols.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
17pub enum PortfolioMode {
18    /// Each symbol gets its own `initial_cash` book (legacy default).
19    #[default]
20    IndependentBooks,
21    /// Single cash pool shared across all symbols.
22    SharedCapital,
23}
24
25/// Budget split when opening positions in shared-capital mode.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
27pub enum PortfolioAllocator {
28    /// `equity / N` for N symbols with non-zero entry intent.
29    #[default]
30    EqualWeight,
31    /// `equity × (|signal| / Σ|signal|)`.
32    SignalWeighted,
33}
34
35/// Bar with symbol tag for shared-capital streaming parity.
36#[derive(Debug, Clone, PartialEq)]
37pub struct PortfolioBar {
38    pub ts: DateTime<Utc>,
39    pub symbol: String,
40    pub close: f64,
41    pub high: Option<f64>,
42    pub low: Option<f64>,
43}
44
45struct SymbolBar {
46    symbol: String,
47    close: f64,
48    high: Option<f64>,
49    low: Option<f64>,
50    raw_signal: f64,
51    meta: Option<HashMap<String, f64>>,
52}
53
54pub(crate) struct TimestampGroup {
55    ts: DateTime<Utc>,
56    bars: Vec<SymbolBar>,
57}
58
59#[derive(Clone)]
60struct SymbolBook {
61    exposure: f64,
62    entry_price: f64,
63    entry_ts: Option<DateTime<Utc>>,
64    entry_metadata: Option<HashMap<String, f64>>,
65    stop_state: stops::StopPositionState,
66    trade_id: u32,
67}
68
69/// Allocate signed units for a new entry given portfolio equity and peer intents.
70fn allocate_entry_units(
71    allocator: PortfolioAllocator,
72    raw_desired: f64,
73    price: f64,
74    equity: f64,
75    peer_intents: &[(f64, f64)], // (abs_weight, price) for each entering symbol
76    my_weight: f64,
77) -> f64 {
78    if !price.is_finite() || price <= 0.0 || equity <= 0.0 || raw_desired == 0.0 {
79        return 0.0;
80    }
81    let sign = if raw_desired > 0.0 { 1.0 } else { -1.0 };
82    let budget = match allocator {
83        PortfolioAllocator::EqualWeight => {
84            let n = peer_intents.len().max(1) as f64;
85            equity / n
86        }
87        PortfolioAllocator::SignalWeighted => {
88            let total: f64 = peer_intents.iter().map(|(w, _)| w).sum();
89            if total <= f64::EPSILON {
90                equity / peer_intents.len().max(1) as f64
91            } else {
92                equity * (my_weight / total)
93            }
94        }
95    };
96    let units_from_budget = budget / price;
97    let cap = raw_desired.abs();
98    sign * units_from_budget.min(cap)
99}
100
101fn mark_to_market_equity(
102    cash: f64,
103    books: &HashMap<String, SymbolBook>,
104    prices: &HashMap<String, f64>,
105) -> f64 {
106    let mut eq = cash;
107    for (sym, book) in books {
108        if book.exposure != 0.0
109            && let Some(&px) = prices.get(sym)
110        {
111            eq += book.exposure * px;
112        }
113    }
114    eq
115}
116
117/// Core shared-capital simulator (batch + streaming).
118pub(crate) fn simulate_shared_capital(
119    groups: &[TimestampGroup],
120    exec: &ExecutionModel,
121    sizer: &Option<InitialRiskPositionSizer>,
122    _delay: ExecutionDelay,
123    stops: &StopConfig,
124    allocator: PortfolioAllocator,
125) -> (
126    Vec<Trade>,
127    HashMap<String, Vec<EquityPoint>>,
128    Vec<EquityPoint>,
129) {
130    let initial_cash = match exec {
131        ExecutionModel::Simple(cm) => cm.initial_cash,
132        ExecutionModel::HighFidelity { .. } => 100_000.0,
133    };
134    let mut cash = initial_cash;
135    let mut books: HashMap<String, SymbolBook> = HashMap::new();
136    let mut trade_id: u32 = 0;
137    let mut trades: Vec<Trade> = Vec::new();
138    let mut per_symbol_equity: HashMap<String, Vec<EquityPoint>> = HashMap::new();
139    let mut portfolio_curve: Vec<EquityPoint> = Vec::with_capacity(groups.len());
140
141    let mut record_exit = |cash: &mut f64,
142                           sym: &str,
143                           book: &SymbolBook,
144                           exit_bar_ts: DateTime<Utc>,
145                           exit_close: f64| {
146        let qty = book.exposure.abs();
147        let side = if book.exposure > 0.0 { 1 } else { -1 };
148        let is_buy = side == -1;
149        let fill_price = exec.slippage_price(exit_close, qty, is_buy, None);
150        let notional = fill_price * qty;
151        let cost = exec.commission_for(qty, fill_price);
152        let gross_pnl = if side == 1 {
153            (fill_price - book.entry_price) * qty
154        } else {
155            (book.entry_price - fill_price) * qty
156        };
157        let net_pnl = gross_pnl - cost;
158        if side == 1 {
159            *cash += notional - cost;
160        } else {
161            *cash -= notional + cost;
162        }
163        trades.push(Trade {
164            trade_id: book.trade_id,
165            symbol: Some(sym.to_string()),
166            side,
167            entry_ts: book.entry_ts.unwrap_or(exit_bar_ts),
168            entry_price: book.entry_price,
169            entry_fill_price: book.entry_price,
170            exit_ts: Some(exit_bar_ts),
171            exit_price: Some(exit_close),
172            exit_fill_price: Some(fill_price),
173            pnl_gross: gross_pnl,
174            costs: cost,
175            pnl_net: net_pnl,
176            quantity: qty,
177            entry_metadata: book.entry_metadata.clone(),
178        });
179    };
180
181    let open_position = |cash: &mut f64,
182                         tid: u32,
183                         desired: f64,
184                         fill_ts: DateTime<Utc>,
185                         fill_close: f64,
186                         meta: Option<HashMap<String, f64>>|
187     -> SymbolBook {
188        let qty = desired.abs();
189        let is_long = desired > 0.0;
190        let fill_price = exec.slippage_price(fill_close, qty, is_long, None);
191        let notional = fill_price * qty;
192        let cost = exec.commission_for(qty, fill_price);
193        if is_long {
194            *cash -= notional + cost;
195        } else {
196            *cash += notional - cost;
197        }
198        let exposure = if is_long { qty } else { -qty };
199        let mut stop_state = stops::StopPositionState::default();
200        if let Some(pct) = stops.trailing_stop_pct {
201            stop_state.trailing_stop_level =
202                Some(stops::trailing_level_at_entry(fill_price, is_long, pct));
203        }
204        SymbolBook {
205            exposure,
206            entry_price: fill_price,
207            entry_ts: Some(fill_ts),
208            entry_metadata: meta,
209            stop_state,
210            trade_id: tid,
211        }
212    };
213
214    for group in groups {
215        let ts = group.ts;
216        let mut prices: HashMap<String, f64> = HashMap::new();
217        for bar in &group.bars {
218            prices.insert(bar.symbol.clone(), bar.close);
219        }
220
221        // Stop checks per symbol
222        for bar in &group.bars {
223            let sym = &bar.symbol;
224            let close = bar.close;
225            if !close.is_finite() {
226                continue;
227            }
228            let Some(book) = books.get_mut(sym) else {
229                continue;
230            };
231            if book.exposure == 0.0 || !stops.has_stops() {
232                continue;
233            }
234            let is_long = book.exposure > 0.0;
235            let ohlc = stops::OhlcBar {
236                close,
237                high: bar.high,
238                low: bar.low,
239            };
240            if let Some(stop_exit) =
241                stops::evaluate_stops(stops, ohlc, is_long, book.entry_price, &mut book.stop_state)
242            {
243                let snapshot = book.clone();
244                record_exit(&mut cash, sym, &snapshot, ts, stop_exit.exit_price);
245                *book = SymbolBook {
246                    exposure: 0.0,
247                    entry_price: 0.0,
248                    entry_ts: None,
249                    entry_metadata: None,
250                    stop_state: stops::StopPositionState::default(),
251                    trade_id: snapshot.trade_id,
252                };
253            }
254        }
255
256        // Collect entry intents for this bar (after stops)
257        let mut desired_map: HashMap<String, f64> = HashMap::new();
258        let mut meta_map: HashMap<String, Option<HashMap<String, f64>>> = HashMap::new();
259        for (idx, bar) in group.bars.iter().enumerate() {
260            let raw = apply_signal_modifiers(bar.raw_signal, None, None);
261            let sized = if let Some(s) = sizer {
262                let eq = mark_to_market_equity(cash, &books, &prices);
263                s.compute_sized_exposure(raw, &bar.meta, bar.close, eq)
264            } else {
265                raw
266            };
267            desired_map.insert(bar.symbol.clone(), sized);
268            meta_map.insert(bar.symbol.clone(), bar.meta.clone());
269            let _ = idx;
270        }
271
272        let eq = mark_to_market_equity(cash, &books, &prices);
273        let mut entry_peers: Vec<(String, f64, f64)> = Vec::new(); // sym, weight, price
274        for (sym, &desired) in &desired_map {
275            if desired == 0.0 {
276                continue;
277            }
278            let in_pos = books.get(sym).map(|b| b.exposure != 0.0).unwrap_or(false);
279            if !in_pos {
280                entry_peers.push((sym.clone(), desired.abs(), prices[sym]));
281            }
282        }
283
284        // Execute signals per symbol (deterministic symbol order)
285        let mut syms: Vec<&String> = desired_map.keys().collect();
286        syms.sort();
287        for sym in syms {
288            let close = prices[sym];
289            if !close.is_finite() {
290                continue;
291            }
292            let desired_raw = desired_map[sym];
293            let meta = meta_map[sym].clone();
294
295            let book = books.entry(sym.clone()).or_insert(SymbolBook {
296                exposure: 0.0,
297                entry_price: 0.0,
298                entry_ts: None,
299                entry_metadata: None,
300                stop_state: stops::StopPositionState::default(),
301                trade_id: 0,
302            });
303
304            if desired_raw == 0.0 && book.exposure != 0.0 {
305                let snapshot = book.clone();
306                record_exit(&mut cash, sym, &snapshot, ts, close);
307                *book = SymbolBook {
308                    exposure: 0.0,
309                    entry_price: 0.0,
310                    entry_ts: None,
311                    entry_metadata: None,
312                    stop_state: stops::StopPositionState::default(),
313                    trade_id: snapshot.trade_id,
314                };
315                continue;
316            }
317
318            if desired_raw == 0.0 {
319                continue;
320            }
321
322            let want_long = desired_raw > 0.0;
323            let in_long = book.exposure > 0.0;
324            let in_short = book.exposure < 0.0;
325            let flip = (want_long && in_short) || (!want_long && in_long);
326
327            if flip {
328                let snapshot = book.clone();
329                record_exit(&mut cash, sym, &snapshot, ts, close);
330                *book = SymbolBook {
331                    exposure: 0.0,
332                    entry_price: 0.0,
333                    entry_ts: None,
334                    entry_metadata: None,
335                    stop_state: stops::StopPositionState::default(),
336                    trade_id: snapshot.trade_id,
337                };
338            }
339
340            if book.exposure == 0.0 {
341                let peers: Vec<(f64, f64)> = entry_peers.iter().map(|(_, w, p)| (*w, *p)).collect();
342                let my_weight = desired_raw.abs();
343                let allocated =
344                    allocate_entry_units(allocator, desired_raw, close, eq, &peers, my_weight);
345                if allocated != 0.0 {
346                    trade_id += 1;
347                    *book = open_position(&mut cash, trade_id, allocated, ts, close, meta);
348                }
349            }
350        }
351
352        // Record equity points
353        let total_eq = mark_to_market_equity(cash, &books, &prices);
354        for bar in &group.bars {
355            let sym = &bar.symbol;
356            let book = books.get(sym);
357            let (pos, sym_cash, sym_eq) = match book {
358                Some(b) if b.exposure != 0.0 => {
359                    let pos_val = b.exposure * bar.close;
360                    (b.exposure, cash, cash + pos_val)
361                }
362                _ => (0.0, cash, cash),
363            };
364            per_symbol_equity
365                .entry(sym.clone())
366                .or_default()
367                .push(EquityPoint {
368                    ts,
369                    symbol: Some(sym.clone()),
370                    equity: sym_eq,
371                    cash: sym_cash,
372                    position: pos,
373                    close: bar.close,
374                });
375        }
376        portfolio_curve.push(EquityPoint {
377            ts,
378            symbol: None,
379            equity: total_eq,
380            cash,
381            position: books.values().map(|b| b.position_value()).sum(),
382            close: 0.0,
383        });
384    }
385
386    // Terminal open trades
387    if let Some(last) = groups.last() {
388        let ts = last.ts;
389        for (sym, book) in &books {
390            if book.exposure != 0.0 {
391                let close = last
392                    .bars
393                    .iter()
394                    .find(|b| &b.symbol == sym)
395                    .map(|b| b.close)
396                    .unwrap_or(0.0);
397                let qty = book.exposure.abs();
398                let side = if book.exposure > 0.0 { 1 } else { -1 };
399                let gross = if side == 1 {
400                    (close - book.entry_price) * qty
401                } else {
402                    (book.entry_price - close) * qty
403                };
404                if let Some(ets) = book.entry_ts {
405                    trades.push(Trade {
406                        trade_id: book.trade_id,
407                        symbol: Some(sym.clone()),
408                        side,
409                        entry_ts: ets,
410                        entry_price: book.entry_price,
411                        entry_fill_price: book.entry_price,
412                        exit_ts: None,
413                        exit_price: Some(close),
414                        exit_fill_price: None,
415                        pnl_gross: gross,
416                        costs: 0.0,
417                        pnl_net: gross,
418                        quantity: qty,
419                        entry_metadata: book.entry_metadata.clone(),
420                    });
421                }
422            }
423            let _ = ts;
424        }
425    }
426
427    (trades, per_symbol_equity, portfolio_curve)
428}
429
430impl SymbolBook {
431    fn position_value(&self) -> f64 {
432        self.exposure
433    }
434}
435
436/// Build timestamp groups from a sorted long-format DataFrame.
437pub(crate) fn build_timestamp_groups(
438    signal_vals: &[f64],
439    signal_metas: &[Option<HashMap<String, f64>>],
440    symbols: &[String],
441    timestamps: &[DateTime<Utc>],
442    closes: &[f64],
443    highs: Option<&[f64]>,
444    lows: Option<&[f64]>,
445) -> Vec<TimestampGroup> {
446    let mut groups: Vec<TimestampGroup> = Vec::new();
447    let mut i = 0usize;
448    while i < timestamps.len() {
449        let ts = timestamps[i];
450        let mut bars = Vec::new();
451        while i < timestamps.len() && timestamps[i] == ts {
452            bars.push(SymbolBar {
453                symbol: symbols[i].clone(),
454                close: closes[i],
455                high: highs.and_then(|h| h.get(i).copied()),
456                low: lows.and_then(|l| l.get(i).copied()),
457                raw_signal: signal_vals[i],
458                meta: signal_metas[i].clone(),
459            });
460            i += 1;
461        }
462        bars.sort_by(|a, b| a.symbol.cmp(&b.symbol));
463        groups.push(TimestampGroup { ts, bars });
464    }
465    groups
466}
467
468/// Run shared-capital streaming simulation for batch↔streaming parity.
469pub fn run_shared_capital_streaming_simulation<G>(
470    bars: &[PortfolioBar],
471    mut generator: G,
472    config: BacktestConfig,
473) -> Result<BacktestResult, BacktestError>
474where
475    G: for<'a> Next<&'a PortfolioBar, Output = StrategySignal>,
476{
477    if bars.is_empty() {
478        return Err(BacktestError::InvalidInput("empty bars".into()));
479    }
480    if config.symbol_col.is_none() {
481        return Err(BacktestError::InvalidInput(
482            "shared-capital streaming requires symbol_col".into(),
483        ));
484    }
485
486    let mut groups_map: BTreeMap<DateTime<Utc>, Vec<SymbolBar>> = BTreeMap::new();
487    for bar in bars {
488        let sig = generator.next(bar);
489        groups_map.entry(bar.ts).or_default().push(SymbolBar {
490            symbol: bar.symbol.clone(),
491            close: bar.close,
492            high: bar.high,
493            low: bar.low,
494            raw_signal: sig.exposure,
495            meta: sig.metadata.clone(),
496        });
497    }
498    let groups: Vec<TimestampGroup> = groups_map
499        .into_iter()
500        .map(|(ts, mut bars)| {
501            bars.sort_by(|a, b| a.symbol.cmp(&b.symbol));
502            TimestampGroup { ts, bars }
503        })
504        .collect();
505
506    let exec = &config.execution_model;
507    let sizer = &config.position_sizer;
508    let delay = config.execution_delay;
509    let stops = &config.stop_config;
510    let allocator = config.portfolio_allocator;
511
512    let (trades, per_symbol_equity, portfolio_eq) =
513        simulate_shared_capital(&groups, exec, sizer, delay, stops, allocator);
514
515    crate::BacktestEngine::assemble_shared_capital_result(
516        &config,
517        trades,
518        per_symbol_equity,
519        portfolio_eq,
520    )
521}