1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct BacktestEquityPoint {
12 pub ts_ms: i64,
14 pub equity: String,
16 pub net_pnl: String,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub price: Option<String>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct BacktestClosedTrade {
26 pub entry_ts_ms: i64,
28 pub exit_ts_ms: i64,
30 pub side: String,
32 pub qty: String,
34 pub entry_price: String,
36 pub exit_price: String,
38 pub gross_pnl: String,
40 pub fees: String,
42 pub net_pnl: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct PerformanceBenchmark {
49 pub start_ts_ms: Option<i64>,
51 pub end_ts_ms: Option<i64>,
53 pub duration_ms: Option<i64>,
55 pub quote_count: usize,
57 pub starting_balance_usdc: Option<String>,
59 pub ending_balance_usdc: Option<String>,
61 pub instrument: Option<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct PerformanceMetrics {
68 pub period_return_pct: Option<f64>,
70 pub apr_pct: Option<f64>,
72 pub sharpe: Option<f64>,
74 pub max_drawdown_pct: Option<f64>,
76 pub max_drawdown_usdc: String,
78 pub win_rate_pct: Option<f64>,
80 pub closed_trade_count: usize,
82 pub winning_trade_count: usize,
84 pub losing_trade_count: usize,
86 pub fill_count: usize,
88 pub total_fees: String,
90 pub total_volume: String,
92 pub net_pnl: String,
94 pub fee_drag_pct: Option<f64>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PerformanceMetricsSnapshot {
101 pub schema_version: u32,
103 pub mode: String,
105 pub scope: String,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub run_started_at_ms: Option<i64>,
110 pub metrics: PerformanceMetrics,
112 pub benchmark: PerformanceBenchmark,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub latest_equity: Option<BacktestEquityPoint>,
117}
118
119#[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 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 pub fn set_instrument(&mut self, instrument: InstrumentId) {
157 if self.instrument.is_none() {
158 self.instrument = Some(instrument);
159 }
160 }
161
162 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 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 pub fn equity_curve(&self) -> Vec<BacktestEquityPoint> {
212 self.equity_curve.clone()
213 }
214
215 pub fn closed_trades(&self, fills: &[OrderFilledEvent]) -> Vec<BacktestClosedTrade> {
217 build_closed_trades(fills)
218 }
219
220 pub fn metrics(
222 &self,
223 fills: &[OrderFilledEvent],
224 positions: &[Position],
225 ) -> PerformanceMetrics {
226 self.snapshot(fills, positions).metrics
227 }
228
229 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
253pub fn total_volume(fills: &[OrderFilledEvent]) -> Decimal {
255 fills.iter().map(|fill| fill.qty.0 * fill.price.0).sum()
256}
257
258pub 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}