Skip to content

Commit 6a56ca1

Browse files
michaelchuclaude
andauthored
Optimize backtest metrics: trade-level stats, CAGR, expectancy (#4)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5bb877f commit 6a56ca1

11 files changed

Lines changed: 680 additions & 265 deletions

File tree

src/data/parquet.rs

Lines changed: 72 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,76 @@ pub fn normalize_quote_datetime(df: DataFrame) -> Result<DataFrame> {
8181
Ok(result)
8282
}
8383

84+
impl DataStore for ParquetStore {
85+
fn load_options(
86+
&self,
87+
_symbol: &str,
88+
start_date: Option<NaiveDate>,
89+
end_date: Option<NaiveDate>,
90+
) -> Result<DataFrame> {
91+
let path_str = self.path.to_string_lossy().to_string();
92+
let df = LazyFrame::scan_parquet(path_str.as_str().into(), ScanArgsParquet::default())?
93+
.collect()
94+
.context("Failed to read Parquet file")?;
95+
96+
// Normalize to quote_datetime
97+
let mut df = normalize_quote_datetime(df)?;
98+
99+
// Apply date filters if quote_datetime exists
100+
if df.schema().contains(QUOTE_DATETIME_COL) {
101+
if let Some(start) = start_date {
102+
let start_dt = start.and_hms_opt(0, 0, 0).unwrap();
103+
df = df
104+
.lazy()
105+
.filter(col(QUOTE_DATETIME_COL).gt_eq(lit(start_dt)))
106+
.collect()?;
107+
}
108+
if let Some(end) = end_date {
109+
let end_dt = end.and_hms_opt(23, 59, 59).unwrap();
110+
df = df
111+
.lazy()
112+
.filter(col(QUOTE_DATETIME_COL).lt_eq(lit(end_dt)))
113+
.collect()?;
114+
}
115+
}
116+
117+
Ok(df)
118+
}
119+
120+
fn list_symbols(&self) -> Result<Vec<String>> {
121+
let path_str = self.path.to_string_lossy().to_string();
122+
let df = LazyFrame::scan_parquet(path_str.as_str().into(), ScanArgsParquet::default())?
123+
.select([col("symbol")])
124+
.unique(None, UniqueKeepStrategy::First)
125+
.collect()?;
126+
127+
let ca = df.column("symbol")?.str()?;
128+
Ok(ca
129+
.into_no_null_iter()
130+
.map(std::string::ToString::to_string)
131+
.collect())
132+
}
133+
134+
fn date_range(&self, _symbol: &str) -> Result<(NaiveDate, NaiveDate)> {
135+
let path_str = self.path.to_string_lossy().to_string();
136+
let df = LazyFrame::scan_parquet(path_str.as_str().into(), ScanArgsParquet::default())?
137+
.collect()?;
138+
139+
let df = normalize_quote_datetime(df)?;
140+
let date_col = df.column(QUOTE_DATETIME_COL)?;
141+
let min = date_col.min_reduce()?;
142+
let max = date_col.max_reduce()?;
143+
144+
let min_str = format!("{:?}", min.value());
145+
let max_str = format!("{:?}", max.value());
146+
147+
let start = NaiveDate::parse_from_str(min_str.trim_matches('"'), "%Y-%m-%d")?;
148+
let end = NaiveDate::parse_from_str(max_str.trim_matches('"'), "%Y-%m-%d")?;
149+
150+
Ok((start, end))
151+
}
152+
}
153+
84154
#[cfg(test)]
85155
mod tests {
86156
use super::*;
@@ -101,7 +171,7 @@ mod tests {
101171
assert!(!result.schema().contains("quote_date"));
102172
match result.column(QUOTE_DATETIME_COL).unwrap().dtype() {
103173
DataType::Datetime(_, _) => {}
104-
other => panic!("Expected Datetime, got {:?}", other),
174+
other => panic!("Expected Datetime, got {other:?}"),
105175
}
106176
}
107177

@@ -157,7 +227,7 @@ mod tests {
157227
assert!(result.schema().contains(QUOTE_DATETIME_COL));
158228
match result.column(QUOTE_DATETIME_COL).unwrap().dtype() {
159229
DataType::Datetime(_, _) => {}
160-
other => panic!("Expected Datetime, got {:?}", other),
230+
other => panic!("Expected Datetime, got {other:?}"),
161231
}
162232
}
163233

@@ -173,73 +243,3 @@ mod tests {
173243
assert_eq!(result.schema(), df.schema());
174244
}
175245
}
176-
177-
impl DataStore for ParquetStore {
178-
fn load_options(
179-
&self,
180-
_symbol: &str,
181-
start_date: Option<NaiveDate>,
182-
end_date: Option<NaiveDate>,
183-
) -> Result<DataFrame> {
184-
let path_str = self.path.to_string_lossy().to_string();
185-
let df = LazyFrame::scan_parquet(path_str.as_str().into(), ScanArgsParquet::default())?
186-
.collect()
187-
.context("Failed to read Parquet file")?;
188-
189-
// Normalize to quote_datetime
190-
let mut df = normalize_quote_datetime(df)?;
191-
192-
// Apply date filters if quote_datetime exists
193-
if df.schema().contains(QUOTE_DATETIME_COL) {
194-
if let Some(start) = start_date {
195-
let start_dt = start.and_hms_opt(0, 0, 0).unwrap();
196-
df = df
197-
.lazy()
198-
.filter(col(QUOTE_DATETIME_COL).gt_eq(lit(start_dt)))
199-
.collect()?;
200-
}
201-
if let Some(end) = end_date {
202-
let end_dt = end.and_hms_opt(23, 59, 59).unwrap();
203-
df = df
204-
.lazy()
205-
.filter(col(QUOTE_DATETIME_COL).lt_eq(lit(end_dt)))
206-
.collect()?;
207-
}
208-
}
209-
210-
Ok(df)
211-
}
212-
213-
fn list_symbols(&self) -> Result<Vec<String>> {
214-
let path_str = self.path.to_string_lossy().to_string();
215-
let df = LazyFrame::scan_parquet(path_str.as_str().into(), ScanArgsParquet::default())?
216-
.select([col("symbol")])
217-
.unique(None, UniqueKeepStrategy::First)
218-
.collect()?;
219-
220-
let ca = df.column("symbol")?.str()?;
221-
Ok(ca
222-
.into_no_null_iter()
223-
.map(std::string::ToString::to_string)
224-
.collect())
225-
}
226-
227-
fn date_range(&self, _symbol: &str) -> Result<(NaiveDate, NaiveDate)> {
228-
let path_str = self.path.to_string_lossy().to_string();
229-
let df = LazyFrame::scan_parquet(path_str.as_str().into(), ScanArgsParquet::default())?
230-
.collect()?;
231-
232-
let df = normalize_quote_datetime(df)?;
233-
let date_col = df.column(QUOTE_DATETIME_COL)?;
234-
let min = date_col.min_reduce()?;
235-
let max = date_col.max_reduce()?;
236-
237-
let min_str = format!("{:?}", min.value());
238-
let max_str = format!("{:?}", max.value());
239-
240-
let start = NaiveDate::parse_from_str(min_str.trim_matches('"'), "%Y-%m-%d")?;
241-
let end = NaiveDate::parse_from_str(max_str.trim_matches('"'), "%Y-%m-%d")?;
242-
243-
Ok((start, end))
244-
}
245-
}

src/data/postgres.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use sqlx::postgres::PgPool;
1111
use super::DataStore;
1212

1313
#[cfg(feature = "postgres")]
14+
#[allow(dead_code)]
1415
pub struct PostgresStore {
1516
pool: PgPool,
1617
}

src/engine/core.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ pub fn run_backtest(df: &DataFrame, params: &BacktestParams) -> Result<BacktestR
199199
&strategy_def,
200200
);
201201

