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    /// Copy the excursion of every open campaign, so a caller can read values as of a moment before later quotes are recorded.
525    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    /// Finalize and remove campaign excursion state, including final realized
534    /// net P&L as the last observation.
535    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        // Long: (bid 105 - 100) * 2 * 10 = 100.
630        // Short: (100 - ask 91) * 1 * 10 = 90.
631        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}