Skip to main content

kestrel_chartkit/
graph.rs

1//! Generic composition graph for indicators with typed dependencies.
2//!
3//! [`Indicator::on_bar`](crate::indicator::Indicator::on_bar) only ever sees the raw [`Bar`] — it
4//! has no way to consume another indicator's output. `engine::pipeline` wires specific engines
5//! together by hand for specific milestones; it is not a reusable graph. [`GraphIndicator`] and
6//! [`CompositionGraph`] fill that gap: nodes declare named dependencies on other nodes' outputs,
7//! the graph topologically sorts them once, and each bar is pushed through in that order with
8//! upstream outputs made available to downstream nodes. Node-local warmup composes with
9//! dependency warmup automatically.
10
11use std::collections::{HashMap, HashSet};
12use std::fmt;
13
14use crate::indicator::{Indicator, IndicatorOutput};
15use crate::model::Bar;
16
17/// A node in a [`CompositionGraph`]. Unlike [`Indicator`], `compute` also receives the current
18/// bar's already-computed outputs of this node's declared [`GraphIndicator::dependencies`].
19pub trait GraphIndicator: Send + Sync {
20    fn name(&self) -> &str;
21    /// Names of other graph nodes whose current-bar output this node reads via `deps` in
22    /// [`GraphIndicator::compute`]. Must match node names actually present in the graph.
23    fn dependencies(&self) -> &[String];
24    fn warmup_period(&self) -> usize {
25        0
26    }
27    fn reset(&mut self);
28    fn compute(
29        &mut self,
30        bar: &Bar,
31        deps: &HashMap<String, IndicatorOutput>,
32    ) -> Option<IndicatorOutput>;
33}
34
35/// Adapts any existing [`Indicator`] into a dependency-free [`GraphIndicator`] leaf node, so the
36/// full existing indicator catalog can be used inside a [`CompositionGraph`] unchanged.
37pub struct Leaf<I: Indicator> {
38    inner: I,
39    dependencies: Vec<String>,
40}
41
42impl<I: Indicator> Leaf<I> {
43    pub fn new(inner: I) -> Self {
44        Self {
45            inner,
46            dependencies: Vec::new(),
47        }
48    }
49}
50
51impl<I: Indicator> GraphIndicator for Leaf<I> {
52    fn name(&self) -> &str {
53        self.inner.name()
54    }
55    fn dependencies(&self) -> &[String] {
56        &self.dependencies
57    }
58    fn warmup_period(&self) -> usize {
59        self.inner.warmup_period()
60    }
61    fn reset(&mut self) {
62        self.inner.reset()
63    }
64    fn compute(
65        &mut self,
66        bar: &Bar,
67        _deps: &HashMap<String, IndicatorOutput>,
68    ) -> Option<IndicatorOutput> {
69        self.inner.on_bar(bar)
70    }
71}
72
73type ComputeFn = Box<
74    dyn FnMut(&Bar, &HashMap<String, IndicatorOutput>) -> Option<IndicatorOutput> + Send + Sync,
75>;
76
77/// A [`GraphIndicator`] built from closures, for nodes that genuinely consume other nodes'
78/// outputs (e.g. a Chandelier Exit reading an upstream ATR node).
79pub struct ComposedNode {
80    name: String,
81    dependencies: Vec<String>,
82    warmup_period: usize,
83    compute_fn: ComputeFn,
84    reset_fn: Box<dyn FnMut() + Send + Sync>,
85}
86
87impl ComposedNode {
88    pub fn new(
89        name: impl Into<String>,
90        dependencies: Vec<String>,
91        warmup_period: usize,
92        compute_fn: impl FnMut(&Bar, &HashMap<String, IndicatorOutput>) -> Option<IndicatorOutput>
93            + Send
94            + Sync
95            + 'static,
96    ) -> Self {
97        Self {
98            name: name.into(),
99            dependencies,
100            warmup_period,
101            compute_fn: Box::new(compute_fn),
102            reset_fn: Box::new(|| {}),
103        }
104    }
105
106    /// Registers a callback invoked on [`CompositionGraph::reset`], for composed nodes that
107    /// capture their own mutable state in the `compute_fn` closure.
108    pub fn with_reset(mut self, reset_fn: impl FnMut() + Send + Sync + 'static) -> Self {
109        self.reset_fn = Box::new(reset_fn);
110        self
111    }
112}
113
114impl GraphIndicator for ComposedNode {
115    fn name(&self) -> &str {
116        &self.name
117    }
118    fn dependencies(&self) -> &[String] {
119        &self.dependencies
120    }
121    fn warmup_period(&self) -> usize {
122        self.warmup_period
123    }
124    fn reset(&mut self) {
125        (self.reset_fn)()
126    }
127    fn compute(
128        &mut self,
129        bar: &Bar,
130        deps: &HashMap<String, IndicatorOutput>,
131    ) -> Option<IndicatorOutput> {
132        (self.compute_fn)(bar, deps)
133    }
134}
135
136/// Error building or running a [`CompositionGraph`].
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum GraphError {
139    DuplicateNode(String),
140    MissingDependency { node: String, dependency: String },
141    CycleDetected,
142}
143
144impl fmt::Display for GraphError {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        match self {
147            GraphError::DuplicateNode(name) => write!(f, "duplicate node name: {}", name),
148            GraphError::MissingDependency { node, dependency } => write!(
149                f,
150                "node '{}' depends on unknown node '{}'",
151                node, dependency
152            ),
153            GraphError::CycleDetected => write!(f, "dependency cycle detected"),
154        }
155    }
156}
157
158impl std::error::Error for GraphError {}
159
160/// A dependency-ordered set of [`GraphIndicator`] nodes, executed together per bar.
161pub struct CompositionGraph {
162    nodes: HashMap<String, Box<dyn GraphIndicator>>,
163    order: Vec<String>,
164    effective_warmup: HashMap<String, usize>,
165}
166
167impl CompositionGraph {
168    /// Builds a graph from `nodes`, computing a topological execution order and each node's
169    /// effective warmup (its own `warmup_period` plus the maximum effective warmup of its
170    /// dependencies). Fails on duplicate node names, references to unknown dependencies, or a
171    /// dependency cycle.
172    pub fn build(nodes: Vec<Box<dyn GraphIndicator>>) -> Result<Self, GraphError> {
173        let mut by_name = HashMap::with_capacity(nodes.len());
174        for node in nodes {
175            let name = node.name().to_string();
176            if by_name.insert(name.clone(), node).is_some() {
177                return Err(GraphError::DuplicateNode(name));
178            }
179        }
180
181        for (name, node) in &by_name {
182            for dep in node.dependencies() {
183                if !by_name.contains_key(dep) {
184                    return Err(GraphError::MissingDependency {
185                        node: name.clone(),
186                        dependency: dep.clone(),
187                    });
188                }
189            }
190        }
191
192        let order = topological_order(&by_name)?;
193
194        let mut effective_warmup: HashMap<String, usize> = HashMap::with_capacity(order.len());
195        for name in &order {
196            let node = &by_name[name];
197            let dep_warmup = node
198                .dependencies()
199                .iter()
200                .map(|dep| effective_warmup[dep])
201                .max()
202                .unwrap_or(0);
203            effective_warmup.insert(name.clone(), node.warmup_period() + dep_warmup);
204        }
205
206        Ok(Self {
207            nodes: by_name,
208            order,
209            effective_warmup,
210        })
211    }
212
213    /// Execution order computed at [`CompositionGraph::build`] time (dependencies before
214    /// dependents).
215    pub fn order(&self) -> &[String] {
216        &self.order
217    }
218
219    /// A node's effective warmup: its own warmup plus the maximum effective warmup among its
220    /// dependencies.
221    pub fn effective_warmup(&self, name: &str) -> Option<usize> {
222        self.effective_warmup.get(name).copied()
223    }
224
225    /// Runs one bar through every node in dependency order, making each node's output available
226    /// to its dependents within the same call. Returns every node's output for this bar.
227    pub fn on_bar(&mut self, bar: &Bar) -> HashMap<String, Option<IndicatorOutput>> {
228        let mut outputs: HashMap<String, Option<IndicatorOutput>> =
229            HashMap::with_capacity(self.order.len());
230        let mut resolved: HashMap<String, IndicatorOutput> = HashMap::new();
231
232        for name in self.order.clone() {
233            let node = self.nodes.get_mut(&name).expect("node in order exists");
234            let deps: HashMap<String, IndicatorOutput> = node
235                .dependencies()
236                .iter()
237                .filter_map(|dep| resolved.get(dep).map(|o| (dep.clone(), o.clone())))
238                .collect();
239
240            let output = node.compute(bar, &deps);
241            if let Some(o) = &output {
242                resolved.insert(name.clone(), o.clone());
243            }
244            outputs.insert(name, output);
245        }
246
247        outputs
248    }
249
250    pub fn reset(&mut self) {
251        for node in self.nodes.values_mut() {
252            node.reset();
253        }
254    }
255}
256
257/// Kahn's algorithm: repeatedly removes nodes with no unprocessed dependencies. Any nodes left
258/// over once no more can be removed are part of a cycle.
259fn topological_order(
260    nodes: &HashMap<String, Box<dyn GraphIndicator>>,
261) -> Result<Vec<String>, GraphError> {
262    let mut remaining_deps: HashMap<&str, HashSet<&str>> = nodes
263        .iter()
264        .map(|(name, node)| {
265            (
266                name.as_str(),
267                node.dependencies().iter().map(|d| d.as_str()).collect(),
268            )
269        })
270        .collect();
271
272    let mut order = Vec::with_capacity(nodes.len());
273    loop {
274        let ready: Vec<&str> = remaining_deps
275            .iter()
276            .filter(|(_, deps)| deps.is_empty())
277            .map(|(name, _)| *name)
278            .collect();
279
280        if ready.is_empty() {
281            break;
282        }
283
284        let mut ready = ready;
285        ready.sort_unstable();
286        for name in ready {
287            remaining_deps.remove(name);
288            order.push(name.to_string());
289        }
290
291        for deps in remaining_deps.values_mut() {
292            for done in &order {
293                deps.remove(done.as_str());
294            }
295        }
296    }
297
298    if order.len() != nodes.len() {
299        return Err(GraphError::CycleDetected);
300    }
301
302    Ok(order)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::indicator::atr::Atr;
309
310    fn sample_bars() -> Vec<Bar> {
311        (0..20)
312            .map(|i| {
313                let c = 100.0 + i as f64;
314                Bar::new(i * 60, c, c + 2.0, c - 2.0, c, 100.0)
315            })
316            .collect()
317    }
318
319    #[test]
320    fn test_graph_orders_dependencies_before_dependents() {
321        let atr_leaf = Box::new(Leaf::new(Atr::new(5, 5)));
322        let derived = Box::new(ComposedNode::new(
323            "double_atr",
324            vec!["atr".to_string()],
325            0,
326            |_bar, deps| deps.get("atr").map(|o| IndicatorOutput::new(o.value * 2.0)),
327        ));
328
329        let graph = CompositionGraph::build(vec![atr_leaf, derived]).unwrap();
330        assert_eq!(
331            graph.order(),
332            &["atr".to_string(), "double_atr".to_string()]
333        );
334        // double_atr's effective warmup includes atr's warmup.
335        assert_eq!(
336            graph.effective_warmup("double_atr"),
337            graph.effective_warmup("atr")
338        );
339    }
340
341    #[test]
342    fn test_graph_propagates_dependency_output_same_bar() {
343        let atr_leaf = Box::new(Leaf::new(Atr::new(3, 3)));
344        let derived = Box::new(ComposedNode::new(
345            "double_atr",
346            vec!["atr".to_string()],
347            0,
348            |_bar, deps| deps.get("atr").map(|o| IndicatorOutput::new(o.value * 2.0)),
349        ));
350
351        let mut graph = CompositionGraph::build(vec![atr_leaf, derived]).unwrap();
352        let bars = sample_bars();
353        let mut last_outputs = HashMap::new();
354        for bar in &bars {
355            last_outputs = graph.on_bar(bar);
356        }
357
358        let atr_value = last_outputs["atr"].as_ref().unwrap().value;
359        let derived_value = last_outputs["double_atr"].as_ref().unwrap().value;
360        assert!((derived_value - atr_value * 2.0).abs() < 1e-9);
361    }
362
363    #[test]
364    fn test_graph_rejects_missing_dependency() {
365        let derived = Box::new(ComposedNode::new(
366            "double_atr",
367            vec!["missing_atr".to_string()],
368            0,
369            |_bar, _deps| None,
370        ));
371        let err = match CompositionGraph::build(vec![derived]) {
372            Err(e) => e,
373            Ok(_) => panic!("Expected missing dependency error"),
374        };
375        assert_eq!(
376            err,
377            GraphError::MissingDependency {
378                node: "double_atr".to_string(),
379                dependency: "missing_atr".to_string(),
380            }
381        );
382    }
383
384    #[test]
385    fn test_graph_rejects_cycle() {
386        let a = Box::new(ComposedNode::new("a", vec!["b".to_string()], 0, |_, _| {
387            None
388        }));
389        let b = Box::new(ComposedNode::new("b", vec!["a".to_string()], 0, |_, _| {
390            None
391        }));
392        let err = match CompositionGraph::build(vec![a, b]) {
393            Err(e) => e,
394            Ok(_) => panic!("Expected cycle error"),
395        };
396        assert_eq!(err, GraphError::CycleDetected);
397    }
398
399    #[test]
400    fn test_graph_rejects_duplicate_node_name() {
401        let a1 = Box::new(ComposedNode::new("a", vec![], 0, |_, _| None));
402        let a2 = Box::new(ComposedNode::new("a", vec![], 0, |_, _| None));
403        let err = match CompositionGraph::build(vec![a1, a2]) {
404            Err(e) => e,
405            Ok(_) => panic!("Expected duplicate node error"),
406        };
407        assert_eq!(err, GraphError::DuplicateNode("a".to_string()));
408    }
409}