202-
let perf_metrics = metrics::calculate_metrics(&equity_curve, params.capital)?;
202+
let perf_metrics = metrics::calculate_metrics(&equity_curve, &trade_log, params.capital)?;
203203

204204
Ok(BacktestResult {
205205
trade_count: trade_log.len(),
@@ -241,8 +241,12 @@ pub fn compare_strategies(df: &DataFrame, params: &CompareParams) -> Result<Vec<
241241
trades: bt.trade_count,
242242
pnl: bt.total_pnl,
243243
sharpe: bt.metrics.sharpe,
244+
sortino: bt.metrics.sortino,
244245
max_dd: bt.metrics.max_drawdown,
245246
win_rate: bt.metrics.win_rate,
247+
profit_factor: bt.metrics.profit_factor,
248+
calmar: bt.metrics.calmar,
249+
total_return_pct: bt.metrics.total_return_pct,
246250
});
247251
}
248252
Err(e) => {
@@ -252,8 +256,12 @@ pub fn compare_strategies(df: &DataFrame, params: &CompareParams) -> Result<Vec<
252256
trades: 0,
253257
pnl: 0.0,
254258
sharpe: 0.0,
259+
sortino: 0.0,
255260
max_dd: 0.0,
256261
win_rate: 0.0,
262+
profit_factor: 0.0,
263+
calmar: 0.0,
264+
total_return_pct: 0.0,
257265
});
258266
}
259267
}
@@ -280,8 +288,8 @@ mod tests {
280288
use super::*;
281289
use chrono::NaiveDate;
282290

283-
/// Build a synthetic options DataFrame with 2 rows for evaluate_strategy tests
284-
/// (which still use the old match_entry_exit pipeline).
291+
/// Build a synthetic options `DataFrame` with 2 rows for `evaluate_strategy` tests
292+
/// (which still use the old `match_entry_exit` pipeline).
285293
fn make_synthetic_options_df() -> DataFrame {
286294
let quote_dates = vec![
287295
NaiveDate::from_ymd_opt(2024, 1, 15)
@@ -314,7 +322,7 @@ mod tests {
314322
df
315323
}
316324

317-
/// Build a daily options DataFrame with intermediate dates for event-driven backtest.
325+
/// Build a daily options `DataFrame` with intermediate dates for event-driven backtest.
318326
/// Shows price decay from entry to exit for a long call.
319327
fn make_daily_options_df() -> DataFrame {
320328
let exp = NaiveDate::from_ymd_opt(2024, 2, 16).unwrap();
@@ -541,7 +549,7 @@ mod tests {
541549
fn run_backtest_spread_strategy() {
542550
// Build data for a bull call spread: long call at lower strike, short call at higher
543551
let exp = NaiveDate::from_ymd_opt(2024, 2, 16).unwrap();
544-
let dates = vec![
552+
let dates = [
545553
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
546554
NaiveDate::from_ymd_opt(2024, 1, 22).unwrap(),
547555
NaiveDate::from_ymd_opt(2024, 2, 11).unwrap(),

src/engine/event_sim.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -717,7 +717,7 @@ mod tests {
717717
let mtm = mark_to_market(&position, d2, &table, &last_known, &Slippage::Mid, 100);
718718
// Long call: entered at 5.25, current mid = 3.25
719719
// MTM = (3.25 - 5.25) * 1.0 * 1 * 100 = -200.0
720-
assert!((mtm - (-200.0)).abs() < 1e-10, "MTM was {}", mtm);
720+
assert!((mtm - (-200.0)).abs() < 1e-10, "MTM was {mtm}");
721721
}
722722

723723
#[test]
@@ -770,7 +770,7 @@ mod tests {
770770
let mtm = mark_to_market(&position, d2, &table, &last_known, &Slippage::Mid, 100);
771771
// Short put: sold at 4.25, current mid = 3.25 (to buy back)
772772
// MTM = (3.25 - 4.25) * (-1.0) * 1 * 100 = +100.0
773-
assert!((mtm - 100.0).abs() < 1e-10, "MTM was {}", mtm);
773+
assert!((mtm - 100.0).abs() < 1e-10, "MTM was {mtm}");
774774
}
775775

776776
#[test]
@@ -818,7 +818,7 @@ mod tests {
818818
assert_eq!(position.legs[0].close_date, Some(d3));
819819
// Close at mid of 2.0/2.50 = 2.25
820820
// PnL = (2.25 - 5.25) * 1.0 * 1 * 100 = -300
821-
assert!((pnl - (-300.0)).abs() < 1e-10, "PnL was {}", pnl);
821+
assert!((pnl - (-300.0)).abs() < 1e-10, "PnL was {pnl}");
822822
}
823823

824824
#[test]
@@ -1221,8 +1221,8 @@ mod tests {
12211221
);
12221222
}
12231223

1224-
/// Helper: build a synthetic daily options DataFrame for testing build_price_table
1225-
/// and find_entry_candidates.
1224+
/// Helper: build a synthetic daily options `DataFrame` for testing `build_price_table`
1225+
/// and `find_entry_candidates`.
12261226
fn make_daily_df() -> DataFrame {
12271227
let d1 = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
12281228
let d2 = NaiveDate::from_ymd_opt(2024, 1, 22).unwrap();
@@ -1286,7 +1286,7 @@ mod tests {
12861286
);
12871287

12881288
// Each date group should have exactly 1 candidate (1 strike per date)
1289-
for (_date, cands) in &candidates {
1289+
for cands in candidates.values() {
12901290
assert_eq!(cands[0].legs.len(), 1);
12911291
}
12921292
}
@@ -1357,7 +1357,7 @@ mod tests {
13571357
!candidates.is_empty(),
13581358
"Should find entry candidates for 3-leg butterfly"
13591359
);
1360-
for (_date, cands) in &candidates {
1360+
for cands in candidates.values() {
13611361
assert_eq!(cands[0].legs.len(), 3, "Butterfly should have 3 legs");
13621362
}
13631363
}

0 commit comments

Comments
 (0)