Skip to main content

qs_backtest/
portfolio.rs

1//! Quote-aware portfolio snapshots and online account accounting.
2
3use 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/// One account observation. Aggregate values are `None` when they cannot be
13/// computed completely (for example, because any open position is unpriced).
14#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
15#[serde(default)]
16pub struct EquityPoint {
17    pub ts: NaiveDateTime,
18    /// Runner-defined observation stage for consumers that need event context.
19    pub observation_kind: Option<String>,
20    /// Stable observation order before any output sampling is applied.
21    pub observation_sequence: Option<u64>,
22    pub realized_pnl: Option<f64>,
23    /// Initial balance plus realized P&L.
24    pub cash_balance: Option<f64>,
25    pub unrealized_pnl: Option<f64>,
26    pub equity: Option<f64>,
27    /// Current peak-to-equity drawdown, represented as a non-negative amount.
28    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    /// Loss from the current liquidation mark to effective protective stops.
34    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/// Campaign P&L extrema, including a zero baseline before the first mark.
42#[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/// Stateful quote and account recorder suitable for incremental runner use.
73#[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    /// Create a recorder. Missing symbols use contract size `1.0`, matching the
100    /// current backtest executor's backward-compatible behavior.
101    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    /// Retain the newest quote for a symbol. Returns `false` for an out-of-order
158    /// older quote that was ignored.
159    pub fn record_quote(&mut self, quote: PriceQuote) -> bool {
160        if self
161            .latest_quotes
162            .get(&quote.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    /// Add a realized close result. Non-finite values are rejected.
184    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    /// Replace the cumulative realized P&L (useful when adapting an executor
193    /// that already owns the authoritative realized total).
194    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    /// Mark normalized open positions at the latest per-symbol quotes and append one equity point.
203    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    /// Mark positions and update exact accounting state without appending to the legacy curve.
231    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 fn campaign_excursion(&self, position_id: &str) -> Option<CampaignExcursion> {
525        self.campaigns.get(position_id).copied()
526    }
527
528    /// Finalize and remove campaign excursion state, including final realized
529    /// net P&L as the last observation.
530    pub fn finish_campaign(
531        &mut self,
532        position_id: &str,
533        final_net_pnl: f64,
534    ) -> Option<CampaignExcursion> {
535        let mut campaign = self.campaigns.remove(position_id).unwrap_or_default();
536        campaign.observe(final_net_pnl).then_some(campaign)
537    }
538
539    pub fn latest_open_positions(&self) -> &[OpenPositionSnapshot] {
540        &self.latest_open_positions
541    }
542
543    pub fn equity_curve(&self) -> &[EquityPoint] {
544        &self.equity_curve
545    }
546
547    pub fn max_drawdown(&self) -> Option<f64> {
548        self.max_drawdown
549    }
550
551    pub fn max_drawdown_pct(&self) -> Option<f64> {
552        self.max_drawdown_pct
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use chrono::{Duration, NaiveDate};
560    use qs_core::{EffectiveStop, StopOrigin};
561
562    use crate::currency::{ConversionPriceSide, FxPair};
563
564    fn ts(second: u32) -> NaiveDateTime {
565        NaiveDate::from_ymd_opt(2026, 3, 4)
566            .unwrap()
567            .and_hms_opt(5, 6, second)
568            .unwrap()
569    }
570
571    fn quote(symbol: &str, second: u32, bid: f64, ask: f64) -> PriceQuote {
572        PriceQuote {
573            symbol: symbol.into(),
574            ts: ts(second),
575            bid,
576            ask,
577        }
578    }
579
580    fn position(id: &str, symbol: &str, side: Side, entry: f64, size: f64) -> OpenPositionSnapshot {
581        OpenPositionSnapshot::new(id, symbol, side, entry, size)
582    }
583
584    fn eur_account_plan() -> RunCurrencyPlan {
585        RunCurrencyPlan::new(
586            "USD",
587            ["S".to_owned()].into_iter().collect(),
588            ["EURUSD".to_owned()].into_iter().collect(),
589            [("S".to_owned(), "EUR".to_owned())].into_iter().collect(),
590            [(
591                "EUR".to_owned(),
592                ConversionRoute::Direct {
593                    pair: FxPair {
594                        symbol: "EURUSD".to_owned(),
595                        base_currency: "EUR".to_owned(),
596                        quote_currency: "USD".to_owned(),
597                    },
598                },
599            )]
600            .into_iter()
601            .collect(),
602            Vec::new(),
603        )
604        .unwrap()
605    }
606
607    #[test]
608    fn marks_longs_at_bid_and_shorts_at_ask_with_contract_sizes() {
609        let mut recorder = PortfolioRecorder::new(
610            1_000.0,
611            [("LONG".to_owned(), 10.0), ("SHORT".to_owned(), 10.0)],
612        );
613        recorder.record_quote(quote("LONG", 0, 105.0, 106.0));
614        recorder.record_quote(quote("SHORT", 0, 90.0, 91.0));
615
616        let point = recorder.record(
617            ts(0),
618            [
619                position("long", "LONG", Side::Buy, 100.0, 2.0),
620                position("short", "SHORT", Side::Sell, 100.0, 1.0),
621            ],
622        );
623
624        // Long: (bid 105 - 100) * 2 * 10 = 100.
625        // Short: (100 - ask 91) * 1 * 10 = 90.
626        assert_eq!(point.unrealized_pnl, Some(190.0));
627        assert_eq!(point.equity, Some(1_190.0));
628        let marked = recorder.latest_open_positions();
629        assert_eq!(marked[0].mark_price, Some(105.0));
630        assert_eq!(marked[1].mark_price, Some(91.0));
631    }
632
633    #[test]
634    fn converts_signed_marks_and_stop_liability_before_aggregation() {
635        let plan = eur_account_plan();
636        let mut conversions = ConversionQuoteBook::new(Duration::hours(1)).unwrap();
637        conversions
638            .record_canonical_tick(quote("EURUSD", 0, 2.0, 3.0))
639            .unwrap();
640        let mut recorder =
641            PortfolioRecorder::new(1_000.0, BTreeMap::new()).with_currency_plan(Some(plan));
642        recorder.record_quote(quote("S", 0, 100.0, 100.0));
643        let mut open = position("p", "S", Side::Buy, 110.0, 1.0);
644        open.effective_stop = Some(EffectiveStop::new(90.0, StopOrigin::Initial));
645
646        let point = recorder.record_with_currency(ts(0), [open], Some(&conversions));
647
648        assert_eq!(point.unrealized_pnl, Some(-30.0));
649        assert_eq!(point.gross_exposure, Some(200.0));
650        assert_eq!(point.open_risk, Some(30.0));
651        assert_eq!(point.equity, Some(970.0));
652        let marked = &recorder.latest_open_positions()[0];
653        assert_eq!(marked.native_unrealized_pnl, Some(-10.0));
654        assert_eq!(marked.native_signed_exposure, Some(100.0));
655        assert_eq!(marked.native_open_risk, Some(10.0));
656        assert_eq!(marked.native_currency.as_deref(), Some("EUR"));
657        assert_eq!(marked.account_currency.as_deref(), Some("USD"));
658        assert_eq!(
659            marked.unrealized_pnl_conversion.as_ref().unwrap().legs[0].price_side,
660            ConversionPriceSide::Ask
661        );
662        assert_eq!(
663            marked.gross_exposure_conversion.as_ref().unwrap().legs[0].price_side,
664            ConversionPriceSide::Bid
665        );
666        assert_eq!(
667            marked.open_risk_conversion.as_ref().unwrap().legs[0].price_side,
668            ConversionPriceSide::Ask
669        );
670    }
671
672    #[test]
673    fn missing_conversion_retains_native_marks_and_unavailable_aggregates() {
674        let plan = eur_account_plan();
675        let conversions = ConversionQuoteBook::new(Duration::hours(1)).unwrap();
676        let mut recorder =
677            PortfolioRecorder::new(1_000.0, BTreeMap::new()).with_currency_plan(Some(plan));
678        recorder.record_quote(quote("S", 0, 100.0, 100.0));
679        let mut open = position("p", "S", Side::Buy, 110.0, 1.0);
680        open.effective_stop = Some(EffectiveStop::new(90.0, StopOrigin::Initial));
681
682        let point = recorder.record_with_currency(ts(0), [open], Some(&conversions));
683
684        assert_eq!(point.cash_balance, Some(1_000.0));
685        assert_eq!(point.unrealized_pnl, None);
686        assert_eq!(point.equity, None);
687        assert_eq!(point.gross_exposure, None);
688        assert_eq!(point.open_risk, None);
689        assert_eq!(point.unpriced_position_count, 1);
690        assert_eq!(point.unavailable_open_risk_count, 1);
691        let marked = &recorder.latest_open_positions()[0];
692        assert_eq!(marked.native_unrealized_pnl, Some(-10.0));
693        assert_eq!(marked.native_signed_exposure, Some(100.0));
694        assert_eq!(marked.native_open_risk, Some(10.0));
695        assert_eq!(marked.unrealized_pnl, None);
696        assert_eq!(marked.gross_exposure, None);
697        assert_eq!(marked.open_risk, None);
698    }
699
700    #[test]
701    fn combines_realized_and_unrealized_equity() {
702        let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new());
703        assert!(recorder.add_realized_pnl(25.0));
704        recorder.record_quote(quote("S", 0, 12.0, 13.0));
705        let point = recorder.record(ts(0), [position("p", "S", Side::Buy, 10.0, 2.0)]);
706
707        assert_eq!(point.realized_pnl, Some(25.0));
708        assert_eq!(point.cash_balance, Some(1_025.0));
709        assert_eq!(point.unrealized_pnl, Some(4.0));
710        assert_eq!(point.equity, Some(1_029.0));
711    }
712
713    #[test]
714    fn missing_quote_makes_aggregate_mark_values_unavailable() {
715        let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new());
716        recorder.record_quote(quote("PRICED", 0, 11.0, 12.0));
717        let point = recorder.record(
718            ts(0),
719            [
720                position("p1", "PRICED", Side::Buy, 10.0, 1.0),
721                position("p2", "MISSING", Side::Buy, 10.0, 1.0),
722            ],
723        );
724
725        assert_eq!(point.cash_balance, Some(1_000.0));
726        assert_eq!(point.unrealized_pnl, None);
727        assert_eq!(point.equity, None);
728        assert_eq!(point.gross_exposure, None);
729        assert_eq!(point.drawdown, None);
730        assert_eq!(point.unpriced_position_count, 1);
731        assert_eq!(recorder.latest_open_positions()[1].mark_price, None);
732    }
733
734    #[test]
735    fn stale_quotes_are_counted_but_still_marked() {
736        let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new())
737            .with_stale_quote_after_millis(Some(1_000));
738        recorder.record_quote(quote("S", 0, 11.0, 12.0));
739        let point = recorder.record(ts(2), [position("p", "S", Side::Buy, 10.0, 1.0)]);
740
741        assert_eq!(point.stale_position_count, 1);
742        assert_eq!(point.unpriced_position_count, 0);
743        assert_eq!(point.equity, Some(1_001.0));
744    }
745
746    #[test]
747    fn online_drawdown_uses_initial_balance_and_prior_peaks() {
748        let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new());
749        recorder.record_quote(quote("S", 0, 1_010.0, 1_011.0));
750        let high = recorder.record(ts(0), [position("p", "S", Side::Buy, 1_000.0, 1.0)]);
751        assert_eq!(high.equity, Some(1_010.0));
752        assert_eq!(high.drawdown, Some(0.0));
753
754        recorder.record_quote(quote("S", 1, 990.0, 991.0));
755        let low = recorder.record(ts(1), [position("p", "S", Side::Buy, 1_000.0, 1.0)]);
756        assert_eq!(low.equity, Some(990.0));
757        assert_eq!(low.drawdown, Some(20.0));
758        assert_eq!(low.max_drawdown, Some(20.0));
759        assert!((low.drawdown_pct.unwrap() - 20.0 / 1_010.0).abs() < 1.0e-12);
760        assert_eq!(recorder.max_drawdown(), Some(20.0));
761    }
762
763    #[test]
764    fn tracks_campaign_mae_mfe_across_marks_and_partial_realization() {
765        let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new());
766        recorder.record_quote(quote("S", 0, 95.0, 96.0));
767        recorder.record(ts(0), [position("p", "S", Side::Buy, 100.0, 1.0)]);
768        assert_eq!(recorder.campaign_excursion("p").unwrap().mae, -5.0);
769
770        recorder.record_quote(quote("S", 1, 110.0, 111.0));
771        let mut partially_closed = position("p", "S", Side::Buy, 100.0, 1.0);
772        partially_closed.realized_pnl = 10.0;
773        recorder.record(ts(1), [partially_closed]);
774        let campaign = recorder.campaign_excursion("p").unwrap();
775        assert_eq!(campaign.mae, -5.0);
776        assert_eq!(campaign.mfe, 20.0);
777        assert_eq!(campaign.observations, 2);
778
779        let finished = recorder.finish_campaign("p", -8.0).unwrap();
780        assert_eq!(finished.mae, -8.0);
781        assert_eq!(finished.mfe, 20.0);
782        assert!(recorder.campaign_excursion("p").is_none());
783    }
784
785    #[test]
786    fn computes_concurrent_exposure_and_open_risk_to_effective_stops() {
787        let mut recorder = PortfolioRecorder::new(1_000.0, [("S".to_owned(), 10.0)]);
788        recorder.record_quote(quote("S", 0, 105.0, 106.0));
789        let mut open = position("p", "S", Side::Buy, 100.0, 2.0);
790        open.effective_stop = Some(EffectiveStop::new(95.0, StopOrigin::Initial));
791
792        let point = recorder.record(ts(0), [open]);
793        assert_eq!(point.gross_exposure, Some(2_100.0));
794        assert_eq!(point.open_risk, Some(200.0));
795        assert_eq!(point.unavailable_open_risk_count, 0);
796    }
797
798    #[test]
799    fn missing_stop_makes_aggregate_open_risk_explicitly_unavailable() {
800        let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new());
801        recorder.record_quote(quote("S", 0, 105.0, 106.0));
802        let point = recorder.record(ts(0), [position("p", "S", Side::Buy, 100.0, 1.0)]);
803
804        assert_eq!(point.open_risk, None);
805        assert_eq!(point.unavailable_open_risk_count, 1);
806        assert_eq!(point.gross_exposure, Some(105.0));
807    }
808
809    #[test]
810    fn ignores_older_quotes_and_rejects_non_finite_account_updates() {
811        let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new());
812        assert!(recorder.record_quote(quote("S", 2, 12.0, 13.0)));
813        assert!(!recorder.record_quote(quote("S", 1, 99.0, 100.0)));
814        assert_eq!(recorder.quote("S").unwrap().bid, 12.0);
815        assert!(!recorder.add_realized_pnl(f64::NAN));
816        assert!(!recorder.set_contract_size("S", 0.0));
817    }
818
819    #[test]
820    fn observe_updates_exact_state_without_appending_to_the_curve() {
821        let mut recorder = PortfolioRecorder::new(1_000.0, BTreeMap::new());
822        recorder.record_quote(quote("S", 0, 90.0, 91.0));
823
824        let observed = recorder.observe(ts(0), [position("p", "S", Side::Buy, 100.0, 1.0)]);
825
826        assert_eq!(observed.equity, Some(990.0));
827        assert_eq!(observed.drawdown, Some(10.0));
828        assert_eq!(recorder.max_drawdown(), Some(10.0));
829        assert_eq!(recorder.latest_open_positions()[0].mark_price, Some(90.0));
830        assert_eq!(recorder.campaign_excursion("p").unwrap().mae, -10.0);
831        assert!(recorder.equity_curve().is_empty());
832
833        recorder.record_quote(quote("S", 1, 95.0, 96.0));
834        recorder.record(ts(1), [position("p", "S", Side::Buy, 100.0, 1.0)]);
835        assert_eq!(recorder.equity_curve().len(), 1);
836        assert_eq!(recorder.campaign_excursion("p").unwrap().observations, 2);
837    }
838
839    #[test]
840    fn equity_point_serde_defaults_keep_old_payloads_readable() {
841        let point: EquityPoint = serde_json::from_str(r#"{"ts":"2026-03-04T05:06:00"}"#).unwrap();
842        assert_eq!(point.observation_kind, None);
843        assert_eq!(point.observation_sequence, None);
844        assert_eq!(point.equity, None);
845        assert_eq!(point.stale_position_count, 0);
846        assert_eq!(point.unpriced_position_count, 0);
847    }
848}