1use std::collections::BTreeMap;
4
5use chrono::NaiveDateTime;
6use qs_core::{FillModel, PriceQuote, Side};
7use serde::{Deserialize, Serialize};
8
9use crate::artifacts::OpenPositionSnapshot;
10use crate::currency::{ConversionQuoteBook, ConversionResult, ConversionRoute, RunCurrencyPlan};
11
12#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
15#[serde(default)]
16pub struct EquityPoint {
17 pub ts: NaiveDateTime,
18 pub observation_kind: Option<String>,
20 pub observation_sequence: Option<u64>,
22 pub realized_pnl: Option<f64>,
23 pub cash_balance: Option<f64>,
25 pub unrealized_pnl: Option<f64>,
26 pub equity: Option<f64>,
27 pub drawdown: Option<f64>,
29 pub drawdown_pct: Option<f64>,
30 pub max_drawdown: Option<f64>,
31 pub max_drawdown_pct: Option<f64>,
32 pub gross_exposure: Option<f64>,
33 pub open_risk: Option<f64>,
35 pub open_position_count: usize,
36 pub stale_position_count: usize,
37 pub unpriced_position_count: usize,
38 pub unavailable_open_risk_count: usize,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
43#[serde(default)]
44pub struct CampaignExcursion {
45 pub mae: f64,
46 pub mfe: f64,
47 pub observations: u64,
48}
49
50impl Default for CampaignExcursion {
51 fn default() -> Self {
52 Self {
53 mae: 0.0,
54 mfe: 0.0,
55 observations: 0,
56 }
57 }
58}
59
60impl CampaignExcursion {
61 pub fn observe(&mut self, campaign_pnl: f64) -> bool {
62 if !campaign_pnl.is_finite() {
63 return false;
64 }
65 self.mae = self.mae.min(campaign_pnl);
66 self.mfe = self.mfe.max(campaign_pnl);
67 self.observations += 1;
68 true
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(default)]
75pub struct PortfolioRecorder {
76 initial_balance: f64,
77 realized_pnl: f64,
78 contract_sizes: BTreeMap<String, f64>,
79 default_contract_size: f64,
80 stale_quote_after_millis: Option<i64>,
81 fill_model: FillModel,
82 currency_plan: Option<RunCurrencyPlan>,
83 latest_quotes: BTreeMap<String, PriceQuote>,
84 campaigns: BTreeMap<String, CampaignExcursion>,
85 latest_open_positions: Vec<OpenPositionSnapshot>,
86 equity_curve: Vec<EquityPoint>,
87 peak_equity: Option<f64>,
88 max_drawdown: Option<f64>,
89 max_drawdown_pct: Option<f64>,
90}
91
92impl Default for PortfolioRecorder {
93 fn default() -> Self {
94 Self::new(0.0, BTreeMap::new())
95 }
96}
97
98impl PortfolioRecorder {
99 pub fn new(
102 initial_balance: f64,
103 contract_sizes: impl IntoIterator<Item = (String, f64)>,
104 ) -> Self {
105 let valid_initial_balance = initial_balance.is_finite().then_some(initial_balance);
106 Self {
107 initial_balance,
108 realized_pnl: 0.0,
109 contract_sizes: contract_sizes.into_iter().collect(),
110 default_contract_size: 1.0,
111 stale_quote_after_millis: None,
112 fill_model: FillModel::BidAsk,
113 currency_plan: None,
114 latest_quotes: BTreeMap::new(),
115 campaigns: BTreeMap::new(),
116 latest_open_positions: Vec::new(),
117 equity_curve: Vec::new(),
118 peak_equity: valid_initial_balance,
119 max_drawdown: valid_initial_balance.map(|_| 0.0),
120 max_drawdown_pct: valid_initial_balance
121 .filter(|balance| *balance > 0.0)
122 .map(|_| 0.0),
123 }
124 }
125
126 pub fn with_fill_model(mut self, fill_model: FillModel) -> Self {
127 self.fill_model = fill_model;
128 self
129 }
130
131 pub fn with_stale_quote_after_millis(mut self, stale_after_millis: Option<i64>) -> Self {
132 self.stale_quote_after_millis = stale_after_millis.map(|value| value.max(0));
133 self
134 }
135
136 pub fn with_currency_plan(mut self, currency_plan: Option<RunCurrencyPlan>) -> Self {
137 self.currency_plan = currency_plan;
138 self
139 }
140
141 pub fn set_default_contract_size(&mut self, contract_size: f64) -> bool {
142 if !contract_size.is_finite() || contract_size <= 0.0 {
143 return false;
144 }
145 self.default_contract_size = contract_size;
146 true
147 }
148
149 pub fn set_contract_size(&mut self, symbol: impl Into<String>, contract_size: f64) -> bool {
150 if !contract_size.is_finite() || contract_size <= 0.0 {
151 return false;
152 }
153 self.contract_sizes.insert(symbol.into(), contract_size);
154 true
155 }
156
157 pub fn record_quote(&mut self, quote: PriceQuote) -> bool {
160 if self
161 .latest_quotes
162 .get("e.symbol)
163 .is_some_and(|current| current.ts > quote.ts)
164 {
165 return false;
166 }
167 self.latest_quotes.insert(quote.symbol.clone(), quote);
168 true
169 }
170
171 pub fn quote(&self, symbol: &str) -> Option<&PriceQuote> {
172 self.latest_quotes.get(symbol)
173 }
174
175 pub fn initial_balance(&self) -> f64 {
176 self.initial_balance
177 }
178
179 pub fn realized_pnl(&self) -> f64 {
180 self.realized_pnl
181 }
182
183 pub fn add_realized_pnl(&mut self, pnl: f64) -> bool {
185 if !pnl.is_finite() || !(self.realized_pnl + pnl).is_finite() {
186 return false;
187 }
188 self.realized_pnl += pnl;
189 true
190 }
191
192 pub fn set_realized_pnl(&mut self, pnl: f64) -> bool {
195 if !pnl.is_finite() {
196 return false;
197 }
198 self.realized_pnl = pnl;
199 true
200 }
201
202 pub fn record(
204 &mut self,
205 ts: NaiveDateTime,
206 positions: impl IntoIterator<Item = OpenPositionSnapshot>,
207 ) -> EquityPoint {
208 self.record_with_currency(ts, positions, None)
209 }
210
211 pub fn observe(
212 &mut self,
213 ts: NaiveDateTime,
214 positions: impl IntoIterator<Item = OpenPositionSnapshot>,
215 ) -> EquityPoint {
216 self.observe_with_currency(ts, positions, None)
217 }
218
219 pub fn record_with_currency(
220 &mut self,
221 ts: NaiveDateTime,
222 positions: impl IntoIterator<Item = OpenPositionSnapshot>,
223 conversion_quotes: Option<&ConversionQuoteBook>,
224 ) -> EquityPoint {
225 let point = self.observe_with_currency(ts, positions, conversion_quotes);
226 self.equity_curve.push(point.clone());
227 point
228 }
229
230 pub fn observe_with_currency(
232 &mut self,
233 ts: NaiveDateTime,
234 positions: impl IntoIterator<Item = OpenPositionSnapshot>,
235 conversion_quotes: Option<&ConversionQuoteBook>,
236 ) -> EquityPoint {
237 let mut marked_positions = Vec::new();
238 let mut unrealized_pnl = 0.0;
239 let mut gross_exposure = 0.0;
240 let mut open_risk = 0.0;
241 let mut stale_position_count = 0;
242 let mut unpriced_position_count = 0;
243 let mut unavailable_unrealized_count = 0;
244 let mut unavailable_exposure_count = 0;
245 let mut unavailable_open_risk_count = 0;
246
247 for mut position in positions {
248 position.clear_mark();
249 let (native_currency, account_currency) = self.currency_labels(&position.symbol);
250 position.native_currency = native_currency;
251 position.account_currency = account_currency;
252 let Some((quote, mark_price, contract_size)) = self.pricing_inputs(&position) else {
253 unpriced_position_count += 1;
254 unavailable_unrealized_count += 1;
255 unavailable_exposure_count += 1;
256 unavailable_open_risk_count += 1;
257 marked_positions.push(position);
258 continue;
259 };
260
261 if self.is_stale(quote, ts) {
262 stale_position_count += 1;
263 }
264
265 let native_unrealized = match position.side {
266 Side::Buy => mark_price - position.average_entry_price,
267 Side::Sell => position.average_entry_price - mark_price,
268 } * position.remaining_size
269 * contract_size;
270 let exposure_sign = match position.side {
271 Side::Buy => 1.0,
272 Side::Sell => -1.0,
273 };
274 let native_signed_exposure =
275 exposure_sign * mark_price * position.remaining_size * contract_size;
276
277 if !native_unrealized.is_finite() || !native_signed_exposure.is_finite() {
278 unpriced_position_count += 1;
279 unavailable_unrealized_count += 1;
280 unavailable_exposure_count += 1;
281 unavailable_open_risk_count += 1;
282 marked_positions.push(position);
283 continue;
284 }
285
286 position.quote_ts = Some(quote.ts);
287 position.mark_price = Some(mark_price);
288 position.native_unrealized_pnl = Some(native_unrealized);
289 position.native_signed_exposure = Some(native_signed_exposure);
290
291 let mut valuation_unavailable = false;
292 match self.convert_native_amount(
293 &position.symbol,
294 native_unrealized,
295 ts,
296 conversion_quotes,
297 ) {
298 Some((account_unrealized, conversion)) => {
299 position.unrealized_pnl = Some(account_unrealized);
300 position.unrealized_pnl_conversion = conversion;
301 unrealized_pnl += account_unrealized;
302 }
303 None => {
304 unavailable_unrealized_count += 1;
305 valuation_unavailable = true;
306 }
307 }
308 match self.convert_native_amount(
309 &position.symbol,
310 native_signed_exposure,
311 ts,
312 conversion_quotes,
313 ) {
314 Some((account_signed_exposure, conversion)) => {
315 let account_exposure = account_signed_exposure.abs();
316 if account_exposure.is_finite() {
317 position.gross_exposure = Some(account_exposure);
318 position.gross_exposure_conversion = conversion;
319 gross_exposure += account_exposure;
320 } else {
321 unavailable_exposure_count += 1;
322 valuation_unavailable = true;
323 }
324 }
325 None => {
326 unavailable_exposure_count += 1;
327 valuation_unavailable = true;
328 }
329 }
330 if valuation_unavailable {
331 unpriced_position_count += 1;
332 }
333
334 let native_stop_liability = position.effective_stop.and_then(|stop| {
335 if !stop.price.is_finite() {
336 return None;
337 }
338 let stop_pnl = match position.side {
339 Side::Buy => stop.price - mark_price,
340 Side::Sell => mark_price - stop.price,
341 } * position.remaining_size
342 * contract_size;
343 stop_pnl.is_finite().then_some(stop_pnl.min(0.0))
344 });
345 if let Some(native_liability) = native_stop_liability {
346 position.native_open_risk = Some(-native_liability);
347 match self.convert_native_amount(
348 &position.symbol,
349 native_liability,
350 ts,
351 conversion_quotes,
352 ) {
353 Some((account_liability, conversion)) => {
354 let account_risk = (-account_liability).max(0.0);
355 if account_risk.is_finite() {
356 position.open_risk = Some(account_risk);
357 position.open_risk_conversion = conversion;
358 open_risk += account_risk;
359 } else {
360 unavailable_open_risk_count += 1;
361 }
362 }
363 None => unavailable_open_risk_count += 1,
364 }
365 } else {
366 unavailable_open_risk_count += 1;
367 }
368
369 if let Some(account_unrealized) = position.unrealized_pnl {
370 let campaign_pnl = position.realized_pnl + account_unrealized;
371 let campaign = self
372 .campaigns
373 .entry(position.position_id.clone())
374 .or_default();
375 if campaign.observe(campaign_pnl) {
376 position.campaign_mae = Some(campaign.mae);
377 position.campaign_mfe = Some(campaign.mfe);
378 }
379 }
380
381 marked_positions.push(position);
382 }
383
384 let open_position_count = marked_positions.len();
385 let complete_unrealized = unavailable_unrealized_count == 0;
386 let complete_exposure = unavailable_exposure_count == 0;
387 let realized = self.realized_pnl.is_finite().then_some(self.realized_pnl);
388 let cash_balance = realized.and_then(|pnl| {
389 let balance = self.initial_balance + pnl;
390 balance.is_finite().then_some(balance)
391 });
392 let total_unrealized = complete_unrealized.then_some(unrealized_pnl);
393 let equity = cash_balance
394 .zip(total_unrealized)
395 .and_then(|(cash, floating)| {
396 let value = cash + floating;
397 value.is_finite().then_some(value)
398 });
399 let total_exposure = complete_exposure.then_some(gross_exposure);
400 let total_open_risk = (unavailable_open_risk_count == 0).then_some(open_risk);
401 let (drawdown, drawdown_pct) = self.observe_equity(equity);
402
403 let point = EquityPoint {
404 ts,
405 observation_kind: None,
406 observation_sequence: None,
407 realized_pnl: realized,
408 cash_balance,
409 unrealized_pnl: total_unrealized,
410 equity,
411 drawdown,
412 drawdown_pct,
413 max_drawdown: self.max_drawdown,
414 max_drawdown_pct: self.max_drawdown_pct,
415 gross_exposure: total_exposure,
416 open_risk: total_open_risk,
417 open_position_count,
418 stale_position_count,
419 unpriced_position_count,
420 unavailable_open_risk_count,
421 };
422 self.latest_open_positions = marked_positions;
423 point
424 }
425
426 fn currency_labels(&self, symbol: &str) -> (Option<String>, Option<String>) {
427 let Some(plan) = self.currency_plan.as_ref() else {
428 return (None, None);
429 };
430 (
431 plan.pnl_currency_for_primary_symbol(symbol)
432 .map(str::to_owned),
433 Some(plan.account_currency().to_owned()),
434 )
435 }
436
437 fn convert_native_amount(
438 &self,
439 symbol: &str,
440 amount: f64,
441 operation_ts: NaiveDateTime,
442 conversion_quotes: Option<&ConversionQuoteBook>,
443 ) -> Option<(f64, Option<ConversionResult>)> {
444 if !amount.is_finite() {
445 return None;
446 }
447 let Some(plan) = self.currency_plan.as_ref() else {
448 return Some((amount, None));
449 };
450 let route = plan.route_for_primary_symbol(symbol)?;
451 let conversion = match conversion_quotes {
452 Some(quotes) => quotes.convert_route(amount, operation_ts, route).ok()?,
453 None => match route {
454 ConversionRoute::Identity { .. } => ConversionResult {
455 from_currency: route.from_currency().to_owned(),
456 to_currency: route.to_currency().to_owned(),
457 input_amount: amount,
458 output_amount: amount,
459 operation_ts,
460 route: route.clone(),
461 legs: Vec::new(),
462 },
463 _ => return None,
464 },
465 };
466 conversion
467 .output_amount
468 .is_finite()
469 .then_some((conversion.output_amount, Some(conversion)))
470 }
471
472 fn pricing_inputs<'a>(
473 &'a self,
474 position: &OpenPositionSnapshot,
475 ) -> Option<(&'a PriceQuote, f64, f64)> {
476 if !position.average_entry_price.is_finite()
477 || !position.remaining_size.is_finite()
478 || position.remaining_size < 0.0
479 {
480 return None;
481 }
482 let quote = self.latest_quotes.get(&position.symbol)?;
483 let mark_price = quote.eval_price(position.side, self.fill_model);
484 let contract_size = self
485 .contract_sizes
486 .get(&position.symbol)
487 .copied()
488 .unwrap_or(self.default_contract_size);
489 if !mark_price.is_finite() || !contract_size.is_finite() || contract_size <= 0.0 {
490 return None;
491 }
492 Some((quote, mark_price, contract_size))
493 }
494
495 fn is_stale(&self, quote: &PriceQuote, ts: NaiveDateTime) -> bool {
496 let Some(limit) = self.stale_quote_after_millis else {
497 return false;
498 };
499 let age = ts.signed_duration_since(quote.ts).num_milliseconds();
500 age > limit
501 }
502
503 fn observe_equity(&mut self, equity: Option<f64>) -> (Option<f64>, Option<f64>) {
504 let Some(equity) = equity else {
505 return (None, None);
506 };
507 let peak = match self.peak_equity {
508 Some(peak) if peak >= equity => peak,
509 _ => {
510 self.peak_equity = Some(equity);
511 equity
512 }
513 };
514 let drawdown = (peak - equity).max(0.0);
515 self.max_drawdown = Some(self.max_drawdown.unwrap_or(0.0).max(drawdown));
516
517 let drawdown_pct = (peak > 0.0).then_some(drawdown / peak);
518 if let Some(value) = drawdown_pct {
519 self.max_drawdown_pct = Some(self.max_drawdown_pct.unwrap_or(0.0).max(value));
520 }
521 (Some(drawdown), drawdown_pct)
522 }
523
524 pub(crate) fn open_campaign_excursions(&self) -> BTreeMap<String, CampaignExcursion> {
526 self.campaigns.clone()
527 }
528
529 pub fn campaign_excursion(&self, position_id: &str) -> Option<CampaignExcursion> {
530 self.campaigns.get(position_id).copied()
531 }
532
533 pub fn finish_campaign(
536 &mut self,
537 position_id: &str,
538 final_net_pnl: f64,
539 ) -> Option<CampaignExcursion> {
540 let mut campaign = self.campaigns.remove(position_id).unwrap_or_default();
541 campaign.observe(final_net_pnl).then_some(campaign)
542 }
543
544 pub fn latest_open_positions(&self) -> &[OpenPositionSnapshot] {
545 &self.latest_open_positions
546 }
547
548 pub fn equity_curve(&self) -> &[EquityPoint] {
549 &self.equity_curve
550 }
551
552 pub fn max_drawdown(&self) -> Option<f64> {
553 self.max_drawdown
554 }
555
556 pub fn max_drawdown_pct(&self) -> Option<f64> {
557 self.max_drawdown_pct
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use chrono::{Duration, NaiveDate};
565 use qs_core::{EffectiveStop, StopOrigin};
566
567 use crate::currency::{ConversionPriceSide, FxPair};
568
569 fn ts(second: u32) -> NaiveDateTime {
570 NaiveDate::from_ymd_opt(2026, 3, 4)
571 .unwrap()
572 .and_hms_opt(5, 6, second)
573 .unwrap()
574 }
575
576 fn quote(symbol: &str, second: u32, bid: f64, ask: f64) -> PriceQuote {
577 PriceQuote {
578 symbol: symbol.into(),
579 ts: ts(second),
580 bid,
581 ask,
582 }
583 }
584
585 fn position(id: &str, symbol: &str, side: Side, entry: f64, size: f64) -> OpenPositionSnapshot {
586 OpenPositionSnapshot::new(id, symbol, side, entry, size)
587 }
588
589 fn eur_account_plan() -> RunCurrencyPlan {
590 RunCurrencyPlan::new(
591 "USD",
592 ["S".to_owned()].into_iter().collect(),
593 ["EURUSD".to_owned()].into_iter().collect(),
594 [("S".to_owned(), "EUR".to_owned())].into_iter().collect(),
595 [(
596 "EUR".to_owned(),
597 ConversionRoute::Direct {
598 pair: FxPair {
599 symbol: "EURUSD".to_owned(),
600 base_currency: "EUR".to_owned(),
601 quote_currency: "USD".to_owned(),
602 },
603 },
604 )]
605 .into_iter()
606 .collect(),
607 Vec::new(),
608 )
609 .unwrap()
610 }
611
612 #[test]
613 fn marks_longs_at_bid_and_shorts_at_ask_with_contract_sizes() {
614 let mut recorder = PortfolioRecorder::new(
615 1_000.0,
616 [("LONG".to_owned(), 10.0), ("SHORT".to_owned(), 10.0)],
617 );
618 recorder.record_quote(quote("LONG", 0, 105.0, 106.0));
619 recorder.record_quote(quote("SHORT", 0, 90.0, 91.0));
620
621 let point = recorder.record(
622 ts(0),
623 [
624 position("long", "LONG", Side::Buy, 100.0, 2.0),
625 position("short", "SHORT", Side::Sell, 100.0, 1.0),
626 ],
627 );
628
629 assert_eq!(point.unrealized_pnl, Some(190.0));
632 assert_eq!(point.equity, Some(1_190.0));
633 let marked = recorder.latest_open_positions();
634 assert_eq!(marked[0].mark_price, Some(105.0));
635 assert_eq!(marked[1].mark_price, Some(91.0));
636 }
637
638 #[test]
639 fn converts_signed_marks_and_stop_liability_before_aggregation() {
640 let plan = eur_account_plan();
641 let mut conversions = ConversionQuoteBook::new(Duration::hours(1)).unwrap();
642 conversions
643 .record_canonical_tick(quote("EURUSD", 0, 2.0, 3.0))
644 .unwrap();
645 let mut recorder =
646 PortfolioRecorder::new(1_000.0, BTreeMap::new()).with_currency_plan(Some(plan));
647 recorder.record_quote(quote("S", 0, 100.0, 100.0));
648 let mut open = position("p", "S", Side::Buy, 110.0, 1.0);
649 open.effective_stop = Some(EffectiveStop::new(90.0, StopOrigin::Initial));
650
651 let point = recorder.record_with_currency(ts(0), [open], Some(&conversions));
652
653 assert_eq!(point.unrealized_pnl, Some(-30.0));
654 assert_eq!(point.gross_exposure, Some(200.0));
655 assert_eq!(point.open_risk, Some(30.0));
656 assert_eq!(point.equity, Some(970.0));
657 let marked = &recorder.latest_open_positions()[0];
658 assert_eq!(marked.native_unrealized_pnl, Some(-10.0));
659 assert_eq!(marked.native_signed_exposure, Some(100.0));
660 assert_eq!(marked.native_open_risk, Some(10.0));
661 assert_eq!(marked.native_currency.as_deref(), Some("EUR"));
662 assert_eq!(marked.account_currency.as_deref(), Some("USD"));
663 assert_eq!(
664 marked.unrealized_pnl_conversion.as_ref().unwrap().legs[0].price_side,
665 ConversionPriceSide::Ask
666 );
667 assert_eq!(
668 marked.gross_exposure_conversion.as_ref().unwrap().legs[0].price_side,
669 ConversionPriceSide::Bid
670 );
671 assert_eq!(
672 marked.open_risk_conversion.as_ref().unwrap().legs[0].price_side,
673 ConversionPriceSide::Ask
674 );
675 }
676
677 #[test]
678 fn missing_conversion_retains_native_marks_and_unavailable_aggregates() {
679 let plan = eur_account_plan();
680 let conversions = ConversionQuoteBook::new(Duration::hours(1)).unwrap();
681 let mut recorder =
682 PortfolioRecorder::new(1_000.0, BTreeMap::new()).with_currency_plan(Some(plan));
683 recorder.record_quote(quote("S", 0, 100.0, 100.0));
684 let mut open = position("p", "S", Side::Buy, 110.0, 1.0);
685 open.effective_stop = Some(EffectiveStop::new(90.0, StopOrigin::Initial));
686
687 let point = recorder.record_with_currency(ts(0), [open], Some(&conversions));
688
689 assert_eq!(point.cash_balance, Some(1_000.0));
690 assert_eq!(point.unrealized_pnl, None);
691 assert_eq!(point.equity, None);
692 assert_eq!(point.gross_exposure, None);
693 assert_eq!(point.open_risk, None);
694 assert_eq!(point.unpriced_position_count, 1);
695 assert_eq!(point.unavailable_open_risk_count, 1);
696 let marked = &recorder.latest_open_positions()[0];
697 assert_eq!(marked.native_unrealized_pnl, Some(-10.0));
698 assert_eq!(marked.native_signed_exposure, Some(100.0));
699 assert_eq!(marked.native_open_risk, Some(10.0));
700 assert_eq!(marked.unrealized_pnl, None);
701 assert_eq!(marked.gross_exposure, None);
702 assert_eq!(marked.open_risk, None);
703 }
704
705 #[test]
706 fn combines_realized_and_unrealized_equity() {
707 let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new());
708 assert!(recorder.add_realized_pnl(25.0));
709 recorder.record_quote(quote("S", 0, 12.0, 13.0));
710 let point = recorder.record(ts(0), [position("p", "S", Side::Buy, 10.0, 2.0)]);
711
712 assert_eq!(point.realized_pnl, Some(25.0));
713 assert_eq!(point.cash_balance, Some(1_025.0));
714 assert_eq!(point.unrealized_pnl, Some(4.0));
715 assert_eq!(point.equity, Some(1_029.0));
716 }
717
718 #[test]
719 fn missing_quote_makes_aggregate_mark_values_unavailable() {
720 let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new());
721 recorder.record_quote(quote("PRICED", 0, 11.0, 12.0));
722 let point = recorder.record(
723 ts(0),
724 [
725 position("p1", "PRICED", Side::Buy, 10.0, 1.0),
726 position("p2", "MISSING", Side::Buy, 10.0, 1.0),
727 ],
728 );
729
730 assert_eq!(point.cash_balance, Some(1_000.0));
731 assert_eq!(point.unrealized_pnl, None);
732 assert_eq!(point.equity, None);
733 assert_eq!(point.gross_exposure, None);
734 assert_eq!(point.drawdown, None);
735 assert_eq!(point.unpriced_position_count, 1);
736 assert_eq!(recorder.latest_open_positions()[1].mark_price, None);
737 }
738
739 #[test]
740 fn stale_quotes_are_counted_but_still_marked() {
741 let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new())
742 .with_stale_quote_after_millis(Some(1_000));
743 recorder.record_quote(quote("S", 0, 11.0, 12.0));
744 let point = recorder.record(ts(2), [position("p", "S", Side::Buy, 10.0, 1.0)]);
745
746 assert_eq!(point.stale_position_count, 1);
747 assert_eq!(point.unpriced_position_count, 0);
748 assert_eq!(point.equity, Some(1_001.0));
749 }
750
751 #[test]
752 fn online_drawdown_uses_initial_balance_and_prior_peaks() {
753 let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new());
754 recorder.record_quote(quote("S", 0, 1_010.0, 1_011.0));
755 let high = recorder.record(ts(0), [position("p", "S", Side::Buy, 1_000.0, 1.0)]);
756 assert_eq!(high.equity, Some(1_010.0));
757 assert_eq!(high.drawdown, Some(0.0));
758
759 recorder.record_quote(quote("S", 1, 990.0, 991.0));
760 let low = recorder.record(ts(1), [position("p", "S", Side::Buy, 1_000.0, 1.0)]);
761 assert_eq!(low.equity, Some(990.0));
762 assert_eq!(low.drawdown, Some(20.0));
763 assert_eq!(low.max_drawdown, Some(20.0));
764 assert!((low.drawdown_pct.unwrap() - 20.0 / 1_010.0).abs() < 1.0e-12);
765 assert_eq!(recorder.max_drawdown(), Some(20.0));
766 }
767
768 #[test]
769 fn tracks_campaign_mae_mfe_across_marks_and_partial_realization() {
770 let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new());
771 recorder.record_quote(quote("S", 0, 95.0, 96.0));
772 recorder.record(ts(0), [position("p", "S", Side::Buy, 100.0, 1.0)]);
773 assert_eq!(recorder.campaign_excursion("p").unwrap().mae, -5.0);
774
775 recorder.record_quote(quote("S", 1, 110.0, 111.0));
776 let mut partially_closed = position("p", "S", Side::Buy, 100.0, 1.0);
777 partially_closed.realized_pnl = 10.0;
778 recorder.record(ts(1), [partially_closed]);
779 let campaign = recorder.campaign_excursion("p").unwrap();
780 assert_eq!(campaign.mae, -5.0);
781 assert_eq!(campaign.mfe, 20.0);
782 assert_eq!(campaign.observations, 2);
783
784 let finished = recorder.finish_campaign("p", -8.0).unwrap();
785 assert_eq!(finished.mae, -8.0);
786 assert_eq!(finished.mfe, 20.0);
787 assert!(recorder.campaign_excursion("p").is_none());
788 }
789
790 #[test]
791 fn computes_concurrent_exposure_and_open_risk_to_effective_stops() {
792 let mut recorder = PortfolioRecorder::new(1_000.0, [("S".to_owned(), 10.0)]);
793 recorder.record_quote(quote("S", 0, 105.0, 106.0));
794 let mut open = position("p", "S", Side::Buy, 100.0, 2.0);
795 open.effective_stop = Some(EffectiveStop::new(95.0, StopOrigin::Initial));
796
797 let point = recorder.record(ts(0), [open]);
798 assert_eq!(point.gross_exposure, Some(2_100.0));
799 assert_eq!(point.open_risk, Some(200.0));
800 assert_eq!(point.unavailable_open_risk_count, 0);
801 }
802
803 #[test]
804 fn missing_stop_makes_aggregate_open_risk_explicitly_unavailable() {
805 let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new());
806 recorder.record_quote(quote("S", 0, 105.0, 106.0));
807 let point = recorder.record(ts(0), [position("p", "S", Side::Buy, 100.0, 1.0)]);
808
809 assert_eq!(point.open_risk, None);
810 assert_eq!(point.unavailable_open_risk_count, 1);
811 assert_eq!(point.gross_exposure, Some(105.0));
812 }
813
814 #[test]
815 fn ignores_older_quotes_and_rejects_non_finite_account_updates() {
816 let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new());
817 assert!(recorder.record_quote(quote("S", 2, 12.0, 13.0)));
818 assert!(!recorder.record_quote(quote("S", 1, 99.0, 100.0)));
819 assert_eq!(recorder.quote("S").unwrap().bid, 12.0);
820 assert!(!recorder.add_realized_pnl(f64::NAN));
821 assert!(!recorder.set_contract_size("S", 0.0));
822 }
823
824 #[test]
825 fn observe_updates_exact_state_without_appending_to_the_curve() {
826 let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new());
827 recorder.record_quote(quote("S", 0, 90.0, 91.0));
828
829 let observed = recorder.observe(ts(0), [position("p", "S", Side::Buy, 100.0, 1.0)]);
830
831 assert_eq!(observed.equity, Some(990.0));
832 assert_eq!(observed.drawdown, Some(10.0));
833 assert_eq!(recorder.max_drawdown(), Some(10.0));
834 assert_eq!(recorder.latest_open_positions()[0].mark_price, Some(90.0));
835 assert_eq!(recorder.campaign_excursion("p").unwrap().mae, -10.0);
836 assert!(recorder.equity_curve().is_empty());
837
838 recorder.record_quote(quote("S", 1, 95.0, 96.0));
839 recorder.record(ts(1), [position("p", "S", Side::Buy, 100.0, 1.0)]);
840 assert_eq!(recorder.equity_curve().len(), 1);
841 assert_eq!(recorder.campaign_excursion("p").unwrap().observations, 2);
842 }
843
844 #[test]
845 fn equity_point_serde_defaults_keep_old_payloads_readable() {
846 let point: EquityPoint = serde_json::from_str(r#"{"ts":"2026-03-04T05:06:00"}"#).unwrap();
847 assert_eq!(point.observation_kind, None);
848 assert_eq!(point.observation_sequence, None);
849 assert_eq!(point.equity, None);
850 assert_eq!(point.stale_position_count, 0);
851 assert_eq!(point.unpriced_position_count, 0);
852 }
853}