Skip to main content

bot_engine/
performance_metrics.rs

1//! Shared performance metrics for backtests and upstream sync payloads.
2
3use bot_core::{now_ms, InstrumentId, OrderFilledEvent, OrderSide, Position};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6
7const YEAR_MS: f64 = 365.0 * 24.0 * 60.0 * 60.0 * 1000.0;
8
9/// Equity curve point emitted by backtests.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct BacktestEquityPoint {
12    /// Timestamp in milliseconds.
13    pub ts_ms: i64,
14    /// Account equity serialized as a decimal string.
15    pub equity: String,
16    /// Net PnL serialized as a decimal string.
17    pub net_pnl: String,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    /// Optional reference price at this point.
20    pub price: Option<String>,
21}
22
23/// Closed trade reconstructed from fill history.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct BacktestClosedTrade {
26    /// Entry timestamp in milliseconds.
27    pub entry_ts_ms: i64,
28    /// Exit timestamp in milliseconds.
29    pub exit_ts_ms: i64,
30    /// Trade direction.
31    pub side: String,
32    /// Closed quantity as a decimal string.
33    pub qty: String,
34    /// Entry price as a decimal string.
35    pub entry_price: String,
36    /// Exit price as a decimal string.
37    pub exit_price: String,
38    /// Gross PnL before fees as a decimal string.
39    pub gross_pnl: String,
40    /// Fees as a decimal string.
41    pub fees: String,
42    /// Net PnL after fees as a decimal string.
43    pub net_pnl: String,
44}
45
46/// Benchmark context for a performance snapshot.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct PerformanceBenchmark {
49    /// First equity point timestamp.
50    pub start_ts_ms: Option<i64>,
51    /// Last equity point timestamp.
52    pub end_ts_ms: Option<i64>,
53    /// Duration between first and last equity points.
54    pub duration_ms: Option<i64>,
55    /// Number of quote updates observed.
56    pub quote_count: usize,
57    /// Starting balance as a decimal string.
58    pub starting_balance_usdc: Option<String>,
59    /// Ending balance as a decimal string.
60    pub ending_balance_usdc: Option<String>,
61    /// Instrument scope for the run.
62    pub instrument: Option<String>,
63}
64
65/// Calculated performance metrics.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct PerformanceMetrics {
68    /// Return over the measured period.
69    pub period_return_pct: Option<f64>,
70    /// Annualized percentage return.
71    pub apr_pct: Option<f64>,
72    /// Simplified Sharpe ratio from equity returns.
73    pub sharpe: Option<f64>,
74    /// Maximum drawdown as a percentage.
75    pub max_drawdown_pct: Option<f64>,
76    /// Maximum drawdown in USDC as a decimal string.
77    pub max_drawdown_usdc: String,
78    /// Percentage of closed trades with positive net PnL.
79    pub win_rate_pct: Option<f64>,
80    /// Number of reconstructed closed trades.
81    pub closed_trade_count: usize,
82    /// Number of winning closed trades.
83    pub winning_trade_count: usize,
84    /// Number of losing closed trades.
85    pub losing_trade_count: usize,
86    /// Number of fills included.
87    pub fill_count: usize,
88    /// Total fees as a decimal string.
89    pub total_fees: String,
90    /// Total traded volume as a decimal string.
91    pub total_volume: String,
92    /// Net PnL as a decimal string.
93    pub net_pnl: String,
94    /// Fees divided by total volume.
95    pub fee_drag_pct: Option<f64>,
96}
97
98/// Serializable performance snapshot.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PerformanceMetricsSnapshot {
101    /// Payload schema version.
102    pub schema_version: u32,
103    /// Run mode, such as `backtest`, `paper`, or `live`.
104    pub mode: String,
105    /// Scope name for the snapshot.
106    pub scope: String,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    /// Live/paper run start timestamp.
109    pub run_started_at_ms: Option<i64>,
110    /// Calculated metrics.
111    pub metrics: PerformanceMetrics,
112    /// Benchmark context.
113    pub benchmark: PerformanceBenchmark,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    /// Latest equity point when available.
116    pub latest_equity: Option<BacktestEquityPoint>,
117}
118
119/// Incremental performance tracker.
120#[derive(Debug, Clone)]
121pub struct PerformanceTracker {
122    mode: String,
123    scope: String,
124    run_started_at_ms: Option<i64>,
125    starting_balance_usdc: Option<Decimal>,
126    instrument: Option<InstrumentId>,
127    equity_curve: Vec<BacktestEquityPoint>,
128    quote_count: usize,
129}
130
131impl PerformanceTracker {
132    /// Create a tracker for one run mode and optional starting balance.
133    pub fn new(
134        mode: impl Into<String>,
135        starting_balance_usdc: Option<Decimal>,
136        instrument: Option<InstrumentId>,
137    ) -> Self {
138        let mode = mode.into();
139        let is_live_like = matches!(mode.as_str(), "live" | "paper");
140        Self {
141            scope: if is_live_like {
142                "current_run".to_string()
143            } else {
144                "backtest_window".to_string()
145            },
146            run_started_at_ms: is_live_like.then(now_ms),
147            mode,
148            starting_balance_usdc,
149            instrument,
150            equity_curve: Vec::new(),
151            quote_count: 0,
152        }
153    }
154
155    /// Set the instrument if it has not already been set.
156    pub fn set_instrument(&mut self, instrument: InstrumentId) {
157        if self.instrument.is_none() {
158            self.instrument = Some(instrument);
159        }
160    }
161
162    /// Record a new equity point.
163    pub fn record_equity_point(&mut self, ts_ms: i64, price: Option<Decimal>, net_pnl: Decimal) {
164        self.quote_count += 1;
165
166        let Some(starting_balance) = self.starting_balance_usdc else {
167            return;
168        };
169
170        self.equity_curve.push(BacktestEquityPoint {
171            ts_ms,
172            equity: (starting_balance + net_pnl).to_string(),
173            net_pnl: net_pnl.to_string(),
174            price: price.map(|p| p.to_string()),
175        });
176    }
177
178    /// Build a full performance snapshot from fills and positions.
179    pub fn snapshot(
180        &self,
181        fills: &[OrderFilledEvent],
182        positions: &[Position],
183    ) -> PerformanceMetricsSnapshot {
184        let total_volume = total_volume(fills);
185        let total_fees = positions.iter().map(|p| p.total_fees).sum::<Decimal>();
186        let net_pnl = positions.iter().map(Position::current_pnl).sum::<Decimal>();
187        let closed_trades = build_closed_trades(fills);
188        let benchmark = self.benchmark(net_pnl);
189        let metrics = calculate_metrics(
190            self.starting_balance_usdc,
191            &self.equity_curve,
192            &closed_trades,
193            fills.len(),
194            total_volume,
195            total_fees,
196            net_pnl,
197        );
198
199        PerformanceMetricsSnapshot {
200            schema_version: 1,
201            mode: self.mode.clone(),
202            scope: self.scope.clone(),
203            run_started_at_ms: self.run_started_at_ms,
204            metrics,
205            benchmark,
206            latest_equity: self.equity_curve.last().cloned(),
207        }
208    }
209
210    /// Return a copy of the equity curve.
211    pub fn equity_curve(&self) -> Vec<BacktestEquityPoint> {
212        self.equity_curve.clone()
213    }
214
215    /// Reconstruct closed trades from fills.
216    pub fn closed_trades(&self, fills: &[OrderFilledEvent]) -> Vec<BacktestClosedTrade> {
217        build_closed_trades(fills)
218    }
219
220    /// Calculate only the metrics block.
221    pub fn metrics(
222        &self,
223        fills: &[OrderFilledEvent],
224        positions: &[Position],
225    ) -> PerformanceMetrics {
226        self.snapshot(fills, positions).metrics
227    }
228
229    /// Build benchmark context for the current run.
230    pub fn benchmark(&self, net_pnl: Decimal) -> PerformanceBenchmark {
231        let start_ts_ms = self.equity_curve.first().map(|p| p.ts_ms);
232        let end_ts_ms = self.equity_curve.last().map(|p| p.ts_ms);
233        let duration_ms = match (start_ts_ms, end_ts_ms) {
234            (Some(start), Some(end)) if end >= start => Some(end - start),
235            _ => None,
236        };
237        let ending_balance_usdc = self
238            .starting_balance_usdc
239            .map(|balance| (balance + net_pnl).to_string());
240
241        PerformanceBenchmark {
242            start_ts_ms,
243            end_ts_ms,
244            duration_ms,
245            quote_count: self.quote_count,
246            starting_balance_usdc: self.starting_balance_usdc.map(|v| v.to_string()),
247            ending_balance_usdc,
248            instrument: self.instrument.as_ref().map(|i| i.0.clone()),
249        }
250    }
251}
252
253/// Calculate total traded volume from fills.
254pub fn total_volume(fills: &[OrderFilledEvent]) -> Decimal {
255    fills.iter().map(|fill| fill.qty.0 * fill.price.0).sum()
256}
257
258/// Reconstruct closed trades from ordered fills.
259pub fn build_closed_trades(fills: &[OrderFilledEvent]) -> Vec<BacktestClosedTrade> {
260    let mut ordered = fills.to_vec();
261    ordered.sort_by_key(|fill| fill.ts);
262
263    let mut position_qty = Decimal::ZERO;
264    let mut avg_entry = Decimal::ZERO;
265    let mut open_ts_ms: Option<i64> = None;
266    let mut open_fee_pool = Decimal::ZERO;
267    let mut closed_trades = Vec::new();
268
269    for fill in ordered {
270        let fill_sign = match fill.side {
271            OrderSide::Buy => Decimal::ONE,
272            OrderSide::Sell => -Decimal::ONE,
273        };
274        let mut remaining_qty = fill.qty.0;
275        let mut remaining_fee = fill.fee.amount;
276
277        while remaining_qty > Decimal::ZERO {
278            let same_side = (position_qty > Decimal::ZERO && fill_sign > Decimal::ZERO)
279                || (position_qty < Decimal::ZERO && fill_sign < Decimal::ZERO);
280            if position_qty == Decimal::ZERO || same_side {
281                let new_abs_qty = position_qty.abs() + remaining_qty;
282                avg_entry = if new_abs_qty > Decimal::ZERO {
283                    ((avg_entry * position_qty.abs()) + (fill.price.0 * remaining_qty))
284                        / new_abs_qty
285                } else {
286                    Decimal::ZERO
287                };
288                position_qty += fill_sign * remaining_qty;
289                open_fee_pool += remaining_fee;
290                open_ts_ms.get_or_insert(fill.ts);
291                break;
292            }
293
294            let position_abs = position_qty.abs();
295            let closing_qty = remaining_qty.min(position_abs);
296            let fee_ratio = if remaining_qty > Decimal::ZERO {
297                closing_qty / remaining_qty
298            } else {
299                Decimal::ZERO
300            };
301            let close_fee = remaining_fee * fee_ratio;
302            let open_fee = if position_abs > Decimal::ZERO {
303                open_fee_pool * (closing_qty / position_abs)
304            } else {
305                Decimal::ZERO
306            };
307
308            let gross_pnl = if position_qty > Decimal::ZERO {
309                (fill.price.0 - avg_entry) * closing_qty
310            } else {
311                (avg_entry - fill.price.0) * closing_qty
312            };
313            let fees = open_fee + close_fee;
314            let side = if position_qty > Decimal::ZERO {
315                "LONG"
316            } else {
317                "SHORT"
318            };
319
320            closed_trades.push(BacktestClosedTrade {
321                entry_ts_ms: open_ts_ms.unwrap_or(fill.ts),
322                exit_ts_ms: fill.ts,
323                side: side.to_string(),
324                qty: closing_qty.to_string(),
325                entry_price: avg_entry.to_string(),
326                exit_price: fill.price.0.to_string(),
327                gross_pnl: gross_pnl.to_string(),
328                fees: fees.to_string(),
329                net_pnl: (gross_pnl - fees).to_string(),
330            });
331
332            position_qty += fill_sign * closing_qty;
333            open_fee_pool -= open_fee;
334            remaining_qty -= closing_qty;
335            remaining_fee -= close_fee;
336
337            if position_qty == Decimal::ZERO {
338                avg_entry = Decimal::ZERO;
339                open_fee_pool = Decimal::ZERO;
340                open_ts_ms = None;
341            }
342        }
343    }
344
345    closed_trades
346}
347
348fn calculate_metrics(
349    starting_balance_usdc: Option<Decimal>,
350    equity_curve: &[BacktestEquityPoint],
351    closed_trades: &[BacktestClosedTrade],
352    fill_count: usize,
353    total_volume: Decimal,
354    total_fees: Decimal,
355    net_pnl: Decimal,
356) -> PerformanceMetrics {
357    let winning_trade_count = closed_trades
358        .iter()
359        .filter(|trade| decimal_from_str(&trade.net_pnl) > Decimal::ZERO)
360        .count();
361    let losing_trade_count = closed_trades
362        .iter()
363        .filter(|trade| decimal_from_str(&trade.net_pnl) < Decimal::ZERO)
364        .count();
365    let closed_trade_count = closed_trades.len();
366    let win_rate_pct = if closed_trade_count > 0 {
367        Some((winning_trade_count as f64 / closed_trade_count as f64) * 100.0)
368    } else {
369        None
370    };
371
372    let period_return_pct = starting_balance_usdc.and_then(|balance| {
373        if balance > Decimal::ZERO {
374            Some((decimal_to_f64(net_pnl / balance)) * 100.0)
375        } else {
376            None
377        }
378    });
379    let duration_ms = match (equity_curve.first(), equity_curve.last()) {
380        (Some(first), Some(last)) if last.ts_ms > first.ts_ms => {
381            Some((last.ts_ms - first.ts_ms) as f64)
382        }
383        _ => None,
384    };
385    let apr_pct = match (period_return_pct, duration_ms) {
386        (Some(return_pct), Some(duration)) if duration > 0.0 => {
387            Some((return_pct / 100.0) * (YEAR_MS / duration) * 100.0)
388        }
389        _ => None,
390    };
391    let (max_drawdown_usdc, max_drawdown_pct) = max_drawdown(equity_curve);
392
393    PerformanceMetrics {
394        period_return_pct,
395        apr_pct,
396        sharpe: sharpe(equity_curve),
397        max_drawdown_pct,
398        max_drawdown_usdc: max_drawdown_usdc.to_string(),
399        win_rate_pct,
400        closed_trade_count,
401        winning_trade_count,
402        losing_trade_count,
403        fill_count,
404        total_fees: total_fees.to_string(),
405        total_volume: total_volume.to_string(),
406        net_pnl: net_pnl.to_string(),
407        fee_drag_pct: starting_balance_usdc.and_then(|balance| {
408            if balance > Decimal::ZERO {
409                Some(decimal_to_f64(total_fees / balance) * 100.0)
410            } else {
411                None
412            }
413        }),
414    }
415}
416
417fn max_drawdown(equity_curve: &[BacktestEquityPoint]) -> (Decimal, Option<f64>) {
418    let mut peak: Option<Decimal> = None;
419    let mut max_dd = Decimal::ZERO;
420    let mut max_dd_pct: Option<f64> = None;
421
422    for point in equity_curve {
423        let equity = decimal_from_str(&point.equity);
424        peak = Some(match peak {
425            Some(existing) if existing > equity => existing,
426            _ => equity,
427        });
428
429        if let Some(current_peak) = peak {
430            let drawdown = current_peak - equity;
431            if drawdown > max_dd {
432                max_dd = drawdown;
433                max_dd_pct = if current_peak > Decimal::ZERO {
434                    Some(decimal_to_f64(drawdown / current_peak) * 100.0)
435                } else {
436                    None
437                };
438            }
439        }
440    }
441
442    (max_dd, max_dd_pct)
443}
444
445fn sharpe(equity_curve: &[BacktestEquityPoint]) -> Option<f64> {
446    if equity_curve.len() < 3 {
447        return None;
448    }
449
450    let mut returns = Vec::new();
451    for pair in equity_curve.windows(2) {
452        let prev = decimal_to_f64(decimal_from_str(&pair[0].equity));
453        let current = decimal_to_f64(decimal_from_str(&pair[1].equity));
454        if prev == 0.0 {
455            continue;
456        }
457        returns.push((current - prev) / prev);
458    }
459
460    if returns.len() < 2 {
461        return None;
462    }
463
464    let mean = returns.iter().sum::<f64>() / returns.len() as f64;
465    let variance =
466        returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (returns.len() - 1) as f64;
467    let std_dev = variance.sqrt();
468    if std_dev == 0.0 {
469        return None;
470    }
471
472    let first_ts = equity_curve.first()?.ts_ms;
473    let last_ts = equity_curve.last()?.ts_ms;
474    if last_ts <= first_ts {
475        return None;
476    }
477    let average_sample_ms = (last_ts - first_ts) as f64 / returns.len() as f64;
478    if average_sample_ms <= 0.0 {
479        return None;
480    }
481
482    Some((mean / std_dev) * (YEAR_MS / average_sample_ms).sqrt())
483}
484
485fn decimal_to_f64(value: Decimal) -> f64 {
486    value.to_string().parse::<f64>().unwrap_or(0.0)
487}
488
489fn decimal_from_str(value: &str) -> Decimal {
490    value.parse::<Decimal>().unwrap_or(Decimal::ZERO)
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use bot_core::{AssetId, ClientOrderId, ExchangeId, Fee, Price, Qty, TradeId};
497    use rust_decimal_macros::dec;
498
499    fn point(ts_ms: i64, equity: Decimal) -> BacktestEquityPoint {
500        BacktestEquityPoint {
501            ts_ms,
502            equity: equity.to_string(),
503            net_pnl: (equity - dec!(1000)).to_string(),
504            price: None,
505        }
506    }
507
508    fn assert_close(actual: Option<f64>, expected: f64) {
509        let actual = actual.expect("metric should be present");
510        assert!(
511            (actual - expected).abs() < 1e-9,
512            "expected {expected}, got {actual}"
513        );
514    }
515
516    fn assert_decimal_str(actual: &str, expected: Decimal) {
517        assert_eq!(decimal_from_str(actual), expected);
518    }
519
520    fn fill(
521        id: &str,
522        side: OrderSide,
523        price: Decimal,
524        qty: Decimal,
525        fee: Decimal,
526        ts: i64,
527    ) -> OrderFilledEvent {
528        OrderFilledEvent {
529            exchange: ExchangeId::new("hyperliquid"),
530            trade_id: TradeId::new(id),
531            client_id: ClientOrderId::new(format!("client-{}", id)),
532            instrument: InstrumentId::new("BTC-PERP"),
533            side,
534            price: Price::new(price),
535            qty: Qty::new(qty),
536            net_qty: Qty::new(qty),
537            fee: Fee::new(fee, AssetId::new("USDC")),
538            ts,
539        }
540    }
541
542    #[test]
543    fn closed_trades_include_allocated_open_and_close_fees() {
544        let fills = vec![
545            fill("1", OrderSide::Buy, dec!(100), dec!(1), dec!(0.10), 1),
546            fill("2", OrderSide::Sell, dec!(110), dec!(1), dec!(0.10), 2),
547        ];
548
549        let closed = build_closed_trades(&fills);
550
551        assert_eq!(closed.len(), 1);
552        assert_eq!(closed[0].gross_pnl, "10");
553        assert_eq!(closed[0].fees, "0.20");
554        assert_eq!(closed[0].net_pnl, "9.80");
555    }
556
557    #[test]
558    fn closed_trades_handle_partial_close_and_reversal() {
559        let fills = vec![
560            fill("1", OrderSide::Buy, dec!(100), dec!(2), dec!(0.20), 1),
561            fill("2", OrderSide::Sell, dec!(110), dec!(3), dec!(0.30), 2),
562            fill("3", OrderSide::Buy, dec!(90), dec!(1), dec!(0.10), 3),
563        ];
564
565        let closed = build_closed_trades(&fills);
566
567        assert_eq!(closed.len(), 2);
568        assert_eq!(closed[0].side, "LONG");
569        assert_decimal_str(&closed[0].qty, dec!(2));
570        assert_decimal_str(&closed[0].gross_pnl, dec!(20));
571        assert_decimal_str(&closed[0].fees, dec!(0.40));
572        assert_decimal_str(&closed[0].net_pnl, dec!(19.60));
573        assert_eq!(closed[1].side, "SHORT");
574        assert_decimal_str(&closed[1].qty, dec!(1));
575        assert_decimal_str(&closed[1].gross_pnl, dec!(20));
576        assert_decimal_str(&closed[1].fees, dec!(0.20));
577        assert_decimal_str(&closed[1].net_pnl, dec!(19.80));
578    }
579
580    #[test]
581    fn metrics_match_known_apr_drawdown_fee_and_volume_values() {
582        let year_ms = YEAR_MS as i64;
583        let equity_curve = vec![
584            point(0, dec!(1000)),
585            point(year_ms / 4, dec!(900)),
586            point(year_ms / 2, dec!(1100)),
587        ];
588
589        let metrics = calculate_metrics(
590            Some(dec!(1000)),
591            &equity_curve,
592            &[],
593            2,
594            dec!(2500),
595            dec!(5),
596            dec!(100),
597        );
598
599        assert_close(metrics.period_return_pct, 10.0);
600        assert_close(metrics.apr_pct, 20.0);
601        assert_eq!(metrics.max_drawdown_usdc, "100");
602        assert_close(metrics.max_drawdown_pct, 10.0);
603        assert_eq!(metrics.total_volume, "2500");
604        assert_eq!(metrics.total_fees, "5");
605        assert_eq!(metrics.net_pnl, "100");
606        assert_close(metrics.fee_drag_pct, 0.5);
607    }
608
609    #[test]
610    fn sharpe_matches_known_sample_return_formula() {
611        let equity_curve = vec![
612            point(0, dec!(1000)),
613            point(86_400_000, dec!(1010)),
614            point(172_800_000, dec!(1000)),
615            point(259_200_000, dec!(1020)),
616        ];
617
618        let metrics = calculate_metrics(
619            Some(dec!(1000)),
620            &equity_curve,
621            &[],
622            0,
623            Decimal::ZERO,
624            Decimal::ZERO,
625            dec!(20),
626        );
627
628        let returns = [10.0 / 1000.0, -10.0 / 1010.0, 20.0 / 1000.0];
629        let mean = returns.iter().sum::<f64>() / returns.len() as f64;
630        let variance =
631            returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (returns.len() - 1) as f64;
632        let expected = (mean / variance.sqrt()) * 365.0_f64.sqrt();
633
634        assert_close(metrics.sharpe, expected);
635    }
636
637    #[test]
638    fn metrics_return_nulls_when_data_is_insufficient() {
639        let tracker =
640            PerformanceTracker::new("backtest", Some(dec!(1000)), Some(InstrumentId::new("BTC")));
641        let metrics = tracker.metrics(&[], &[]);
642
643        assert_eq!(metrics.period_return_pct, Some(0.0));
644        assert_eq!(metrics.apr_pct, None);
645        assert_eq!(metrics.sharpe, None);
646        assert_eq!(metrics.win_rate_pct, None);
647    }
648
649    #[test]
650    fn drawdown_and_win_rate_are_computed_from_curve_and_closed_trades() {
651        let mut tracker =
652            PerformanceTracker::new("backtest", Some(dec!(1000)), Some(InstrumentId::new("BTC")));
653        tracker.record_equity_point(1, Some(dec!(100)), dec!(0));
654        tracker.record_equity_point(2, Some(dec!(95)), dec!(-100));
655        tracker.record_equity_point(3, Some(dec!(110)), dec!(100));
656
657        let fills = vec![
658            fill("1", OrderSide::Buy, dec!(100), dec!(1), dec!(0), 1),
659            fill("2", OrderSide::Sell, dec!(110), dec!(1), dec!(0), 2),
660        ];
661        let metrics = tracker.metrics(&fills, &[]);
662
663        assert_eq!(metrics.closed_trade_count, 1);
664        assert_eq!(metrics.win_rate_pct, Some(100.0));
665        assert_eq!(metrics.max_drawdown_usdc, "100");
666        assert_eq!(metrics.max_drawdown_pct, Some(10.0));
667        assert!(metrics.apr_pct.is_some());
668    }
669}