Skip to main content

chronix_analytics/multivariate/
derived.rs

1//! Derived series engine with DAG-based dependency resolution.
2
3use std::collections::HashMap;
4
5use crate::multivariate::context::MultiSeriesContext;
6use crate::multivariate::error::MultivariateError;
7
8/// An expression that produces a derived series from a context.
9pub trait DerivedSeriesExpr: Send + Sync {
10    /// Evaluate the expression against the given context.
11    fn evaluate(&self, ctx: &MultiSeriesContext) -> Result<Vec<f64>, MultivariateError>;
12}
13
14/// Definition of a derived series.
15pub struct DerivedSeriesDefinition {
16    /// Name of the derived series.
17    pub name: String,
18    /// Expression that computes the derived values.
19    pub expression: Box<dyn DerivedSeriesExpr>,
20    /// Names of the source series this definition depends on.
21    pub dependencies: Vec<String>,
22}
23
24// ─── Built-in expressions ───────────────────────────────────────────
25
26/// Arithmetic on two named series.
27pub struct ArithmeticExpr {
28    /// Name of the left operand series.
29    pub left: String,
30    /// Name of the right operand series.
31    pub right: String,
32    /// Binary arithmetic operator.
33    pub op: ArithOp,
34}
35
36/// Binary arithmetic operators for derived series.
37#[derive(Debug, Clone, Copy)]
38pub enum ArithOp {
39    /// Element-wise addition.
40    Add,
41    /// Element-wise subtraction.
42    Sub,
43    /// Element-wise multiplication.
44    Mul,
45    /// Element-wise division (safe: divides by zero yield 0.0).
46    Div,
47}
48
49impl DerivedSeriesExpr for ArithmeticExpr {
50    fn evaluate(&self, ctx: &MultiSeriesContext) -> Result<Vec<f64>, MultivariateError> {
51        let l = ctx
52            .matrix
53            .series_by_name(&self.left)
54            .ok_or_else(|| MultivariateError::SeriesNotFound(self.left.clone()))?;
55        let r = ctx
56            .matrix
57            .series_by_name(&self.right)
58            .ok_or_else(|| MultivariateError::SeriesNotFound(self.right.clone()))?;
59        if l.len() != r.len() {
60            return Err(MultivariateError::DimensionMismatch {
61                expected: l.len(),
62                got: r.len(),
63            });
64        }
65        let result = match self.op {
66            ArithOp::Add => l.iter().zip(r.iter()).map(|(a, b)| a + b).collect(),
67            ArithOp::Sub => l.iter().zip(r.iter()).map(|(a, b)| a - b).collect(),
68            ArithOp::Mul => l.iter().zip(r.iter()).map(|(a, b)| a * b).collect(),
69            ArithOp::Div => l
70                .iter()
71                .zip(r.iter())
72                .map(|(a, b)| if b.abs() > 1e-15 { a / b } else { 0.0 })
73                .collect(),
74        };
75        Ok(result)
76    }
77}
78
79/// Log returns: ln(p_t / p_{t-1}).
80pub struct LogReturnExpr {
81    /// Name of the price / value series.
82    pub series_name: String,
83}
84
85impl DerivedSeriesExpr for LogReturnExpr {
86    fn evaluate(&self, ctx: &MultiSeriesContext) -> Result<Vec<f64>, MultivariateError> {
87        let s = ctx
88            .matrix
89            .series_by_name(&self.series_name)
90            .ok_or_else(|| MultivariateError::SeriesNotFound(self.series_name.clone()))?;
91        let mut result = Vec::with_capacity(s.len());
92        result.push(0.0); // first point has no return
93        for w in s.windows(2) {
94            if w[0].abs() > 1e-15 {
95                let ratio = w[1] / w[0];
96                if ratio > 0.0 {
97                    result.push(ratio.ln());
98                } else {
99                    result.push(f64::NAN);
100                }
101            } else {
102                result.push(0.0);
103            }
104        }
105        Ok(result)
106    }
107}
108
109/// Rolling statistic expression.
110pub struct RollingStatExpr {
111    /// Name of the source series.
112    pub series_name: String,
113    /// Window size (number of observations).
114    pub window: usize,
115    /// Which rolling statistic to compute.
116    pub stat: RollingStat,
117}
118
119/// Supported rolling statistics.
120#[derive(Debug, Clone, Copy)]
121pub enum RollingStat {
122    /// Rolling arithmetic mean.
123    Mean,
124    /// Rolling standard deviation.
125    Std,
126    /// Rolling minimum.
127    Min,
128    /// Rolling maximum.
129    Max,
130}
131
132impl DerivedSeriesExpr for RollingStatExpr {
133    fn evaluate(&self, ctx: &MultiSeriesContext) -> Result<Vec<f64>, MultivariateError> {
134        let s = ctx
135            .matrix
136            .series_by_name(&self.series_name)
137            .ok_or_else(|| MultivariateError::SeriesNotFound(self.series_name.clone()))?;
138        let n = s.len();
139        let mut result = Vec::with_capacity(n);
140        let mut sum = 0.0;
141        let mut sum_sq = 0.0;
142        // Monotone deques for O(n) amortised rolling min/max
143        let mut min_deque: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
144        let mut max_deque: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
145
146        for i in 0..n {
147            sum += s[i];
148            sum_sq += s[i] * s[i];
149            if i >= self.window {
150                sum -= s[i - self.window];
151                sum_sq -= s[i - self.window] * s[i - self.window];
152            }
153
154            // Periodic FP drift recomputation every 1024 steps.
155            if i > 0 && i % 1024 == 0 {
156                let start = i.saturating_sub(self.window.saturating_sub(1));
157                sum = s[start..=i].iter().sum();
158                sum_sq = s[start..=i].iter().map(|&v| v * v).sum();
159            }
160
161            let count = (i + 1).min(self.window) as f64;
162            match self.stat {
163                RollingStat::Mean => {
164                    result.push(sum / count);
165                }
166                RollingStat::Std => {
167                    if count < 2.0 {
168                        result.push(0.0);
169                    } else {
170                        // Bessel's correction: sample std = sqrt(Var/(n-1))
171                        let var = ((sum_sq - sum * sum / count) / (count - 1.0)).max(0.0);
172                        result.push(var.sqrt());
173                    }
174                }
175                RollingStat::Min => {
176                    // Remove elements that fell out of the window
177                    while min_deque.front().is_some_and(|&f| f + self.window <= i) {
178                        min_deque.pop_front();
179                    }
180                    // Maintain increasing monotone deque
181                    while min_deque.back().is_some_and(|&b| s[b] >= s[i]) {
182                        min_deque.pop_back();
183                    }
184                    min_deque.push_back(i);
185                    result.push(s[min_deque[0]]);
186                }
187                RollingStat::Max => {
188                    // Remove elements that fell out of the window
189                    while max_deque.front().is_some_and(|&f| f + self.window <= i) {
190                        max_deque.pop_front();
191                    }
192                    // Maintain decreasing monotone deque
193                    while max_deque.back().is_some_and(|&b| s[b] <= s[i]) {
194                        max_deque.pop_back();
195                    }
196                    max_deque.push_back(i);
197                    result.push(s[max_deque[0]]);
198                }
199            }
200        }
201        Ok(result)
202    }
203}
204
205// ─── Lazy Derived Series ────────────────────────────────────────────
206
207/// Lazy wrapper around a derived series definition that caches its result.
208///
209/// The expression is only evaluated when `get()` is called for the first time
210/// (or after `invalidate()`), and the result is cached for subsequent reads.
211pub struct LazyDerivedSeries {
212    definition: DerivedSeriesDefinition,
213    cache: parking_lot::Mutex<Option<Vec<f64>>>,
214}
215
216impl LazyDerivedSeries {
217    /// Creates a new lazy-evaluated derived series.
218    pub fn new(definition: DerivedSeriesDefinition) -> Self {
219        Self {
220            definition,
221            cache: parking_lot::Mutex::new(None),
222        }
223    }
224
225    /// Returns the cached result, evaluating the expression if needed.
226    pub fn get(&self, ctx: &MultiSeriesContext) -> Result<Vec<f64>, MultivariateError> {
227        let mut cache = self.cache.lock();
228        if let Some(ref cached) = *cache {
229            return Ok(cached.clone());
230        }
231        let result = self.definition.expression.evaluate(ctx)?;
232        *cache = Some(result.clone());
233        Ok(result)
234    }
235
236    /// Invalidates the cached result, forcing re-evaluation on next `get()`.
237    pub fn invalidate(&self) {
238        let mut cache = self.cache.lock();
239        *cache = None;
240    }
241
242    /// Returns true if the result is currently cached.
243    pub fn is_cached(&self) -> bool {
244        self.cache.lock().is_some()
245    }
246
247    /// Returns the underlying definition.
248    pub fn definition(&self) -> &DerivedSeriesDefinition {
249        &self.definition
250    }
251}
252
253// ─── DAG Engine ─────────────────────────────────────────────────────
254
255/// Engine that evaluates derived series in topological order.
256///
257/// # Evaluation Strategy
258///
259/// Definitions are topologically sorted into **wave-front levels**:
260/// nodes within the same level share no mutual dependencies and are
261/// evaluated in parallel via `rayon`.  After each level completes,
262/// results are merged into the [`MultiSeriesContext`] so that the
263/// next level's expressions can reference them.
264pub struct DerivedSeriesEngine {
265    definitions: Vec<DerivedSeriesDefinition>,
266}
267
268impl DerivedSeriesEngine {
269    /// Creates a new engine from the given definitions.
270    pub fn new(definitions: Vec<DerivedSeriesDefinition>) -> Self {
271        Self { definitions }
272    }
273
274    /// Topologically sort and evaluate all definitions.
275    ///
276    /// Nodes within the same wave-front level are evaluated in parallel
277    /// via rayon.  Results are merged into the context after each level
278    /// so that the next level's expressions can reference them.
279    pub fn evaluate(
280        &self,
281        ctx: &mut MultiSeriesContext,
282    ) -> Result<HashMap<String, Vec<f64>>, MultivariateError> {
283        use rayon::prelude::*;
284
285        let _start = std::time::Instant::now();
286        let levels = self.topological_sort_levels()?;
287        let mut results = HashMap::new();
288
289        for level in &levels {
290            if level.len() == 1 {
291                // Single node — no parallelism overhead.
292                let def = &self.definitions[level[0]];
293                let vals = def.expression.evaluate(ctx)?;
294                Self::insert_into_context(ctx, &def.name, vals.clone());
295                results.insert(def.name.clone(), vals);
296            } else {
297                // Multiple independent nodes — evaluate in parallel.
298                let wave_results: Vec<Result<(String, Vec<f64>), MultivariateError>> = level
299                    .par_iter()
300                    .map(|&idx| {
301                        let def = &self.definitions[idx];
302                        let vals = def.expression.evaluate(ctx)?;
303                        Ok((def.name.clone(), vals))
304                    })
305                    .collect();
306
307                // Merge wave results into context (sequential).
308                for result in wave_results {
309                    let (name, vals) = result?;
310                    Self::insert_into_context(ctx, &name, vals.clone());
311                    results.insert(name, vals);
312                }
313            }
314        }
315
316        metrics::histogram!("chronix_derived_series_eval_duration_seconds")
317            .record(_start.elapsed().as_secs_f64());
318        Ok(results)
319    }
320
321    /// Insert or update a series in the context matrix.
322    fn insert_into_context(ctx: &mut MultiSeriesContext, name: &str, vals: Vec<f64>) {
323        if let Some(pos) = ctx.matrix.series_ids.iter().position(|n| n == name) {
324            ctx.matrix.data[pos] = vals;
325        } else {
326            ctx.matrix.series_ids.push(name.to_string());
327            ctx.matrix.data.push(vals);
328        }
329    }
330
331    /// Topological sort that returns levels (wave-fronts).
332    ///
333    /// All nodes within a level have zero remaining in-degree and can
334    /// be evaluated concurrently.
335    fn topological_sort_levels(&self) -> Result<Vec<Vec<usize>>, MultivariateError> {
336        let n = self.definitions.len();
337        let name_to_idx: HashMap<&str, usize> = self
338            .definitions
339            .iter()
340            .enumerate()
341            .map(|(i, d)| (d.name.as_str(), i))
342            .collect();
343
344        // Build adjacency list
345        let mut in_degree = vec![0usize; n];
346        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
347        for (i, def) in self.definitions.iter().enumerate() {
348            for dep in &def.dependencies {
349                if let Some(&j) = name_to_idx.get(dep.as_str()) {
350                    adj[j].push(i);
351                    in_degree[i] += 1;
352                }
353            }
354        }
355
356        // Kahn's algorithm — level-by-level.
357        let mut current_level: Vec<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
358        let mut levels = Vec::new();
359        let mut processed = 0usize;
360
361        while !current_level.is_empty() {
362            let mut next_level = Vec::new();
363            for &node in &current_level {
364                processed += 1;
365                for &next in &adj[node] {
366                    in_degree[next] -= 1;
367                    if in_degree[next] == 0 {
368                        next_level.push(next);
369                    }
370                }
371            }
372            levels.push(current_level);
373            current_level = next_level;
374        }
375
376        if processed != n {
377            return Err(MultivariateError::CycleDetected);
378        }
379        Ok(levels)
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    fn test_ctx() -> MultiSeriesContext {
388        MultiSeriesContext::build(
389            vec![
390                (
391                    "a".to_string(),
392                    vec![0, 1_000_000_000, 2_000_000_000],
393                    vec![10.0, 20.0, 30.0],
394                ),
395                (
396                    "b".to_string(),
397                    vec![0, 1_000_000_000, 2_000_000_000],
398                    vec![5.0, 10.0, 15.0],
399                ),
400            ],
401            None,
402        )
403        .unwrap()
404    }
405
406    #[test]
407    fn arithmetic_add() {
408        let ctx = test_ctx();
409        let expr = ArithmeticExpr {
410            left: "a".to_string(),
411            right: "b".to_string(),
412            op: ArithOp::Add,
413        };
414        let result = expr.evaluate(&ctx).unwrap();
415        assert!((result[0] - 15.0).abs() < 1e-6);
416        assert!((result[1] - 30.0).abs() < 1e-6);
417        assert!((result[2] - 45.0).abs() < 1e-6);
418    }
419
420    #[test]
421    fn log_return() {
422        let ctx = test_ctx();
423        let expr = LogReturnExpr {
424            series_name: "a".to_string(),
425        };
426        let result = expr.evaluate(&ctx).unwrap();
427        assert!((result[0] - 0.0).abs() < 1e-6);
428        assert!((result[1] - (20.0f64 / 10.0).ln()).abs() < 1e-6);
429    }
430
431    #[test]
432    fn dag_evaluation() {
433        let mut ctx = test_ctx();
434        let defs = vec![DerivedSeriesDefinition {
435            name: "spread".to_string(),
436            expression: Box::new(ArithmeticExpr {
437                left: "a".to_string(),
438                right: "b".to_string(),
439                op: ArithOp::Sub,
440            }),
441            dependencies: vec![],
442        }];
443        let engine = DerivedSeriesEngine::new(defs);
444        let results = engine.evaluate(&mut ctx).unwrap();
445        let spread = &results["spread"];
446        assert!((spread[0] - 5.0).abs() < 1e-6);
447        assert!((spread[1] - 10.0).abs() < 1e-6);
448    }
449
450    #[test]
451    fn cycle_detection() {
452        let defs = vec![
453            DerivedSeriesDefinition {
454                name: "x".to_string(),
455                expression: Box::new(ArithmeticExpr {
456                    left: "a".to_string(),
457                    right: "y".to_string(),
458                    op: ArithOp::Add,
459                }),
460                dependencies: vec!["y".to_string()],
461            },
462            DerivedSeriesDefinition {
463                name: "y".to_string(),
464                expression: Box::new(ArithmeticExpr {
465                    left: "a".to_string(),
466                    right: "x".to_string(),
467                    op: ArithOp::Add,
468                }),
469                dependencies: vec!["x".to_string()],
470            },
471        ];
472        let engine = DerivedSeriesEngine::new(defs);
473        let mut ctx = test_ctx();
474        assert!(engine.evaluate(&mut ctx).is_err());
475    }
476
477    #[test]
478    fn lazy_derived_series_caches_result() {
479        let ctx = test_ctx();
480
481        let def = DerivedSeriesDefinition {
482            name: "double_a".to_string(),
483            expression: Box::new(ArithmeticExpr {
484                left: "a".to_string(),
485                right: "a".to_string(),
486                op: ArithOp::Add,
487            }),
488            dependencies: vec!["a".to_string()],
489        };
490
491        let lazy = LazyDerivedSeries::new(def);
492
493        // Not cached initially
494        assert!(!lazy.is_cached());
495
496        // First call evaluates
497        let result = lazy.get(&ctx).unwrap();
498        assert_eq!(result, vec![20.0, 40.0, 60.0]);
499        assert!(lazy.is_cached());
500
501        // Second call returns cached
502        let result2 = lazy.get(&ctx).unwrap();
503        assert_eq!(result2, vec![20.0, 40.0, 60.0]);
504
505        // Invalidate clears cache
506        lazy.invalidate();
507        assert!(!lazy.is_cached());
508    }
509}