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    apply_signal_modifiers, stops, BacktestConfig, BacktestError, BacktestResult, EquityPoint,
8    ExecutionDelay, ExecutionModel, InitialRiskPositionSizer, StopConfig, StrategySignal, Trade,
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(cash: f64, books: &HashMap<String, SymbolBook>, prices: &HashMap<String, f64>) -> f64 {
102    let mut eq = cash;
103    for (sym, book) in books {
104        if book.exposure != 0.0 {
105            if let Some(&px) = prices.get(sym) {
106                eq += book.exposure * px;
107            }
108        }
109    }
110    eq
111}
112
113/// Core shared-capital simulator (batch + streaming).
114pub(crate) fn simulate_shared_capital(
115    groups: &[TimestampGroup],
116    exec: &ExecutionModel,
117    sizer: &Option<InitialRiskPositionSizer>,
118    _delay: ExecutionDelay,
119    stops: &StopConfig,
120    allocator: PortfolioAllocator,
121) -> (Vec<Trade>, HashMap<String, Vec<EquityPoint>>, Vec<EquityPoint>) {
122    let initial_cash = match exec {
123        ExecutionModel::Simple(cm) => cm.initial_cash,
124        ExecutionModel::HighFidelity { .. } => 100_000.0,
125    };
126    let mut cash = initial_cash;
127    let mut books: HashMap<String, SymbolBook> = HashMap::new();
128    let mut trade_id: u32 = 0;
129    let mut trades: Vec<Trade> = Vec::new();
130    let mut per_symbol_equity: HashMap<String, Vec<EquityPoint>> = HashMap::new();
131    let mut portfolio_curve: Vec<EquityPoint> = Vec::with_capacity(groups.len());
132
133    let mut record_exit = |cash: &mut f64,
134                           sym: &str,
135                           book: &SymbolBook,
136                           exit_bar_ts: DateTime<Utc>,
137                           exit_close: f64| {
138        let qty = book.exposure.abs();
139        let side = if book.exposure > 0.0 { 1 } else { -1 };
140        let is_buy = side == -1;
141        let fill_price = exec.slippage_price(exit_close, qty, is_buy, None);
142        let notional = fill_price * qty;
143        let cost = exec.commission_for(qty, fill_price);
144        let gross_pnl = if side == 1 {
145            (fill_price - book.entry_price) * qty
146        } else {
147            (book.entry_price - fill_price) * qty
148        };
149        let net_pnl = gross_pnl - cost;
150        if side == 1 {
151            *cash += notional - cost;
152        } else {
153            *cash -= notional + cost;
154        }
155        trades.push(Trade {
156            trade_id: book.trade_id,
157            symbol: Some(sym.to_string()),
158            side,
159            entry_ts: book.entry_ts.unwrap_or(exit_bar_ts),
160            entry_price: book.entry_price,
161            entry_fill_price: book.entry_price,
162            exit_ts: Some(exit_bar_ts),
163            exit_price: Some(exit_close),
164            exit_fill_price: Some(fill_price),
165            pnl_gross: gross_pnl,
166            costs: cost,
167            pnl_net: net_pnl,
168            quantity: qty,
169            entry_metadata: book.entry_metadata.clone(),
170        });
171    };
172
173    let open_position = |cash: &mut f64,
174                         tid: u32,
175                         desired: f64,
176                         fill_ts: DateTime<Utc>,
177                         fill_close: f64,
178                         meta: Option<HashMap<String, f64>>|
179     -> SymbolBook {
180        let qty = desired.abs();
181        let is_long = desired > 0.0;
182        let fill_price = exec.slippage_price(fill_close, qty, is_long, None);
183        let notional = fill_price * qty;
184        let cost = exec.commission_for(qty, fill_price);
185        if is_long {
186            *cash -= notional + cost;
187        } else {
188            *cash += notional - cost;
189        }
190        let exposure = if is_long { qty } else { -qty };
191        let mut stop_state = stops::StopPositionState::default();
192        if let Some(pct) = stops.trailing_stop_pct {
193            stop_state.trailing_stop_level =
194                Some(stops::trailing_level_at_entry(fill_price, is_long, pct));
195        }
196        SymbolBook {
197            exposure,
198            entry_price: fill_price,
199            entry_ts: Some(fill_ts),
200            entry_metadata: meta,
201            stop_state,
202            trade_id: tid,
203        }
204    };
205
206    for group in groups {
207        let ts = group.ts;
208        let mut prices: HashMap<String, f64> = HashMap::new();
209        for bar in &group.bars {
210            prices.insert(bar.symbol.clone(), bar.close);
211        }
212
213        // Stop checks per symbol
214        for bar in &group.bars {
215            let sym = &bar.symbol;
216            let close = bar.close;
217            if !close.is_finite() {
218                continue;
219            }
220            let Some(book) = books.get_mut(sym) else {
221                continue;
222            };
223            if book.exposure == 0.0 || !stops.has_stops() {
224                continue;
225            }
226            let is_long = book.exposure > 0.0;
227            let ohlc = stops::OhlcBar {
228                close,
229                high: bar.high,
230                low: bar.low,
231            };
232            if let Some(stop_exit) =
233                stops::evaluate_stops(stops, ohlc, is_long, book.entry_price, &mut book.stop_state)
234            {
235                let snapshot = book.clone();
236                record_exit(
237                    &mut cash,
238                    sym,
239                    &snapshot,
240                    ts,
241                    stop_exit.exit_price,
242                );
243                *book = SymbolBook {
244                    exposure: 0.0,
245                    entry_price: 0.0,
246                    entry_ts: None,
247                    entry_metadata: None,
248                    stop_state: stops::StopPositionState::default(),
249                    trade_id: snapshot.trade_id,
250                };
251            }
252        }
253
254        // Collect entry intents for this bar (after stops)
255        let mut desired_map: HashMap<String, f64> = HashMap::new();
256        let mut meta_map: HashMap<String, Option<HashMap<String, f64>>> = HashMap::new();
257        for (idx, bar) in group.bars.iter().enumerate() {
258            let raw = apply_signal_modifiers(bar.raw_signal, None, None);
259            let sized = if let Some(s) = sizer {
260                let eq = mark_to_market_equity(cash, &books, &prices);
261                s.compute_sized_exposure(raw, &bar.meta, bar.close, eq)
262            } else {
263                raw
264            };
265            desired_map.insert(bar.symbol.clone(), sized);
266            meta_map.insert(bar.symbol.clone(), bar.meta.clone());
267            let _ = idx;
268        }
269
270        let eq = mark_to_market_equity(cash, &books, &prices);
271        let mut entry_peers: Vec<(String, f64, f64)> = Vec::new(); // sym, weight, price
272        for (sym, &desired) in &desired_map {
273            if desired == 0.0 {
274                continue;
275            }
276            let in_pos = books.get(sym).map(|b| b.exposure != 0.0).unwrap_or(false);
277            if !in_pos {
278                entry_peers.push((sym.clone(), desired.abs(), prices[sym]));
279            }
280        }
281
282        // Execute signals per symbol (deterministic symbol order)
283        let mut syms: Vec<&String> = desired_map.keys().collect();
284        syms.sort();
285        for sym in syms {
286            let close = prices[sym];
287            if !close.is_finite() {
288                continue;
289            }
290            let desired_raw = desired_map[sym];
291            let meta = meta_map[sym].clone();
292
293            let book = books.entry(sym.clone()).or_insert(SymbolBook {
294                exposure: 0.0,
295                entry_price: 0.0,
296                entry_ts: None,
297                entry_metadata: None,
298                stop_state: stops::StopPositionState::default(),
299                trade_id: 0,
300            });
301
302            if desired_raw == 0.0 && book.exposure != 0.0 {
303                let snapshot = book.clone();
304                record_exit(&mut cash, sym, &snapshot, ts, close);
305                *book = SymbolBook {
306                    exposure: 0.0,
307                    entry_price: 0.0,
308                    entry_ts: None,
309                    entry_metadata: None,
310                    stop_state: stops::StopPositionState::default(),
311                    trade_id: snapshot.trade_id,
312                };
313                continue;
314            }
315
316            if desired_raw == 0.0 {
317                continue;
318            }
319
320            let want_long = desired_raw > 0.0;
321            let in_long = book.exposure > 0.0;
322            let in_short = book.exposure < 0.0;
323            let flip = (want_long && in_short) || (!want_long && in_long);
324
325            if flip {
326                let snapshot = book.clone();
327                record_exit(&mut cash, sym, &snapshot, ts, close);
328                *book = SymbolBook {
329                    exposure: 0.0,
330                    entry_price: 0.0,
331                    entry_ts: None,
332                    entry_metadata: None,
333                    stop_state: stops::StopPositionState::default(),
334                    trade_id: snapshot.trade_id,
335                };
336            }
337
338            if book.exposure == 0.0 {
339                let peers: Vec<(f64, f64)> = entry_peers
340                    .iter()
341                    .map(|(_, w, p)| (*w, *p))
342                    .collect();
343                let my_weight = desired_raw.abs();
344                let allocated = allocate_entry_units(
345                    allocator,
346                    desired_raw,
347                    close,
348                    eq,
349                    &peers,
350                    my_weight,
351                );
352                if allocated != 0.0 {
353                    trade_id += 1;
354                    *book = open_position(&mut cash, trade_id, allocated, ts, close, meta);
355                }
356            }
357        }
358
359        // Record equity points
360        let total_eq = mark_to_market_equity(cash, &books, &prices);
361        for bar in &group.bars {
362            let sym = &bar.symbol;
363            let book = books.get(sym);
364            let (pos, sym_cash, sym_eq) = match book {
365                Some(b) if b.exposure != 0.0 => {
366                    let pos_val = b.exposure * bar.close;
367                    (b.exposure, cash, cash + pos_val)
368                }
369                _ => (0.0, cash, cash),
370            };
371            per_symbol_equity
372                .entry(sym.clone())
373                .or_default()
374                .push(EquityPoint {
375                    ts,
376                    symbol: Some(sym.clone()),
377                    equity: sym_eq,
378                    cash: sym_cash,
379                    position: pos,
380                    close: bar.close,
381                });
382        }
383        portfolio_curve.push(EquityPoint {
384            ts,
385            symbol: None,
386            equity: total_eq,
387            cash,
388            position: books.values().map(|b| b.position_value()).sum(),
389            close: 0.0,
390        });
391    }
392
393    // Terminal open trades
394    if let Some(last) = groups.last() {
395        let ts = last.ts;
396        for (sym, book) in &books {
397            if book.exposure != 0.0 {
398                let close = last
399                    .bars
400                    .iter()
401                    .find(|b| &b.symbol == sym)
402                    .map(|b| b.close)
403                    .unwrap_or(0.0);
404                let qty = book.exposure.abs();
405                let side = if book.exposure > 0.0 { 1 } else { -1 };
406                let gross = if side == 1 {
407                    (close - book.entry_price) * qty
408                } else {
409                    (book.entry_price - close) * qty
410                };
411                if let Some(ets) = book.entry_ts {
412                    trades.push(Trade {
413                        trade_id: book.trade_id,
414                        symbol: Some(sym.clone()),
415                        side,
416                        entry_ts: ets,
417                        entry_price: book.entry_price,
418                        entry_fill_price: book.entry_price,
419                        exit_ts: None,
420                        exit_price: Some(close),
421                        exit_fill_price: None,
422                        pnl_gross: gross,
423                        costs: 0.0,
424                        pnl_net: gross,
425                        quantity: qty,
426                        entry_metadata: book.entry_metadata.clone(),
427                    });
428                }
429            }
430            let _ = ts;
431        }
432    }
433
434    (trades, per_symbol_equity, portfolio_curve)
435}
436
437impl SymbolBook {
438    fn position_value(&self) -> f64 {
439        self.exposure
440    }
441}
442
443/// Build timestamp groups from a sorted long-format DataFrame.
444pub(crate) fn build_timestamp_groups(
445    signal_vals: &[f64],
446    signal_metas: &[Option<HashMap<String, f64>>],
447    symbols: &[String],
448    timestamps: &[DateTime<Utc>],
449    closes: &[f64],
450    highs: Option<&[f64]>,
451    lows: Option<&[f64]>,
452) -> Vec<TimestampGroup> {
453    let mut groups: Vec<TimestampGroup> = Vec::new();
454    let mut i = 0usize;
455    while i < timestamps.len() {
456        let ts = timestamps[i];
457        let mut bars = Vec::new();
458        while i < timestamps.len() && timestamps[i] == ts {
459            bars.push(SymbolBar {
460                symbol: symbols[i].clone(),
461                close: closes[i],
462                high: highs.and_then(|h| h.get(i).copied()),
463                low: lows.and_then(|l| l.get(i).copied()),
464                raw_signal: signal_vals[i],
465                meta: signal_metas[i].clone(),
466            });
467            i += 1;
468        }
469        bars.sort_by(|a, b| a.symbol.cmp(&b.symbol));
470        groups.push(TimestampGroup { ts, bars });
471    }
472    groups
473}
474
475/// Run shared-capital streaming simulation for batch↔streaming parity.
476pub fn run_shared_capital_streaming_simulation<G>(
477    bars: &[PortfolioBar],
478    mut generator: G,
479    config: BacktestConfig,
480) -> Result<BacktestResult, BacktestError>
481where
482    G: for<'a> Next<&'a PortfolioBar, Output = StrategySignal>,
483{
484    if bars.is_empty() {
485        return Err(BacktestError::InvalidInput("empty bars".into()));
486    }
487    if config.symbol_col.is_none() {
488        return Err(BacktestError::InvalidInput(
489            "shared-capital streaming requires symbol_col".into(),
490        ));
491    }
492
493    let mut groups_map: BTreeMap<DateTime<Utc>, Vec<SymbolBar>> = BTreeMap::new();
494    for bar in bars {
495        let sig = generator.next(bar);
496        groups_map
497            .entry(bar.ts)
498            .or_default()
499            .push(SymbolBar {
500                symbol: bar.symbol.clone(),
501                close: bar.close,
502                high: bar.high,
503                low: bar.low,
504                raw_signal: sig.exposure,
505                meta: sig.metadata.clone(),
506            });
507    }
508    let groups: Vec<TimestampGroup> = groups_map
509        .into_iter()
510        .map(|(ts, mut bars)| {
511            bars.sort_by(|a, b| a.symbol.cmp(&b.symbol));
512            TimestampGroup { ts, bars }
513        })
514        .collect();
515
516    let exec = &config.execution_model;
517    let sizer = &config.position_sizer;
518    let delay = config.execution_delay;
519    let stops = &config.stop_config;
520    let allocator = config.portfolio_allocator;
521
522    let (trades, per_symbol_equity, portfolio_eq) = simulate_shared_capital(
523        &groups,
524        exec,
525        sizer,
526        delay,
527        stops,
528        allocator,
529    );
530
531    crate::BacktestEngine::assemble_shared_capital_result(
532        &config,
533        trades,
534        per_symbol_equity,
535        portfolio_eq,
536    )
537}