1use sig_types::{BacktestReport, BacktestMetrics, Result, SigcError};
6use std::collections::HashMap;
7use std::sync::{Arc, RwLock};
8
9pub type ResultId = String;
11
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub struct ResultMetadata {
15 pub id: ResultId,
16 pub strategy_name: String,
17 pub strategy_version: Option<String>,
18 pub created_at: String,
19 pub start_date: String,
20 pub end_date: String,
21 pub total_return: f64,
22 pub sharpe_ratio: f64,
23 pub max_drawdown: f64,
24 pub tags: HashMap<String, String>,
25}
26
27#[derive(Debug, Clone, Default)]
29pub struct ResultQuery {
30 pub strategy_name: Option<String>,
31 pub strategy_version: Option<String>,
32 pub start_date_after: Option<String>,
33 pub end_date_before: Option<String>,
34 pub min_sharpe: Option<f64>,
35 pub max_drawdown: Option<f64>,
36 pub tags: HashMap<String, String>,
37 pub limit: Option<usize>,
38 pub order_by: Option<String>,
39}
40
41impl ResultQuery {
42 pub fn new() -> Self {
43 Self::default()
44 }
45
46 pub fn strategy(mut self, name: &str) -> Self {
47 self.strategy_name = Some(name.to_string());
48 self
49 }
50
51 pub fn version(mut self, version: &str) -> Self {
52 self.strategy_version = Some(version.to_string());
53 self
54 }
55
56 pub fn min_sharpe(mut self, sharpe: f64) -> Self {
57 self.min_sharpe = Some(sharpe);
58 self
59 }
60
61 pub fn limit(mut self, n: usize) -> Self {
62 self.limit = Some(n);
63 self
64 }
65
66 pub fn order_by(mut self, field: &str) -> Self {
67 self.order_by = Some(field.to_string());
68 self
69 }
70
71 pub fn tag(mut self, key: &str, value: &str) -> Self {
72 self.tags.insert(key.to_string(), value.to_string());
73 self
74 }
75}
76
77pub trait ResultStore: Send + Sync {
79 fn store(&self, report: &BacktestReport, metadata: ResultMetadata) -> Result<ResultId>;
81
82 fn get(&self, id: &ResultId) -> Result<Option<(BacktestReport, ResultMetadata)>>;
84
85 fn query(&self, query: &ResultQuery) -> Result<Vec<ResultMetadata>>;
87
88 fn delete(&self, id: &ResultId) -> Result<bool>;
90
91 fn list_ids(&self) -> Result<Vec<ResultId>>;
93
94 fn name(&self) -> &str;
96
97 fn is_available(&self) -> bool;
99}
100
101pub struct MemoryResultStore {
103 results: Arc<RwLock<HashMap<ResultId, (BacktestReport, ResultMetadata)>>>,
104}
105
106impl MemoryResultStore {
107 pub fn new() -> Self {
108 MemoryResultStore {
109 results: Arc::new(RwLock::new(HashMap::new())),
110 }
111 }
112}
113
114impl Default for MemoryResultStore {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120impl ResultStore for MemoryResultStore {
121 fn store(&self, report: &BacktestReport, metadata: ResultMetadata) -> Result<ResultId> {
122 let id = metadata.id.clone();
123 let mut results = self.results.write()
124 .map_err(|e| SigcError::Runtime(format!("Lock error: {}", e)))?;
125 results.insert(id.clone(), (report.clone(), metadata));
126 Ok(id)
127 }
128
129 fn get(&self, id: &ResultId) -> Result<Option<(BacktestReport, ResultMetadata)>> {
130 let results = self.results.read()
131 .map_err(|e| SigcError::Runtime(format!("Lock error: {}", e)))?;
132 Ok(results.get(id).cloned())
133 }
134
135 fn query(&self, query: &ResultQuery) -> Result<Vec<ResultMetadata>> {
136 let results = self.results.read()
137 .map_err(|e| SigcError::Runtime(format!("Lock error: {}", e)))?;
138
139 let mut matched: Vec<ResultMetadata> = results
140 .values()
141 .filter(|(_, meta)| {
142 if let Some(ref name) = query.strategy_name {
144 if &meta.strategy_name != name {
145 return false;
146 }
147 }
148
149 if let Some(ref version) = query.strategy_version {
151 if meta.strategy_version.as_ref() != Some(version) {
152 return false;
153 }
154 }
155
156 if let Some(min_sharpe) = query.min_sharpe {
158 if meta.sharpe_ratio < min_sharpe {
159 return false;
160 }
161 }
162
163 if let Some(max_dd) = query.max_drawdown {
165 if meta.max_drawdown > max_dd {
166 return false;
167 }
168 }
169
170 for (key, value) in &query.tags {
172 if meta.tags.get(key) != Some(value) {
173 return false;
174 }
175 }
176
177 true
178 })
179 .map(|(_, meta)| meta.clone())
180 .collect();
181
182 if let Some(ref order_by) = query.order_by {
184 match order_by.as_str() {
185 "sharpe_ratio" => matched.sort_by(|a, b| {
186 b.sharpe_ratio.partial_cmp(&a.sharpe_ratio).unwrap_or(std::cmp::Ordering::Equal)
187 }),
188 "total_return" => matched.sort_by(|a, b| {
189 b.total_return.partial_cmp(&a.total_return).unwrap_or(std::cmp::Ordering::Equal)
190 }),
191 "created_at" => matched.sort_by(|a, b| b.created_at.cmp(&a.created_at)),
192 _ => {}
193 }
194 }
195
196 if let Some(limit) = query.limit {
198 matched.truncate(limit);
199 }
200
201 Ok(matched)
202 }
203
204 fn delete(&self, id: &ResultId) -> Result<bool> {
205 let mut results = self.results.write()
206 .map_err(|e| SigcError::Runtime(format!("Lock error: {}", e)))?;
207 Ok(results.remove(id).is_some())
208 }
209
210 fn list_ids(&self) -> Result<Vec<ResultId>> {
211 let results = self.results.read()
212 .map_err(|e| SigcError::Runtime(format!("Lock error: {}", e)))?;
213 Ok(results.keys().cloned().collect())
214 }
215
216 fn name(&self) -> &str {
217 "memory"
218 }
219
220 fn is_available(&self) -> bool {
221 true
222 }
223}
224
225pub struct PostgresResultStore {
227 host: String,
228 port: u16,
229 database: String,
230 user: String,
231 password: String,
232}
233
234impl PostgresResultStore {
235 pub fn new(host: &str, port: u16, database: &str, user: &str, password: &str) -> Self {
236 PostgresResultStore {
237 host: host.to_string(),
238 port,
239 database: database.to_string(),
240 user: user.to_string(),
241 password: password.to_string(),
242 }
243 }
244
245 pub fn from_env() -> Option<Self> {
247 let host = std::env::var("PGHOST").ok()?;
248 let port: u16 = std::env::var("PGPORT").ok()?.parse().ok()?;
249 let database = std::env::var("PGDATABASE").ok()?;
250 let user = std::env::var("PGUSER").ok()?;
251 let password = std::env::var("PGPASSWORD").ok()?;
252
253 Some(Self::new(&host, port, &database, &user, &password))
254 }
255
256 fn connection_string(&self) -> String {
257 format!(
258 "host={} port={} dbname={} user={} password={}",
259 self.host, self.port, self.database, self.user, self.password
260 )
261 }
262
263 fn get_client(&self) -> Result<postgres::Client> {
264 postgres::Client::connect(&self.connection_string(), postgres::NoTls)
265 .map_err(|e| SigcError::Runtime(format!("Failed to connect: {}", e)))
266 }
267
268 pub fn init_schema(&self) -> Result<()> {
270 let mut client = self.get_client()?;
271
272 client.batch_execute(r#"
273 CREATE TABLE IF NOT EXISTS backtest_results (
274 id TEXT PRIMARY KEY,
275 strategy_name TEXT NOT NULL,
276 strategy_version TEXT,
277 created_at TIMESTAMP NOT NULL DEFAULT NOW(),
278 start_date TEXT NOT NULL,
279 end_date TEXT NOT NULL,
280 total_return DOUBLE PRECISION NOT NULL,
281 annualized_return DOUBLE PRECISION,
282 sharpe_ratio DOUBLE PRECISION NOT NULL,
283 max_drawdown DOUBLE PRECISION NOT NULL,
284 sortino_ratio DOUBLE PRECISION,
285 calmar_ratio DOUBLE PRECISION,
286 win_rate DOUBLE PRECISION,
287 profit_factor DOUBLE PRECISION,
288 turnover DOUBLE PRECISION,
289 returns_series TEXT NOT NULL,
290 tags TEXT
291 );
292
293 CREATE INDEX IF NOT EXISTS idx_results_strategy ON backtest_results(strategy_name);
294 CREATE INDEX IF NOT EXISTS idx_results_created ON backtest_results(created_at);
295 CREATE INDEX IF NOT EXISTS idx_results_sharpe ON backtest_results(sharpe_ratio);
296 "#).map_err(|e| SigcError::Runtime(format!("Schema init failed: {}", e)))?;
297
298 Ok(())
299 }
300}
301
302impl ResultStore for PostgresResultStore {
303 fn store(&self, report: &BacktestReport, metadata: ResultMetadata) -> Result<ResultId> {
304 let mut client = self.get_client()?;
305
306 let returns_str: String = report.returns_series
308 .iter()
309 .map(|r| r.to_string())
310 .collect::<Vec<_>>()
311 .join(",");
312
313 let tags_str: String = metadata.tags
315 .iter()
316 .map(|(k, v)| format!("{}={}", k, v))
317 .collect::<Vec<_>>()
318 .join(";");
319
320 client.execute(
321 r#"
322 INSERT INTO backtest_results
323 (id, strategy_name, strategy_version, start_date, end_date,
324 total_return, annualized_return, sharpe_ratio, max_drawdown,
325 sortino_ratio, calmar_ratio, win_rate, profit_factor, turnover,
326 returns_series, tags)
327 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
328 ON CONFLICT (id) DO UPDATE SET
329 returns_series = EXCLUDED.returns_series,
330 tags = EXCLUDED.tags
331 "#,
332 &[
333 &metadata.id,
334 &metadata.strategy_name,
335 &metadata.strategy_version,
336 &metadata.start_date,
337 &metadata.end_date,
338 &report.metrics.total_return,
339 &report.metrics.annualized_return,
340 &report.metrics.sharpe_ratio,
341 &report.metrics.max_drawdown,
342 &report.metrics.sortino_ratio,
343 &report.metrics.calmar_ratio,
344 &report.metrics.win_rate,
345 &report.metrics.profit_factor,
346 &report.metrics.turnover,
347 &returns_str,
348 &tags_str,
349 ],
350 ).map_err(|e| SigcError::Runtime(format!("Insert failed: {}", e)))?;
351
352 Ok(metadata.id)
353 }
354
355 fn get(&self, id: &ResultId) -> Result<Option<(BacktestReport, ResultMetadata)>> {
356 let mut client = self.get_client()?;
357
358 let row = client.query_opt(
359 r#"
360 SELECT id, strategy_name, strategy_version, created_at::text,
361 start_date, end_date, total_return, annualized_return,
362 sharpe_ratio, max_drawdown, sortino_ratio, calmar_ratio,
363 win_rate, profit_factor, turnover, returns_series, tags
364 FROM backtest_results WHERE id = $1
365 "#,
366 &[id],
367 ).map_err(|e| SigcError::Runtime(format!("Query failed: {}", e)))?;
368
369 match row {
370 Some(row) => {
371 let returns_str: String = row.get("returns_series");
373 let returns_series: Vec<f64> = returns_str
374 .split(',')
375 .filter(|s| !s.is_empty())
376 .filter_map(|s| s.parse().ok())
377 .collect();
378
379 let tags_str: String = row.get("tags");
381 let tags: HashMap<String, String> = tags_str
382 .split(';')
383 .filter(|s| !s.is_empty())
384 .filter_map(|s| {
385 let parts: Vec<&str> = s.splitn(2, '=').collect();
386 if parts.len() == 2 {
387 Some((parts[0].to_string(), parts[1].to_string()))
388 } else {
389 None
390 }
391 })
392 .collect();
393
394 let created_at: String = row.get("created_at");
395
396 let report = BacktestReport {
397 metrics: BacktestMetrics {
398 total_return: row.get("total_return"),
399 annualized_return: row.get("annualized_return"),
400 sharpe_ratio: row.get("sharpe_ratio"),
401 max_drawdown: row.get("max_drawdown"),
402 sortino_ratio: row.get("sortino_ratio"),
403 calmar_ratio: row.get("calmar_ratio"),
404 win_rate: row.get("win_rate"),
405 profit_factor: row.get("profit_factor"),
406 turnover: row.get("turnover"),
407 },
408 returns_series,
409 positions: None,
410 benchmark_metrics: None,
411 executed_at: 0, plan_hash: String::new(),
413 };
414
415 let metadata = ResultMetadata {
416 id: row.get("id"),
417 strategy_name: row.get("strategy_name"),
418 strategy_version: row.get("strategy_version"),
419 created_at,
420 start_date: row.get("start_date"),
421 end_date: row.get("end_date"),
422 total_return: row.get("total_return"),
423 sharpe_ratio: row.get("sharpe_ratio"),
424 max_drawdown: row.get("max_drawdown"),
425 tags,
426 };
427
428 Ok(Some((report, metadata)))
429 }
430 None => Ok(None),
431 }
432 }
433
434 fn query(&self, query: &ResultQuery) -> Result<Vec<ResultMetadata>> {
435 let mut client = self.get_client()?;
436
437 let mut sql = String::from(
438 "SELECT id, strategy_name, strategy_version, created_at::text,
439 start_date, end_date, total_return, sharpe_ratio, max_drawdown, tags
440 FROM backtest_results WHERE 1=1"
441 );
442
443 let mut params: Vec<Box<dyn postgres::types::ToSql + Sync>> = Vec::new();
444 let mut param_idx = 1;
445
446 if let Some(ref name) = query.strategy_name {
447 sql.push_str(&format!(" AND strategy_name = ${}", param_idx));
448 params.push(Box::new(name.clone()));
449 param_idx += 1;
450 }
451
452 if let Some(ref version) = query.strategy_version {
453 sql.push_str(&format!(" AND strategy_version = ${}", param_idx));
454 params.push(Box::new(version.clone()));
455 param_idx += 1;
456 }
457
458 if let Some(min_sharpe) = query.min_sharpe {
459 sql.push_str(&format!(" AND sharpe_ratio >= ${}", param_idx));
460 params.push(Box::new(min_sharpe));
461 param_idx += 1;
462 }
463
464 if let Some(max_dd) = query.max_drawdown {
465 sql.push_str(&format!(" AND max_drawdown <= ${}", param_idx));
466 params.push(Box::new(max_dd));
467 let _ = param_idx;
468 }
469
470 if let Some(ref order_by) = query.order_by {
472 let order_col = match order_by.as_str() {
473 "sharpe_ratio" => "sharpe_ratio DESC",
474 "total_return" => "total_return DESC",
475 "created_at" => "created_at DESC",
476 _ => "created_at DESC",
477 };
478 sql.push_str(&format!(" ORDER BY {}", order_col));
479 }
480
481 if let Some(limit) = query.limit {
483 sql.push_str(&format!(" LIMIT {}", limit));
484 }
485
486 let param_refs: Vec<&(dyn postgres::types::ToSql + Sync)> =
488 params.iter().map(|p| p.as_ref()).collect();
489
490 let rows = client.query(&sql, ¶m_refs)
491 .map_err(|e| SigcError::Runtime(format!("Query failed: {}", e)))?;
492
493 let results: Vec<ResultMetadata> = rows
494 .iter()
495 .map(|row| {
496 let tags_str: String = row.get("tags");
498 let tags: HashMap<String, String> = tags_str
499 .split(';')
500 .filter(|s| !s.is_empty())
501 .filter_map(|s| {
502 let parts: Vec<&str> = s.splitn(2, '=').collect();
503 if parts.len() == 2 {
504 Some((parts[0].to_string(), parts[1].to_string()))
505 } else {
506 None
507 }
508 })
509 .collect();
510
511 ResultMetadata {
512 id: row.get("id"),
513 strategy_name: row.get("strategy_name"),
514 strategy_version: row.get("strategy_version"),
515 created_at: row.get("created_at"),
516 start_date: row.get("start_date"),
517 end_date: row.get("end_date"),
518 total_return: row.get("total_return"),
519 sharpe_ratio: row.get("sharpe_ratio"),
520 max_drawdown: row.get("max_drawdown"),
521 tags,
522 }
523 })
524 .collect();
525
526 Ok(results)
527 }
528
529 fn delete(&self, id: &ResultId) -> Result<bool> {
530 let mut client = self.get_client()?;
531
532 let rows_affected = client.execute(
533 "DELETE FROM backtest_results WHERE id = $1",
534 &[id],
535 ).map_err(|e| SigcError::Runtime(format!("Delete failed: {}", e)))?;
536
537 Ok(rows_affected > 0)
538 }
539
540 fn list_ids(&self) -> Result<Vec<ResultId>> {
541 let mut client = self.get_client()?;
542
543 let rows = client.query(
544 "SELECT id FROM backtest_results ORDER BY created_at DESC",
545 &[],
546 ).map_err(|e| SigcError::Runtime(format!("Query failed: {}", e)))?;
547
548 Ok(rows.iter().map(|row| row.get("id")).collect())
549 }
550
551 fn name(&self) -> &str {
552 "postgres"
553 }
554
555 fn is_available(&self) -> bool {
556 self.get_client().is_ok()
557 }
558}
559
560pub struct ResultStoreRegistry {
562 stores: HashMap<String, Box<dyn ResultStore>>,
563 default: Option<String>,
564}
565
566impl ResultStoreRegistry {
567 pub fn new() -> Self {
568 ResultStoreRegistry {
569 stores: HashMap::new(),
570 default: None,
571 }
572 }
573
574 pub fn register(&mut self, name: &str, store: Box<dyn ResultStore>) {
575 if self.default.is_none() {
576 self.default = Some(name.to_string());
577 }
578 self.stores.insert(name.to_string(), store);
579 }
580
581 pub fn set_default(&mut self, name: &str) {
582 if self.stores.contains_key(name) {
583 self.default = Some(name.to_string());
584 }
585 }
586
587 pub fn get(&self, name: &str) -> Option<&dyn ResultStore> {
588 self.stores.get(name).map(|s| s.as_ref())
589 }
590
591 pub fn default_store(&self) -> Option<&dyn ResultStore> {
592 self.default.as_ref().and_then(|name| self.get(name))
593 }
594
595 pub fn list(&self) -> Vec<String> {
596 self.stores.keys().cloned().collect()
597 }
598}
599
600impl Default for ResultStoreRegistry {
601 fn default() -> Self {
602 Self::new()
603 }
604}
605
606pub fn generate_result_id() -> ResultId {
608 use std::time::{SystemTime, UNIX_EPOCH};
609
610 let timestamp = SystemTime::now()
611 .duration_since(UNIX_EPOCH)
612 .unwrap()
613 .as_millis();
614
615 let random: u32 = rand::random();
616 format!("{:x}-{:08x}", timestamp, random)
617}
618
619#[derive(Debug, Clone)]
621pub struct ResultComparison {
622 pub result_a: ResultMetadata,
623 pub result_b: ResultMetadata,
624 pub return_diff: f64,
625 pub sharpe_diff: f64,
626 pub drawdown_diff: f64,
627 pub winner: ComparisonWinner,
628}
629
630#[derive(Debug, Clone, Copy, PartialEq, Eq)]
631pub enum ComparisonWinner {
632 A,
633 B,
634 Tie,
635}
636
637impl ResultComparison {
638 pub fn compare(a: &ResultMetadata, b: &ResultMetadata) -> Self {
640 let return_diff = a.total_return - b.total_return;
641 let sharpe_diff = a.sharpe_ratio - b.sharpe_ratio;
642 let drawdown_diff = b.max_drawdown - a.max_drawdown; let winner = if sharpe_diff > 0.1 {
646 ComparisonWinner::A
647 } else if sharpe_diff < -0.1 {
648 ComparisonWinner::B
649 } else {
650 ComparisonWinner::Tie
651 };
652
653 ResultComparison {
654 result_a: a.clone(),
655 result_b: b.clone(),
656 return_diff,
657 sharpe_diff,
658 drawdown_diff,
659 winner,
660 }
661 }
662
663 pub fn to_table(&self) -> String {
665 format!(
666 "| Metric | {} | {} | Diff |\n\
667 |--------|------|------|------|\n\
668 | Return | {:.2}% | {:.2}% | {:+.2}% |\n\
669 | Sharpe | {:.2} | {:.2} | {:+.2} |\n\
670 | MaxDD | {:.2}% | {:.2}% | {:+.2}% |",
671 self.result_a.id, self.result_b.id,
672 self.result_a.total_return * 100.0, self.result_b.total_return * 100.0, self.return_diff * 100.0,
673 self.result_a.sharpe_ratio, self.result_b.sharpe_ratio, self.sharpe_diff,
674 self.result_a.max_drawdown * 100.0, self.result_b.max_drawdown * 100.0, self.drawdown_diff * 100.0
675 )
676 }
677}
678
679#[derive(Debug, Clone)]
681pub struct PerformanceHistory {
682 pub strategy_name: String,
683 pub results: Vec<ResultMetadata>,
684}
685
686impl PerformanceHistory {
687 pub fn from_results(strategy_name: &str, results: Vec<ResultMetadata>) -> Self {
689 PerformanceHistory {
690 strategy_name: strategy_name.to_string(),
691 results,
692 }
693 }
694
695 pub fn best_by_sharpe(&self) -> Option<&ResultMetadata> {
697 self.results.iter().max_by(|a, b| {
698 a.sharpe_ratio.partial_cmp(&b.sharpe_ratio).unwrap_or(std::cmp::Ordering::Equal)
699 })
700 }
701
702 pub fn best_by_return(&self) -> Option<&ResultMetadata> {
704 self.results.iter().max_by(|a, b| {
705 a.total_return.partial_cmp(&b.total_return).unwrap_or(std::cmp::Ordering::Equal)
706 })
707 }
708
709 pub fn average_metrics(&self) -> Option<(f64, f64, f64)> {
711 if self.results.is_empty() {
712 return None;
713 }
714
715 let n = self.results.len() as f64;
716 let avg_return = self.results.iter().map(|r| r.total_return).sum::<f64>() / n;
717 let avg_sharpe = self.results.iter().map(|r| r.sharpe_ratio).sum::<f64>() / n;
718 let avg_drawdown = self.results.iter().map(|r| r.max_drawdown).sum::<f64>() / n;
719
720 Some((avg_return, avg_sharpe, avg_drawdown))
721 }
722
723 pub fn trend(&self) -> PerformanceTrend {
725 if self.results.len() < 2 {
726 return PerformanceTrend::Insufficient;
727 }
728
729 let mid = self.results.len() / 2;
731 let first_half: f64 = self.results[..mid].iter().map(|r| r.sharpe_ratio).sum::<f64>() / mid as f64;
732 let second_half: f64 = self.results[mid..].iter().map(|r| r.sharpe_ratio).sum::<f64>() / (self.results.len() - mid) as f64;
733
734 let diff = second_half - first_half;
735 if diff > 0.2 {
736 PerformanceTrend::Improving
737 } else if diff < -0.2 {
738 PerformanceTrend::Declining
739 } else {
740 PerformanceTrend::Stable
741 }
742 }
743
744 pub fn count(&self) -> usize {
746 self.results.len()
747 }
748}
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq)]
751pub enum PerformanceTrend {
752 Improving,
753 Declining,
754 Stable,
755 Insufficient,
756}
757
758impl std::fmt::Display for PerformanceTrend {
759 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
760 match self {
761 PerformanceTrend::Improving => write!(f, "IMPROVING"),
762 PerformanceTrend::Declining => write!(f, "DECLINING"),
763 PerformanceTrend::Stable => write!(f, "STABLE"),
764 PerformanceTrend::Insufficient => write!(f, "INSUFFICIENT DATA"),
765 }
766 }
767}
768
769pub fn load_performance_history(
771 store: &dyn ResultStore,
772 strategy_name: &str,
773) -> Result<PerformanceHistory> {
774 let query = ResultQuery::new()
775 .strategy(strategy_name)
776 .order_by("created_at");
777
778 let results = store.query(&query)?;
779 Ok(PerformanceHistory::from_results(strategy_name, results))
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785
786 fn create_test_report() -> BacktestReport {
787 BacktestReport {
788 metrics: BacktestMetrics {
789 total_return: 0.15,
790 annualized_return: 0.12,
791 sharpe_ratio: 1.5,
792 max_drawdown: 0.08,
793 turnover: 2.0,
794 sortino_ratio: 2.0,
795 calmar_ratio: 1.5,
796 win_rate: 0.55,
797 profit_factor: 1.8,
798 },
799 returns_series: vec![0.01, 0.02, -0.01, 0.03],
800 positions: None,
801 benchmark_metrics: None,
802 executed_at: 1704110400, plan_hash: "test-hash".to_string(),
804 }
805 }
806
807 fn create_test_metadata(id: &str) -> ResultMetadata {
808 let mut tags = HashMap::new();
809 tags.insert("type".to_string(), "momentum".to_string());
810
811 ResultMetadata {
812 id: id.to_string(),
813 strategy_name: "test_strategy".to_string(),
814 strategy_version: Some("1.0".to_string()),
815 created_at: "2024-01-01 12:00:00".to_string(),
816 start_date: "2023-01-01".to_string(),
817 end_date: "2023-12-31".to_string(),
818 total_return: 0.15,
819 sharpe_ratio: 1.5,
820 max_drawdown: 0.08,
821 tags,
822 }
823 }
824
825 #[test]
826 fn test_memory_store_crud() {
827 let store = MemoryResultStore::new();
828 let report = create_test_report();
829 let metadata = create_test_metadata("test-1");
830
831 let id = store.store(&report, metadata.clone()).unwrap();
833 assert_eq!(id, "test-1");
834
835 let (retrieved, meta) = store.get(&id).unwrap().unwrap();
837 assert_eq!(retrieved.metrics.total_return, 0.15);
838 assert_eq!(meta.strategy_name, "test_strategy");
839
840 let ids = store.list_ids().unwrap();
842 assert_eq!(ids.len(), 1);
843
844 let deleted = store.delete(&id).unwrap();
846 assert!(deleted);
847 assert!(store.get(&id).unwrap().is_none());
848 }
849
850 #[test]
851 fn test_memory_store_query() {
852 let store = MemoryResultStore::new();
853
854 for i in 0..5 {
856 let report = create_test_report();
857 let mut metadata = create_test_metadata(&format!("test-{}", i));
858 metadata.sharpe_ratio = i as f64 * 0.5;
859 metadata.strategy_name = if i < 3 { "strategy_a" } else { "strategy_b" }.to_string();
860 store.store(&report, metadata).unwrap();
861 }
862
863 let query = ResultQuery::new().strategy("strategy_a");
865 let results = store.query(&query).unwrap();
866 assert_eq!(results.len(), 3);
867
868 let query = ResultQuery::new().min_sharpe(1.0);
870 let results = store.query(&query).unwrap();
871 assert_eq!(results.len(), 3); let query = ResultQuery::new()
875 .order_by("sharpe_ratio")
876 .limit(2);
877 let results = store.query(&query).unwrap();
878 assert_eq!(results.len(), 2);
879 assert!(results[0].sharpe_ratio >= results[1].sharpe_ratio);
880 }
881
882 #[test]
883 fn test_result_query_builder() {
884 let query = ResultQuery::new()
885 .strategy("momentum")
886 .version("2.0")
887 .min_sharpe(1.5)
888 .limit(10)
889 .tag("type", "equity");
890
891 assert_eq!(query.strategy_name.as_deref(), Some("momentum"));
892 assert_eq!(query.strategy_version.as_deref(), Some("2.0"));
893 assert_eq!(query.min_sharpe, Some(1.5));
894 assert_eq!(query.limit, Some(10));
895 assert_eq!(query.tags.get("type").map(|s| s.as_str()), Some("equity"));
896 }
897
898 #[test]
899 fn test_generate_result_id() {
900 let id1 = generate_result_id();
901 let id2 = generate_result_id();
902
903 assert!(!id1.is_empty());
904 assert_ne!(id1, id2);
905 }
906
907 #[test]
908 fn test_registry() {
909 let mut registry = ResultStoreRegistry::new();
910 registry.register("memory", Box::new(MemoryResultStore::new()));
911
912 assert!(registry.get("memory").is_some());
913 assert!(registry.default_store().is_some());
914 assert_eq!(registry.list().len(), 1);
915 }
916
917 #[test]
918 fn test_result_comparison() {
919 let mut meta_a = create_test_metadata("result-a");
920 meta_a.total_return = 0.20;
921 meta_a.sharpe_ratio = 2.0;
922 meta_a.max_drawdown = 0.05;
923
924 let mut meta_b = create_test_metadata("result-b");
925 meta_b.total_return = 0.10;
926 meta_b.sharpe_ratio = 1.0;
927 meta_b.max_drawdown = 0.10;
928
929 let comparison = ResultComparison::compare(&meta_a, &meta_b);
930
931 assert_eq!(comparison.winner, ComparisonWinner::A);
932 assert!((comparison.return_diff - 0.10).abs() < 0.001);
933 assert!((comparison.sharpe_diff - 1.0).abs() < 0.001);
934 }
935
936 #[test]
937 fn test_performance_history() {
938 let mut results = Vec::new();
939
940 for i in 0..6 {
942 let mut meta = create_test_metadata(&format!("result-{}", i));
943 meta.sharpe_ratio = 1.0 + i as f64 * 0.2;
944 meta.total_return = 0.10 + i as f64 * 0.02;
945 results.push(meta);
946 }
947
948 let history = PerformanceHistory::from_results("test_strategy", results);
949
950 assert_eq!(history.count(), 6);
951 assert_eq!(history.trend(), PerformanceTrend::Improving);
952
953 let best = history.best_by_sharpe().unwrap();
954 assert!((best.sharpe_ratio - 2.0).abs() < 0.001);
955
956 let (avg_return, avg_sharpe, _) = history.average_metrics().unwrap();
957 assert!(avg_return > 0.0);
958 assert!(avg_sharpe > 1.0);
959 }
960
961 #[test]
962 fn test_performance_trend() {
963 let mut results = Vec::new();
965 for i in 0..4 {
966 let mut meta = create_test_metadata(&format!("result-{}", i));
967 meta.sharpe_ratio = 2.0 - i as f64 * 0.3;
968 results.push(meta);
969 }
970
971 let history = PerformanceHistory::from_results("declining", results);
972 assert_eq!(history.trend(), PerformanceTrend::Declining);
973 }
974}