Skip to main content

sig_runtime/
result_store.rs

1//! Result persistence for backtest results
2//!
3//! Stores and retrieves backtest results from various backends.
4
5use sig_types::{BacktestReport, BacktestMetrics, Result, SigcError};
6use std::collections::HashMap;
7use std::sync::{Arc, RwLock};
8
9/// Unique identifier for a stored result
10pub type ResultId = String;
11
12/// Metadata about a stored result
13#[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/// Query parameters for retrieving results
28#[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
77/// Trait for storing and retrieving backtest results
78pub trait ResultStore: Send + Sync {
79    /// Store a backtest result
80    fn store(&self, report: &BacktestReport, metadata: ResultMetadata) -> Result<ResultId>;
81
82    /// Retrieve a result by ID
83    fn get(&self, id: &ResultId) -> Result<Option<(BacktestReport, ResultMetadata)>>;
84
85    /// Query results by criteria
86    fn query(&self, query: &ResultQuery) -> Result<Vec<ResultMetadata>>;
87
88    /// Delete a result
89    fn delete(&self, id: &ResultId) -> Result<bool>;
90
91    /// List all result IDs
92    fn list_ids(&self) -> Result<Vec<ResultId>>;
93
94    /// Get store name
95    fn name(&self) -> &str;
96
97    /// Check if store is available
98    fn is_available(&self) -> bool;
99}
100
101/// In-memory result store for testing
102pub 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                // Filter by strategy name
143                if let Some(ref name) = query.strategy_name {
144                    if &meta.strategy_name != name {
145                        return false;
146                    }
147                }
148
149                // Filter by version
150                if let Some(ref version) = query.strategy_version {
151                    if meta.strategy_version.as_ref() != Some(version) {
152                        return false;
153                    }
154                }
155
156                // Filter by min sharpe
157                if let Some(min_sharpe) = query.min_sharpe {
158                    if meta.sharpe_ratio < min_sharpe {
159                        return false;
160                    }
161                }
162
163                // Filter by max drawdown
164                if let Some(max_dd) = query.max_drawdown {
165                    if meta.max_drawdown > max_dd {
166                        return false;
167                    }
168                }
169
170                // Filter by tags
171                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        // Sort
183        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        // Limit
197        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
225/// PostgreSQL result store
226pub 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    /// Create from environment variables
246    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    /// Initialize database schema
269    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        // Serialize returns_series as comma-separated string
307        let returns_str: String = report.returns_series
308            .iter()
309            .map(|r| r.to_string())
310            .collect::<Vec<_>>()
311            .join(",");
312
313        // Serialize tags as key=value pairs
314        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                // Parse returns series
372                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                // Parse tags
380                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, // Timestamp not stored, use 0
412                    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        // Order by
471        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        // Limit
482        if let Some(limit) = query.limit {
483            sql.push_str(&format!(" LIMIT {}", limit));
484        }
485
486        // Convert params for query
487        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, &param_refs)
491            .map_err(|e| SigcError::Runtime(format!("Query failed: {}", e)))?;
492
493        let results: Vec<ResultMetadata> = rows
494            .iter()
495            .map(|row| {
496                // Parse tags
497                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
560/// Registry for managing multiple result stores
561pub 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
606/// Generate a unique result ID
607pub 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/// Result comparison between two backtests
620#[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    /// Compare two results
639    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; // Lower is better
643
644        // Determine winner based on Sharpe ratio
645        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    /// Format comparison as a table
664    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/// Historical performance tracking for a strategy
680#[derive(Debug, Clone)]
681pub struct PerformanceHistory {
682    pub strategy_name: String,
683    pub results: Vec<ResultMetadata>,
684}
685
686impl PerformanceHistory {
687    /// Create from query results
688    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    /// Get best result by Sharpe ratio
696    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    /// Get best result by total return
703    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    /// Get average metrics across all results
710    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    /// Get performance trend (improving/declining/stable)
724    pub fn trend(&self) -> PerformanceTrend {
725        if self.results.len() < 2 {
726            return PerformanceTrend::Insufficient;
727        }
728
729        // Compare first half to second half
730        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    /// Count results
745    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
769/// Helper to load performance history from a store
770pub 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, // 2024-01-01 12:00:00 UTC
803            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        // Store
832        let id = store.store(&report, metadata.clone()).unwrap();
833        assert_eq!(id, "test-1");
834
835        // Get
836        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        // List
841        let ids = store.list_ids().unwrap();
842        assert_eq!(ids.len(), 1);
843
844        // Delete
845        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        // Add multiple results
855        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        // Query by strategy
864        let query = ResultQuery::new().strategy("strategy_a");
865        let results = store.query(&query).unwrap();
866        assert_eq!(results.len(), 3);
867
868        // Query with min sharpe
869        let query = ResultQuery::new().min_sharpe(1.0);
870        let results = store.query(&query).unwrap();
871        assert_eq!(results.len(), 3); // sharpe >= 1.0
872
873        // Query with limit and order
874        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        // Add results with increasing sharpe (improving trend)
941        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        // Declining trend
964        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}