1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
17pub enum PortfolioMode {
18 #[default]
20 IndependentBooks,
21 SharedCapital,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
27pub enum PortfolioAllocator {
28 #[default]
30 EqualWeight,
31 SignalWeighted,
33}
34
35#[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
69fn allocate_entry_units(
71 allocator: PortfolioAllocator,
72 raw_desired: f64,
73 price: f64,
74 equity: f64,
75 peer_intents: &[(f64, f64)], 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
117pub(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 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 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(); 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 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 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 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
436pub(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
468pub 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}