1use 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#[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(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
113pub(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 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 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(); 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 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 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 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
443pub(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
475pub 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}