Skip to main content

qs_strategy/
material.rs

1use std::collections::VecDeque;
2use std::sync::Arc;
3
4use chrono::{Datelike, NaiveDateTime, Timelike};
5use qs_core::Side;
6
7use crate::{
8    BarField, CompileError, ConfiguredActionKind, EvaluationError, FeedbackField, MaterialArg,
9    MaterialArgs, PositionField, ScalarType, SourceId, Value, ValueType,
10};
11
12pub const MATERIAL_BAR_FIELD: &str = "completed_bar_field";
13pub const MATERIAL_INPUT_TIME: &str = "input_time";
14pub const MATERIAL_READINESS: &str = "readiness";
15/// ISO weekday of the authoritative input time, Monday = 1 through Sunday = 7.
16pub const MATERIAL_WEEKDAY: &str = "weekday";
17/// Seconds elapsed since midnight of the authoritative input time, 0 through 86399.
18pub const MATERIAL_SECONDS_OF_DAY: &str = "seconds_of_day";
19pub const MATERIAL_EMA: &str = "ema";
20pub const MATERIAL_ATR: &str = "atr";
21pub const MATERIAL_SMA: &str = "sma";
22pub const MATERIAL_STRICT_SMA: &str = "strict_sma";
23pub const MATERIAL_STRICT_EMA: &str = "strict_ema";
24pub const MATERIAL_STDDEV: &str = "stddev";
25pub const MATERIAL_ROLLING_MIN: &str = "rolling_min";
26pub const MATERIAL_ROLLING_MAX: &str = "rolling_max";
27pub const MATERIAL_LAG: &str = "lag";
28pub const MATERIAL_RSI: &str = "rsi";
29pub const MATERIAL_CROSS_ABOVE: &str = "cross_above";
30pub const MATERIAL_CROSS_BELOW: &str = "cross_below";
31pub const MATERIAL_POSITION_EXISTS: &str = "position_exists";
32pub const MATERIAL_POSITION_PENDING: &str = "position_pending";
33pub const MATERIAL_POSITION_OPEN: &str = "position_open";
34pub const MATERIAL_POSITION_ENTRY_PRICE: &str = "position_entry_price";
35pub const MATERIAL_POSITION_SIDE: &str = "position_side";
36pub const MATERIAL_POSITION_REMAINING_SIZE: &str = "position_remaining_size";
37pub const MATERIAL_POSITION_STOPLOSS: &str = "position_stoploss";
38pub const MATERIAL_POSITION_OPENED_AT: &str = "position_opened_at";
39pub const MATERIAL_POSITION_FAVORABLE_EXCURSION: &str = "position_favorable_excursion";
40pub const MATERIAL_POSITION_ADVERSE_EXCURSION: &str = "position_adverse_excursion";
41pub const MATERIAL_POSITION_INITIAL_RISK: &str = "position_initial_risk";
42pub const MATERIAL_BARS_SINCE_OPEN: &str = "bars_since_open";
43pub const MATERIAL_ENTRY_FILLED: &str = "entry_filled";
44pub const MATERIAL_ENTRY_REJECTED: &str = "entry_rejected";
45pub const MATERIAL_POSITION_CLOSED: &str = "position_closed";
46pub const MATERIAL_CANCELLATION_APPLIED: &str = "cancellation_applied";
47pub const MATERIAL_CANCELLATION_REJECTED: &str = "cancellation_rejected";
48
49#[derive(Debug, Clone, PartialEq)]
50pub struct CompletedBar {
51    pub open: f64,
52    pub high: f64,
53    pub low: f64,
54    pub close: f64,
55    pub volume: Option<f64>,
56}
57
58#[derive(Debug, Clone, PartialEq)]
59pub struct CompletedBarUpdate {
60    pub source: SourceId,
61    pub bar: CompletedBar,
62}
63
64#[derive(Debug, Clone, PartialEq)]
65pub struct NamedValue {
66    pub name: String,
67    pub value: Value,
68    pub updated: bool,
69}
70
71#[derive(Debug, Clone, PartialEq)]
72pub enum TradeSlotState {
73    Vacant,
74    Pending {
75        side: Side,
76        requested_price: Option<f64>,
77        stoploss: Option<f64>,
78    },
79    Open {
80        side: Side,
81        entry_price: f64,
82        remaining_size: f64,
83        stoploss: Option<f64>,
84        /// Authoritative time of the committed entry fill.
85        opened_at: NaiveDateTime,
86        /// Best campaign profit and loss since entry in the adapter's account currency, never below zero, or missing when the adapter cannot price the position yet.
87        favorable_excursion: Option<f64>,
88        /// Worst campaign profit and loss since entry in the adapter's account currency, never above zero, or missing when the adapter cannot price the position yet.
89        adverse_excursion: Option<f64>,
90        /// Positive initial risk amount in account currency for R normalization, or missing when the entry has no usable protective stop.
91        initial_risk: Option<f64>,
92    },
93}
94
95#[derive(Debug, Clone, PartialEq)]
96pub struct TradeSlotFacts {
97    pub slot: String,
98    pub state: TradeSlotState,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
102pub enum CommandFact {
103    EntryFilled,
104    PositionReduced,
105    PositionClosed,
106    StoplossModified,
107    PendingCancelled,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum CommandTerminalStatus {
112    Applied,
113    Skipped,
114    Rejected,
115    Failed,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum CommandFeedback {
120    Fact {
121        command_id: String,
122        fact: CommandFact,
123    },
124    Terminal {
125        command_id: String,
126        status: CommandTerminalStatus,
127        reason: Option<String>,
128    },
129}
130
131impl CommandFeedback {
132    pub fn command_id(&self) -> &str {
133        match self {
134            Self::Fact { command_id, .. } | Self::Terminal { command_id, .. } => command_id,
135        }
136    }
137}
138
139#[derive(Debug, Clone, PartialEq)]
140pub struct StrategyInput {
141    pub time: NaiveDateTime,
142    pub ready: bool,
143    pub completed_bars: Vec<CompletedBarUpdate>,
144    pub values: Vec<NamedValue>,
145    pub trade_slots: Vec<TradeSlotFacts>,
146    pub feedback: Vec<CommandFeedback>,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct CompletedBarRequirement {
151    pub source: SourceId,
152    pub required_lookback: usize,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct NamedInputRequirement {
157    pub name: String,
158    pub value_type: ValueType,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct ConfiguredStrategyRequirements {
163    pub completed_bars: Vec<CompletedBarRequirement>,
164    /// Sources whose documents directly consume exact completed-bar count.
165    pub count_required_sources: Vec<SourceId>,
166    pub named_inputs: Vec<NamedInputRequirement>,
167    pub trade_slots: Vec<String>,
168    pub needs_command_feedback: bool,
169    /// Every distinct trade slot and routing class an Entry action can emit, in sorted order.
170    pub entries: Vec<EntryRequirement>,
171    /// Trade slots whose stoploss the strategy moves itself through `ModifyStoploss` or `MoveStoplossToEntry`, in sorted order.
172    pub stop_managed_slots: Vec<String>,
173}
174
175/// One trade slot an Entry action reserves and the class it carries, where `None` is an unclassified entry.
176#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
177pub struct EntryRequirement {
178    pub slot: String,
179    pub entry_class: Option<String>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum MaterialLookback {
184    None,
185    Sources(Vec<CompletedBarRequirement>),
186    InheritInputs { minimum: usize },
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub(crate) struct FeedbackObservation {
191    pub slot: String,
192    pub action: ConfiguredActionKind,
193    pub field: FeedbackField,
194}
195
196pub struct MaterialEvalContext<'a> {
197    pub input: &'a StrategyInput,
198    /// Whether every causal leaf of each input expression updated at this boundary.
199    pub input_updates: &'a [bool],
200    /// Whether any causal leaf of each input expression updated at this boundary.
201    pub any_input_updates: &'a [bool],
202    pub(crate) feedback: &'a [FeedbackObservation],
203    pub(crate) retained_feedback: &'a [FeedbackObservation],
204}
205
206impl MaterialEvalContext<'_> {
207    pub fn feedback_matches(
208        &self,
209        slot: &str,
210        action: ConfiguredActionKind,
211        field: FeedbackField,
212    ) -> bool {
213        feedback_matches(self.feedback, slot, action, field)
214    }
215
216    fn visible_feedback_matches(
217        &self,
218        slot: &str,
219        action: ConfiguredActionKind,
220        field: FeedbackField,
221    ) -> bool {
222        self.feedback_matches(slot, action, field)
223            || feedback_matches(self.retained_feedback, slot, action, field)
224    }
225}
226
227fn feedback_matches(
228    feedback: &[FeedbackObservation],
229    slot: &str,
230    action: ConfiguredActionKind,
231    field: FeedbackField,
232) -> bool {
233    feedback
234        .iter()
235        .any(|item| item.slot == slot && item.action == action && item.field == field)
236}
237
238/// Stateful deterministic material evaluator.
239///
240/// `clone_box` must deep-clone all semantic evaluator state. Shared mutable semantic state and
241/// external side effects violate this contract. Shared immutable factory data and non-semantic
242/// telemetry are allowed. The runtime clones evaluators before a boundary and commits those clones
243/// only after the complete strategy evaluation succeeds.
244pub trait MaterialEvaluator: Send {
245    fn clone_box(&self) -> Box<dyn MaterialEvaluator>;
246    fn evaluate(
247        &mut self,
248        inputs: &[Value],
249        context: &MaterialEvalContext<'_>,
250    ) -> Result<Value, String>;
251}
252
253impl Clone for Box<dyn MaterialEvaluator> {
254    fn clone(&self) -> Self {
255        self.clone_box()
256    }
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum MaterialUpdateTrigger {
261    EveryInput,
262    Source(SourceId),
263    FeedbackPulse,
264    /// Evaluate when every input expression updated at this boundary.
265    AllInputs,
266    /// Evaluate when any causal leaf of any input expression updated at this boundary.
267    AnyInput,
268}
269
270#[derive(Debug, Clone, Copy, PartialEq)]
271pub enum ParamKind {
272    Integer { min: i64, max: i64 },
273    Number { min: f64, max: f64 },
274    Source,
275    Slot,
276    BarField,
277    ActionKind,
278}
279
280#[derive(Debug, Clone, Copy, PartialEq)]
281pub struct ParamSpec {
282    pub name: &'static str,
283    pub kind: ParamKind,
284    pub required: bool,
285}
286
287/// Result of constructing one material evaluator.
288///
289/// `max_state_bytes` is the factory's deterministic upper bound for evaluator-owned semantic state.
290pub struct MaterialBuild {
291    pub output_type: ValueType,
292    pub lookback: MaterialLookback,
293    pub max_state_bytes: usize,
294    pub evaluator: Box<dyn MaterialEvaluator>,
295}
296
297pub trait MaterialFactory: Send + Sync {
298    /// Describe an implemented strict numeric contract without allocating evaluator state.
299    fn numeric_descriptor(
300        &self,
301        _params: &MaterialArgs,
302        _input_types: &[ValueType],
303    ) -> Result<Option<crate::NumericDescriptor>, String> {
304        Ok(None)
305    }
306
307    fn params(&self) -> &[ParamSpec] {
308        &[]
309    }
310
311    fn build(
312        &self,
313        params: &MaterialArgs,
314        input_types: &[ValueType],
315    ) -> Result<MaterialBuild, String>;
316
317    fn update_trigger(
318        &self,
319        _params: &MaterialArgs,
320        _input_types: &[ValueType],
321    ) -> Result<MaterialUpdateTrigger, String> {
322        Ok(MaterialUpdateTrigger::EveryInput)
323    }
324}
325
326#[derive(Clone)]
327struct Registration {
328    key: String,
329    factory: Arc<dyn MaterialFactory>,
330}
331
332#[derive(Clone)]
333pub struct MaterialLibrary {
334    registrations: Vec<Registration>,
335}
336
337impl MaterialLibrary {
338    pub fn builtins() -> Self {
339        let keys = [
340            MATERIAL_BAR_FIELD,
341            MATERIAL_INPUT_TIME,
342            MATERIAL_READINESS,
343            MATERIAL_WEEKDAY,
344            MATERIAL_SECONDS_OF_DAY,
345            MATERIAL_EMA,
346            MATERIAL_ATR,
347            MATERIAL_SMA,
348            MATERIAL_STRICT_SMA,
349            MATERIAL_STRICT_EMA,
350            MATERIAL_STDDEV,
351            MATERIAL_ROLLING_MIN,
352            MATERIAL_ROLLING_MAX,
353            MATERIAL_LAG,
354            MATERIAL_RSI,
355            MATERIAL_CROSS_ABOVE,
356            MATERIAL_CROSS_BELOW,
357            MATERIAL_POSITION_EXISTS,
358            MATERIAL_POSITION_PENDING,
359            MATERIAL_POSITION_OPEN,
360            MATERIAL_POSITION_ENTRY_PRICE,
361            MATERIAL_POSITION_SIDE,
362            MATERIAL_POSITION_REMAINING_SIZE,
363            MATERIAL_POSITION_STOPLOSS,
364            MATERIAL_POSITION_OPENED_AT,
365            MATERIAL_POSITION_FAVORABLE_EXCURSION,
366            MATERIAL_POSITION_ADVERSE_EXCURSION,
367            MATERIAL_POSITION_INITIAL_RISK,
368            MATERIAL_BARS_SINCE_OPEN,
369            MATERIAL_ENTRY_FILLED,
370            MATERIAL_ENTRY_REJECTED,
371            MATERIAL_POSITION_CLOSED,
372            MATERIAL_CANCELLATION_APPLIED,
373            MATERIAL_CANCELLATION_REJECTED,
374        ];
375        let mut registrations = keys
376            .into_iter()
377            .map(|key| Registration {
378                key: key.into(),
379                factory: Arc::new(BuiltinFactory { key }),
380            })
381            .collect::<Vec<_>>();
382        registrations.extend(
383            crate::bar_primitives::registrations().map(|(key, factory)| Registration {
384                key: key.into(),
385                factory,
386            }),
387        );
388        registrations.extend(
389            crate::series_primitives::registrations().map(|(key, factory)| Registration {
390                key: key.into(),
391                factory,
392            }),
393        );
394        registrations.extend(
395            crate::momentum_primitives::registrations().map(|(key, factory)| Registration {
396                key: key.into(),
397                factory,
398            }),
399        );
400        registrations.extend(crate::statistical_primitives::registrations().map(
401            |(key, factory)| Registration {
402                key: key.into(),
403                factory,
404            },
405        ));
406        registrations.extend(
407            crate::recursive_primitives::registrations().map(|(key, factory)| Registration {
408                key: key.into(),
409                factory,
410            }),
411        );
412        registrations.extend(crate::normalization_primitives::registrations().map(
413            |(key, factory)| Registration {
414                key: key.into(),
415                factory,
416            },
417        ));
418        registrations.extend(
419            crate::temporal_primitives::registrations().map(|(key, factory)| Registration {
420                key: key.into(),
421                factory,
422            }),
423        );
424        registrations.extend(
425            crate::structure_primitives::registrations().map(|(key, factory)| Registration {
426                key: key.into(),
427                factory,
428            }),
429        );
430        Self { registrations }
431    }
432
433    pub fn with_factory(
434        mut self,
435        key: impl Into<String>,
436        factory: Arc<dyn MaterialFactory>,
437    ) -> Result<Self, CompileError> {
438        let key = key.into();
439        crate::validate_id(&key).map_err(|reason| CompileError::InvalidIdentifier {
440            path: "material_library.key".into(),
441            reason,
442        })?;
443        if self.registrations.iter().any(|item| item.key == key) {
444            return Err(CompileError::DuplicateIdentifier {
445                path: "material_library".into(),
446                id: key,
447            });
448        }
449        self.registrations.push(Registration { key, factory });
450        Ok(self)
451    }
452
453    pub(crate) fn factory(&self, key: &str) -> Option<&Arc<dyn MaterialFactory>> {
454        self.registration(key).map(|item| &item.factory)
455    }
456
457    /// Inspect a registered numeric contract using the same validation as evaluator construction.
458    /// None means the registered factory does not supply a numeric descriptor, not inferred support.
459    pub fn numeric_descriptor(
460        &self,
461        key: &str,
462        params: &MaterialArgs,
463        input_types: &[ValueType],
464    ) -> Result<Option<crate::NumericDescriptor>, String> {
465        self.factory(key)
466            .ok_or_else(|| format!("unknown material key: {key}"))?
467            .numeric_descriptor(params, input_types)
468    }
469
470    pub fn parameter_schema(&self, key: &str) -> Option<&[ParamSpec]> {
471        self.registration(key).map(|item| item.factory.params())
472    }
473
474    fn registration(&self, key: &str) -> Option<&Registration> {
475        self.registrations.iter().find(|item| item.key == key)
476    }
477}
478
479struct BuiltinFactory {
480    key: &'static str,
481}
482
483const PERIOD_SCHEMA: [ParamSpec; 1] = [ParamSpec {
484    name: "period",
485    kind: ParamKind::Integer {
486        min: 1,
487        max: crate::MAX_MATERIAL_LOOKBACK as i64,
488    },
489    required: true,
490}];
491const STRICT_PERIOD_SCHEMA: [ParamSpec; 2] = [
492    ParamSpec {
493        name: "source",
494        kind: ParamKind::Source,
495        required: true,
496    },
497    ParamSpec {
498        name: "period",
499        kind: ParamKind::Integer { min: 1, max: 1024 },
500        required: true,
501    },
502];
503const SOURCE_FIELD_SCHEMA: [ParamSpec; 2] = [
504    ParamSpec {
505        name: "source",
506        kind: ParamKind::Source,
507        required: true,
508    },
509    ParamSpec {
510        name: "field",
511        kind: ParamKind::BarField,
512        required: true,
513    },
514];
515const SOURCE_PERIOD_SCHEMA: [ParamSpec; 2] = [
516    ParamSpec {
517        name: "source",
518        kind: ParamKind::Source,
519        required: true,
520    },
521    ParamSpec {
522        name: "period",
523        kind: ParamKind::Integer {
524            min: 1,
525            max: crate::MAX_MATERIAL_LOOKBACK as i64 - 1,
526        },
527        required: true,
528    },
529];
530const SLOT_SCHEMA: [ParamSpec; 1] = [ParamSpec {
531    name: "slot",
532    kind: ParamKind::Slot,
533    required: true,
534}];
535const SLOT_SOURCE_SCHEMA: [ParamSpec; 2] = [
536    ParamSpec {
537        name: "slot",
538        kind: ParamKind::Slot,
539        required: true,
540    },
541    ParamSpec {
542        name: "source",
543        kind: ParamKind::Source,
544        required: true,
545    },
546];
547const FEEDBACK_SCHEMA: [ParamSpec; 2] = [
548    ParamSpec {
549        name: "slot",
550        kind: ParamKind::Slot,
551        required: true,
552    },
553    ParamSpec {
554        name: "action",
555        kind: ParamKind::ActionKind,
556        required: true,
557    },
558];
559
560impl MaterialFactory for BuiltinFactory {
561    fn numeric_descriptor(
562        &self,
563        params: &MaterialArgs,
564        inputs: &[ValueType],
565    ) -> Result<Option<crate::NumericDescriptor>, String> {
566        if !matches!(self.key, MATERIAL_STRICT_SMA | MATERIAL_STRICT_EMA) {
567            return Ok(None);
568        }
569        if params.len() != 2
570            || params
571                .iter()
572                .any(|(key, _)| !matches!(key.as_str(), "source" | "period"))
573        {
574            return Err("strict average requires only source and period parameters".into());
575        }
576        let period = checked_period(params)?;
577        require_one_numeric(inputs)?;
578        let source_clock = source_arg(params, "source")?;
579        let max_state_bytes = crate::numeric::ObservedWindow::state_bytes(period)?
580            .checked_add(std::mem::size_of::<StrictEmaEvaluator>())
581            .ok_or_else(|| "strict average state bound overflowed".to_string())?;
582        if max_state_bytes > crate::MAX_MATERIAL_STATE_BYTES {
583            return Err("strict average state exceeds the material bound".into());
584        }
585        let sma = self.key == MATERIAL_STRICT_SMA;
586        Ok(Some(crate::NumericDescriptor {
587            calculation: if sma {
588                crate::NumericCalculation::ObservedSma { period }
589            } else {
590                crate::NumericCalculation::SmaSeededEma {
591                    period,
592                    alpha: 2.0 / (period as f64 + 1.0),
593                }
594            },
595            source_clock,
596            inputs: crate::NumericInputs::Scalar(inputs.to_vec()),
597            output_type: ValueType::optional(inputs[0].scalar),
598            unit: match inputs[0].scalar {
599                ScalarType::Price => crate::NumericUnit::Price,
600                ScalarType::Ratio => crate::NumericUnit::Ratio,
601                ScalarType::Percent => crate::NumericUnit::Percent,
602                ScalarType::PricePerObservation => crate::NumericUnit::PricePerObservation,
603                ScalarType::PricePerObservationSquared => {
604                    crate::NumericUnit::PricePerObservationSquared
605                }
606                ScalarType::RatioPerObservation => crate::NumericUnit::RatioPerObservation,
607                ScalarType::RatioPerObservationSquared => {
608                    crate::NumericUnit::RatioPerObservationSquared
609                }
610                ScalarType::LogReturn => crate::NumericUnit::LogReturn,
611                ScalarType::LogReturnVariance => crate::NumericUnit::LogReturnVariance,
612                _ => crate::NumericUnit::Number,
613            },
614            range: crate::NumericRange::Unbounded,
615            missing: if sma {
616                crate::NumericMissingPolicy::ConsumeWindowSlot
617            } else {
618                crate::NumericMissingPolicy::ResetAndReseed
619            },
620            first_output_observations: period,
621            required_lookback: period,
622            max_state_bytes,
623            exact_aliases: &[],
624        }))
625    }
626
627    fn params(&self) -> &[ParamSpec] {
628        match self.key {
629            MATERIAL_BAR_FIELD => &SOURCE_FIELD_SCHEMA,
630            MATERIAL_STRICT_SMA | MATERIAL_STRICT_EMA => &STRICT_PERIOD_SCHEMA,
631            MATERIAL_EMA | MATERIAL_SMA | MATERIAL_STDDEV | MATERIAL_ROLLING_MIN
632            | MATERIAL_ROLLING_MAX | MATERIAL_LAG | MATERIAL_RSI => &PERIOD_SCHEMA,
633            MATERIAL_ATR => &SOURCE_PERIOD_SCHEMA,
634            MATERIAL_POSITION_EXISTS
635            | MATERIAL_POSITION_PENDING
636            | MATERIAL_POSITION_OPEN
637            | MATERIAL_POSITION_ENTRY_PRICE
638            | MATERIAL_POSITION_SIDE
639            | MATERIAL_POSITION_REMAINING_SIZE
640            | MATERIAL_POSITION_STOPLOSS
641            | MATERIAL_POSITION_OPENED_AT
642            | MATERIAL_POSITION_FAVORABLE_EXCURSION
643            | MATERIAL_POSITION_ADVERSE_EXCURSION
644            | MATERIAL_POSITION_INITIAL_RISK => &SLOT_SCHEMA,
645            MATERIAL_BARS_SINCE_OPEN => &SLOT_SOURCE_SCHEMA,
646            MATERIAL_ENTRY_FILLED
647            | MATERIAL_ENTRY_REJECTED
648            | MATERIAL_POSITION_CLOSED
649            | MATERIAL_CANCELLATION_APPLIED
650            | MATERIAL_CANCELLATION_REJECTED => &FEEDBACK_SCHEMA,
651            _ => &[],
652        }
653    }
654
655    fn build(&self, params: &MaterialArgs, inputs: &[ValueType]) -> Result<MaterialBuild, String> {
656        let descriptor = self.numeric_descriptor(params, inputs)?;
657        let state_bytes = match self.key {
658            MATERIAL_EMA | MATERIAL_RSI => 64,
659            MATERIAL_ATR | MATERIAL_CROSS_ABOVE | MATERIAL_CROSS_BELOW => 48,
660            MATERIAL_SMA | MATERIAL_STDDEV | MATERIAL_ROLLING_MIN | MATERIAL_ROLLING_MAX
661            | MATERIAL_LAG => checked_period(params)? * std::mem::size_of::<f64>() + 64,
662            MATERIAL_STRICT_SMA | MATERIAL_STRICT_EMA => {
663                descriptor.as_ref().unwrap().max_state_bytes
664            }
665            _ => crate::MAX_GENERATED_ID_BYTES + 64,
666        };
667        let build = |output_type, lookback, evaluator: Box<dyn MaterialEvaluator>| {
668            Ok(MaterialBuild {
669                output_type,
670                lookback,
671                max_state_bytes: state_bytes,
672                evaluator,
673            })
674        };
675        match self.key {
676            MATERIAL_BAR_FIELD => {
677                require_inputs(inputs, &[])?;
678                let source = source_arg(params, "source")?;
679                let field = bar_field_arg(params, "field")?;
680                build(
681                    crate::bar_field_type(field),
682                    source_lookback(source.clone(), 1),
683                    Box::new(BarFieldEvaluator { source, field }),
684                )
685            }
686            MATERIAL_INPUT_TIME => {
687                require_none(params)?;
688                require_inputs(inputs, &[])?;
689                build(
690                    ValueType::required(ScalarType::Timestamp),
691                    MaterialLookback::None,
692                    Box::new(InputTimeEvaluator),
693                )
694            }
695            MATERIAL_WEEKDAY | MATERIAL_SECONDS_OF_DAY => {
696                require_none(params)?;
697                require_inputs(inputs, &[])?;
698                build(
699                    ValueType::required(ScalarType::Integer),
700                    MaterialLookback::None,
701                    Box::new(CalendarEvaluator {
702                        weekday: self.key == MATERIAL_WEEKDAY,
703                    }),
704                )
705            }
706            MATERIAL_READINESS => {
707                require_none(params)?;
708                require_inputs(inputs, &[])?;
709                build(
710                    ValueType::required(ScalarType::Bool),
711                    MaterialLookback::None,
712                    Box::new(ReadinessEvaluator),
713                )
714            }
715            MATERIAL_EMA => {
716                let period = checked_period(params)?;
717                require_one_numeric(inputs)?;
718                build(
719                    ValueType::optional(inputs[0].scalar),
720                    MaterialLookback::InheritInputs { minimum: period },
721                    Box::new(EmaEvaluator {
722                        alpha: 2.0 / (period as f64 + 1.0),
723                        value: None,
724                        scalar: inputs[0].scalar,
725                    }),
726                )
727            }
728            MATERIAL_ATR => {
729                let source = source_arg(params, "source")?;
730                let period = checked_period(params)?;
731                require_inputs(inputs, &[])?;
732                build(
733                    ValueType::optional(ScalarType::Price),
734                    source_lookback(source.clone(), period + 1),
735                    Box::new(AtrEvaluator {
736                        source,
737                        alpha: 1.0 / period as f64,
738                        previous_close: None,
739                        value: None,
740                    }),
741                )
742            }
743            MATERIAL_STRICT_SMA | MATERIAL_STRICT_EMA => {
744                let descriptor = descriptor.unwrap();
745                let period = descriptor.first_output_observations;
746                let source = descriptor.source_clock;
747                let scalar = descriptor.output_type.scalar;
748                let evaluator: Box<dyn MaterialEvaluator> = if self.key == MATERIAL_STRICT_SMA {
749                    Box::new(StrictSmaEvaluator {
750                        values: crate::numeric::ObservedWindow::new(period)?,
751                        scalar,
752                    })
753                } else {
754                    Box::new(StrictEmaEvaluator {
755                        period,
756                        seed_values: crate::numeric::ObservedWindow::new(period)?,
757                        value: None,
758                        scalar,
759                    })
760                };
761                build(
762                    ValueType::optional(scalar),
763                    source_lookback(source, period),
764                    evaluator,
765                )
766            }
767            MATERIAL_SMA | MATERIAL_STDDEV | MATERIAL_ROLLING_MIN | MATERIAL_ROLLING_MAX => {
768                let period = checked_period(params)?;
769                require_one_numeric(inputs)?;
770                let kind = match self.key {
771                    MATERIAL_SMA => RollingKind::Mean,
772                    MATERIAL_STDDEV => RollingKind::PopulationStdDev,
773                    MATERIAL_ROLLING_MIN => RollingKind::Min,
774                    MATERIAL_ROLLING_MAX => RollingKind::Max,
775                    _ => unreachable!(),
776                };
777                build(
778                    ValueType::optional(inputs[0].scalar),
779                    MaterialLookback::InheritInputs { minimum: period },
780                    Box::new(RollingEvaluator {
781                        period,
782                        values: VecDeque::with_capacity(period),
783                        scalar: inputs[0].scalar,
784                        kind,
785                    }),
786                )
787            }
788            MATERIAL_LAG => {
789                let period = checked_period(params)?;
790                require_one_numeric(inputs)?;
791                build(
792                    ValueType::optional(inputs[0].scalar),
793                    MaterialLookback::InheritInputs {
794                        minimum: period + 1,
795                    },
796                    Box::new(LagEvaluator {
797                        period,
798                        values: VecDeque::with_capacity(period + 1),
799                        scalar: inputs[0].scalar,
800                    }),
801                )
802            }
803            MATERIAL_RSI => {
804                let period = checked_period(params)?;
805                require_one_numeric(inputs)?;
806                build(
807                    ValueType::optional(ScalarType::Number),
808                    MaterialLookback::InheritInputs {
809                        minimum: period + 1,
810                    },
811                    Box::new(RsiEvaluator {
812                        period,
813                        previous: None,
814                        seed_gains: 0.0,
815                        seed_losses: 0.0,
816                        seed_changes: 0,
817                        average_gain: None,
818                        average_loss: None,
819                    }),
820                )
821            }
822            MATERIAL_CROSS_ABOVE | MATERIAL_CROSS_BELOW => {
823                require_none(params)?;
824                require_cross(inputs)?;
825                build(
826                    ValueType::required(ScalarType::Bool),
827                    MaterialLookback::InheritInputs { minimum: 0 },
828                    Box::new(CrossEvaluator {
829                        above: self.key == MATERIAL_CROSS_ABOVE,
830                        previous: None,
831                    }),
832                )
833            }
834            MATERIAL_POSITION_EXISTS => position_build(params, inputs, PositionField::Exists),
835            MATERIAL_POSITION_PENDING => position_build(params, inputs, PositionField::IsPending),
836            MATERIAL_POSITION_OPEN => position_build(params, inputs, PositionField::IsOpen),
837            MATERIAL_POSITION_ENTRY_PRICE => {
838                position_build(params, inputs, PositionField::EntryPrice)
839            }
840            MATERIAL_POSITION_SIDE => position_build(params, inputs, PositionField::Side),
841            MATERIAL_POSITION_REMAINING_SIZE => {
842                position_build(params, inputs, PositionField::RemainingSize)
843            }
844            MATERIAL_POSITION_STOPLOSS => position_build(params, inputs, PositionField::Stoploss),
845            MATERIAL_POSITION_OPENED_AT => position_build(params, inputs, PositionField::OpenedAt),
846            MATERIAL_POSITION_FAVORABLE_EXCURSION => {
847                position_build(params, inputs, PositionField::FavorableExcursion)
848            }
849            MATERIAL_POSITION_ADVERSE_EXCURSION => {
850                position_build(params, inputs, PositionField::AdverseExcursion)
851            }
852            MATERIAL_POSITION_INITIAL_RISK => {
853                position_build(params, inputs, PositionField::InitialRisk)
854            }
855            MATERIAL_BARS_SINCE_OPEN => {
856                require_inputs(inputs, &[])?;
857                let slot = slot_arg(params, "slot")?;
858                crate::validate_id(&slot)?;
859                let source = source_arg(params, "source")?;
860                build(
861                    ValueType::optional(ScalarType::Integer),
862                    source_lookback(source.clone(), 1),
863                    Box::new(BarsSinceOpenEvaluator {
864                        slot,
865                        source,
866                        opened_at: None,
867                        count: 0,
868                    }),
869                )
870            }
871            MATERIAL_ENTRY_FILLED => feedback_build(params, inputs, FeedbackField::EntryFilled),
872            MATERIAL_ENTRY_REJECTED => feedback_build(params, inputs, FeedbackField::EntryRejected),
873            MATERIAL_POSITION_CLOSED => {
874                feedback_build(params, inputs, FeedbackField::PositionClosed)
875            }
876            MATERIAL_CANCELLATION_APPLIED => {
877                feedback_build(params, inputs, FeedbackField::CancellationApplied)
878            }
879            MATERIAL_CANCELLATION_REJECTED => {
880                feedback_build(params, inputs, FeedbackField::CancellationRejected)
881            }
882            _ => Err("unknown built-in material".into()),
883        }
884    }
885
886    fn update_trigger(
887        &self,
888        params: &MaterialArgs,
889        _inputs: &[ValueType],
890    ) -> Result<MaterialUpdateTrigger, String> {
891        Ok(match self.key {
892            MATERIAL_BAR_FIELD | MATERIAL_ATR => {
893                MaterialUpdateTrigger::Source(source_arg(params, "source")?)
894            }
895            MATERIAL_STRICT_SMA | MATERIAL_STRICT_EMA => {
896                MaterialUpdateTrigger::Source(source_arg(params, "source")?)
897            }
898            MATERIAL_EMA | MATERIAL_SMA | MATERIAL_STDDEV | MATERIAL_ROLLING_MIN
899            | MATERIAL_ROLLING_MAX | MATERIAL_LAG | MATERIAL_RSI | MATERIAL_CROSS_ABOVE
900            | MATERIAL_CROSS_BELOW => MaterialUpdateTrigger::AnyInput,
901            MATERIAL_ENTRY_FILLED
902            | MATERIAL_ENTRY_REJECTED
903            | MATERIAL_POSITION_CLOSED
904            | MATERIAL_CANCELLATION_APPLIED
905            | MATERIAL_CANCELLATION_REJECTED => MaterialUpdateTrigger::FeedbackPulse,
906            _ => MaterialUpdateTrigger::EveryInput,
907        })
908    }
909}
910
911fn source_lookback(source: SourceId, required_lookback: usize) -> MaterialLookback {
912    MaterialLookback::Sources(vec![CompletedBarRequirement {
913        source,
914        required_lookback,
915    }])
916}
917
918fn checked_period(params: &MaterialArgs) -> Result<usize, String> {
919    let value = integer_arg(params, "period")?;
920    usize::try_from(value).map_err(|_| "period is outside the supported lookback bound".into())
921}
922
923fn integer_arg(params: &MaterialArgs, name: &str) -> Result<i64, String> {
924    match params.get(name) {
925        Some(MaterialArg::Integer(value)) => Ok(*value),
926        _ => Err(format!("integer material argument '{name}' is required")),
927    }
928}
929
930fn source_arg(params: &MaterialArgs, name: &str) -> Result<SourceId, String> {
931    match params.get(name) {
932        Some(MaterialArg::Source(value)) => Ok(value.clone()),
933        _ => Err(format!("source material argument '{name}' is required")),
934    }
935}
936
937fn slot_arg(params: &MaterialArgs, name: &str) -> Result<String, String> {
938    match params.get(name) {
939        Some(MaterialArg::Slot(value)) => Ok(value.clone()),
940        _ => Err(format!("slot material argument '{name}' is required")),
941    }
942}
943
944fn bar_field_arg(params: &MaterialArgs, name: &str) -> Result<BarField, String> {
945    match params.get(name) {
946        Some(MaterialArg::BarField(value)) => Ok(*value),
947        _ => Err(format!("bar-field material argument '{name}' is required")),
948    }
949}
950
951fn action_kind_arg(params: &MaterialArgs, name: &str) -> Result<ConfiguredActionKind, String> {
952    match params.get(name) {
953        Some(MaterialArg::ActionKind(value)) => Ok(*value),
954        _ => Err(format!(
955            "action-kind material argument '{name}' is required"
956        )),
957    }
958}
959
960fn require_none(params: &MaterialArgs) -> Result<(), String> {
961    if params.is_empty() {
962        Ok(())
963    } else {
964        Err("material takes no parameters".into())
965    }
966}
967
968fn require_inputs(actual: &[ValueType], expected: &[ValueType]) -> Result<(), String> {
969    if actual == expected {
970        Ok(())
971    } else {
972        Err(format!("expected inputs {expected:?}, got {actual:?}"))
973    }
974}
975
976fn require_one_numeric(inputs: &[ValueType]) -> Result<(), String> {
977    if inputs.len() == 1
978        && matches!(
979            inputs[0].scalar,
980            ScalarType::Number
981                | ScalarType::Price
982                | ScalarType::Ratio
983                | ScalarType::Percent
984                | ScalarType::PricePerObservation
985                | ScalarType::PricePerObservationSquared
986                | ScalarType::RatioPerObservation
987                | ScalarType::RatioPerObservationSquared
988                | ScalarType::LogReturn
989                | ScalarType::LogReturnVariance
990        )
991    {
992        Ok(())
993    } else {
994        Err("expected one number or price input".into())
995    }
996}
997
998fn require_cross(inputs: &[ValueType]) -> Result<(), String> {
999    if inputs.len() == 2
1000        && inputs[0].scalar == inputs[1].scalar
1001        && matches!(
1002            inputs[0].scalar,
1003            ScalarType::Integer
1004                | ScalarType::Number
1005                | ScalarType::Price
1006                | ScalarType::Ratio
1007                | ScalarType::Percent
1008                | ScalarType::PricePerObservation
1009                | ScalarType::PricePerObservationSquared
1010                | ScalarType::RatioPerObservation
1011                | ScalarType::RatioPerObservationSquared
1012                | ScalarType::LogReturn
1013                | ScalarType::LogReturnVariance
1014        )
1015    {
1016        Ok(())
1017    } else {
1018        Err("expected two inputs of the same numeric type".into())
1019    }
1020}
1021
1022fn position_build(
1023    params: &MaterialArgs,
1024    inputs: &[ValueType],
1025    field: PositionField,
1026) -> Result<MaterialBuild, String> {
1027    require_inputs(inputs, &[])?;
1028    let slot = slot_arg(params, "slot")?;
1029    crate::validate_id(&slot)?;
1030    let output_type = position_field_type(field);
1031    Ok(MaterialBuild {
1032        output_type,
1033        lookback: MaterialLookback::None,
1034        max_state_bytes: crate::MAX_ID_BYTES + 64,
1035        evaluator: Box::new(PositionEvaluator { slot, field }),
1036    })
1037}
1038
1039fn feedback_build(
1040    params: &MaterialArgs,
1041    inputs: &[ValueType],
1042    field: FeedbackField,
1043) -> Result<MaterialBuild, String> {
1044    require_inputs(inputs, &[])?;
1045    let slot = slot_arg(params, "slot")?;
1046    let action = action_kind_arg(params, "action")?;
1047    crate::validate_id(&slot)?;
1048    Ok(MaterialBuild {
1049        output_type: ValueType::required(ScalarType::Bool),
1050        lookback: MaterialLookback::None,
1051        max_state_bytes: 0,
1052        evaluator: Box::new(FeedbackEvaluator {
1053            slot,
1054            action,
1055            field,
1056        }),
1057    })
1058}
1059
1060pub(crate) fn position_field_type(field: PositionField) -> ValueType {
1061    match field {
1062        PositionField::Exists | PositionField::IsPending | PositionField::IsOpen => {
1063            ValueType::required(ScalarType::Bool)
1064        }
1065        PositionField::EntryPrice | PositionField::Stoploss => {
1066            ValueType::optional(ScalarType::Price)
1067        }
1068        PositionField::Side => ValueType::optional(ScalarType::Side),
1069        PositionField::RemainingSize
1070        | PositionField::FavorableExcursion
1071        | PositionField::AdverseExcursion
1072        | PositionField::InitialRisk => ValueType::optional(ScalarType::Number),
1073        PositionField::OpenedAt => ValueType::optional(ScalarType::Timestamp),
1074    }
1075}
1076
1077macro_rules! clone_eval {
1078    ($ty:ty) => {
1079        fn clone_box(&self) -> Box<dyn MaterialEvaluator> {
1080            Box::new(self.clone())
1081        }
1082    };
1083}
1084
1085#[derive(Clone)]
1086struct BarFieldEvaluator {
1087    source: SourceId,
1088    field: BarField,
1089}
1090impl MaterialEvaluator for BarFieldEvaluator {
1091    clone_eval!(Self);
1092    fn evaluate(
1093        &mut self,
1094        _: &[Value],
1095        context: &MaterialEvalContext<'_>,
1096    ) -> Result<Value, String> {
1097        let update = context
1098            .input
1099            .completed_bars
1100            .iter()
1101            .find(|item| item.source == self.source)
1102            .ok_or_else(|| "configured bar source did not update".to_string())?;
1103        Ok(bar_value(&update.bar, self.field))
1104    }
1105}
1106
1107#[derive(Clone)]
1108struct InputTimeEvaluator;
1109impl MaterialEvaluator for InputTimeEvaluator {
1110    clone_eval!(Self);
1111    fn evaluate(
1112        &mut self,
1113        _: &[Value],
1114        context: &MaterialEvalContext<'_>,
1115    ) -> Result<Value, String> {
1116        Ok(Value::Timestamp(context.input.time))
1117    }
1118}
1119
1120/// A pure function of the authoritative input time, so every adapter supplies it identically.
1121#[derive(Clone)]
1122struct CalendarEvaluator {
1123    weekday: bool,
1124}
1125impl MaterialEvaluator for CalendarEvaluator {
1126    clone_eval!(Self);
1127    fn evaluate(
1128        &mut self,
1129        _: &[Value],
1130        context: &MaterialEvalContext<'_>,
1131    ) -> Result<Value, String> {
1132        let time = context.input.time;
1133        Ok(Value::Integer(if self.weekday {
1134            i64::from(time.weekday().number_from_monday())
1135        } else {
1136            i64::from(time.num_seconds_from_midnight())
1137        }))
1138    }
1139}
1140
1141#[derive(Clone)]
1142struct ReadinessEvaluator;
1143impl MaterialEvaluator for ReadinessEvaluator {
1144    clone_eval!(Self);
1145    fn evaluate(
1146        &mut self,
1147        _: &[Value],
1148        context: &MaterialEvalContext<'_>,
1149    ) -> Result<Value, String> {
1150        Ok(Value::Bool(context.input.ready))
1151    }
1152}
1153
1154#[derive(Clone)]
1155struct EmaEvaluator {
1156    alpha: f64,
1157    value: Option<f64>,
1158    scalar: ScalarType,
1159}
1160impl MaterialEvaluator for EmaEvaluator {
1161    clone_eval!(Self);
1162    fn evaluate(&mut self, inputs: &[Value], _: &MaterialEvalContext<'_>) -> Result<Value, String> {
1163        let current = numeric_value(&inputs[0])?;
1164        if let Some(current) = current {
1165            self.value = Some(self.value.map_or(current, |previous| {
1166                self.alpha * current + (1.0 - self.alpha) * previous
1167            }));
1168        }
1169        Ok(self
1170            .value
1171            .map(|value| numeric(self.scalar, value))
1172            .unwrap_or(Value::Missing(self.scalar)))
1173    }
1174}
1175
1176#[derive(Clone)]
1177struct StrictSmaEvaluator {
1178    values: crate::numeric::ObservedWindow,
1179    scalar: ScalarType,
1180}
1181
1182impl MaterialEvaluator for StrictSmaEvaluator {
1183    clone_eval!(Self);
1184
1185    fn evaluate(
1186        &mut self,
1187        inputs: &[Value],
1188        context: &MaterialEvalContext<'_>,
1189    ) -> Result<Value, String> {
1190        let sample = if context.input_updates.first() == Some(&false) {
1191            None
1192        } else {
1193            numeric_value(&inputs[0])?
1194        };
1195        self.values.push(sample)?;
1196        Ok(self
1197            .values
1198            .mean()?
1199            .map(|value| numeric(self.scalar, value))
1200            .unwrap_or(Value::Missing(self.scalar)))
1201    }
1202}
1203
1204#[derive(Clone)]
1205struct StrictEmaEvaluator {
1206    period: usize,
1207    seed_values: crate::numeric::ObservedWindow,
1208    value: Option<f64>,
1209    scalar: ScalarType,
1210}
1211
1212impl MaterialEvaluator for StrictEmaEvaluator {
1213    clone_eval!(Self);
1214
1215    fn evaluate(
1216        &mut self,
1217        inputs: &[Value],
1218        context: &MaterialEvalContext<'_>,
1219    ) -> Result<Value, String> {
1220        let sample = if context.input_updates.first() == Some(&false) {
1221            None
1222        } else {
1223            numeric_value(&inputs[0])?
1224        };
1225        let Some(sample) = sample else {
1226            self.seed_values.reset();
1227            self.value = None;
1228            return Ok(Value::Missing(self.scalar));
1229        };
1230        let next = if let Some(previous) = self.value {
1231            crate::numeric::ema_step(sample, previous, self.period)?
1232        } else {
1233            self.seed_values.push(Some(sample))?;
1234            let Some(seed) = self.seed_values.mean()? else {
1235                return Ok(Value::Missing(self.scalar));
1236            };
1237            self.seed_values.reset();
1238            seed
1239        };
1240        if !next.is_finite() {
1241            return Err("strict EMA arithmetic overflowed".into());
1242        }
1243        self.value = Some(next);
1244        Ok(numeric(self.scalar, next))
1245    }
1246}
1247
1248#[cfg(test)]
1249use crate::numeric::stable_mean;
1250
1251#[derive(Clone, Copy)]
1252enum RollingKind {
1253    Mean,
1254    PopulationStdDev,
1255    Min,
1256    Max,
1257}
1258
1259#[derive(Clone)]
1260struct RollingEvaluator {
1261    period: usize,
1262    values: VecDeque<f64>,
1263    scalar: ScalarType,
1264    kind: RollingKind,
1265}
1266
1267impl MaterialEvaluator for RollingEvaluator {
1268    clone_eval!(Self);
1269    fn evaluate(&mut self, inputs: &[Value], _: &MaterialEvalContext<'_>) -> Result<Value, String> {
1270        if let Some(value) = numeric_value(&inputs[0])? {
1271            self.values.push_back(value);
1272            if self.values.len() > self.period {
1273                self.values.pop_front();
1274            }
1275        }
1276        if self.values.len() < self.period {
1277            return Ok(Value::Missing(self.scalar));
1278        }
1279        let value = match self.kind {
1280            RollingKind::Mean => self.values.iter().sum::<f64>() / self.period as f64,
1281            RollingKind::PopulationStdDev => {
1282                let mean = self.values.iter().sum::<f64>() / self.period as f64;
1283                (self
1284                    .values
1285                    .iter()
1286                    .map(|value| (value - mean).powi(2))
1287                    .sum::<f64>()
1288                    / self.period as f64)
1289                    .sqrt()
1290            }
1291            RollingKind::Min => self.values.iter().copied().fold(f64::INFINITY, f64::min),
1292            RollingKind::Max => self
1293                .values
1294                .iter()
1295                .copied()
1296                .fold(f64::NEG_INFINITY, f64::max),
1297        };
1298        Ok(numeric(self.scalar, value))
1299    }
1300}
1301
1302#[derive(Clone)]
1303struct LagEvaluator {
1304    period: usize,
1305    values: VecDeque<f64>,
1306    scalar: ScalarType,
1307}
1308
1309impl MaterialEvaluator for LagEvaluator {
1310    clone_eval!(Self);
1311    fn evaluate(&mut self, inputs: &[Value], _: &MaterialEvalContext<'_>) -> Result<Value, String> {
1312        let Some(value) = numeric_value(&inputs[0])? else {
1313            return Ok(Value::Missing(self.scalar));
1314        };
1315        self.values.push_back(value);
1316        if self.values.len() <= self.period {
1317            return Ok(Value::Missing(self.scalar));
1318        }
1319        let lagged = self
1320            .values
1321            .pop_front()
1322            .expect("a lagged value is available");
1323        Ok(numeric(self.scalar, lagged))
1324    }
1325}
1326
1327#[derive(Clone)]
1328struct RsiEvaluator {
1329    period: usize,
1330    previous: Option<f64>,
1331    seed_gains: f64,
1332    seed_losses: f64,
1333    seed_changes: usize,
1334    average_gain: Option<f64>,
1335    average_loss: Option<f64>,
1336}
1337
1338impl MaterialEvaluator for RsiEvaluator {
1339    clone_eval!(Self);
1340    fn evaluate(&mut self, inputs: &[Value], _: &MaterialEvalContext<'_>) -> Result<Value, String> {
1341        let Some(current) = numeric_value(&inputs[0])? else {
1342            return Ok(Value::Missing(ScalarType::Number));
1343        };
1344        let Some(previous) = self.previous.replace(current) else {
1345            return Ok(Value::Missing(ScalarType::Number));
1346        };
1347        let change = current - previous;
1348        let gain = change.max(0.0);
1349        let loss = (-change).max(0.0);
1350        let (average_gain, average_loss) = match (self.average_gain, self.average_loss) {
1351            (Some(average_gain), Some(average_loss)) => {
1352                let divisor = self.period as f64;
1353                (
1354                    (average_gain * (divisor - 1.0) + gain) / divisor,
1355                    (average_loss * (divisor - 1.0) + loss) / divisor,
1356                )
1357            }
1358            _ => {
1359                self.seed_gains += gain;
1360                self.seed_losses += loss;
1361                self.seed_changes += 1;
1362                if self.seed_changes < self.period {
1363                    return Ok(Value::Missing(ScalarType::Number));
1364                }
1365                (
1366                    self.seed_gains / self.period as f64,
1367                    self.seed_losses / self.period as f64,
1368                )
1369            }
1370        };
1371        self.average_gain = Some(average_gain);
1372        self.average_loss = Some(average_loss);
1373        let value = if average_loss == 0.0 {
1374            if average_gain == 0.0 { 50.0 } else { 100.0 }
1375        } else if average_gain == 0.0 {
1376            0.0
1377        } else {
1378            100.0 - 100.0 / (1.0 + average_gain / average_loss)
1379        };
1380        Ok(Value::Number(value))
1381    }
1382}
1383
1384#[derive(Clone)]
1385struct AtrEvaluator {
1386    source: SourceId,
1387    alpha: f64,
1388    previous_close: Option<f64>,
1389    value: Option<f64>,
1390}
1391impl MaterialEvaluator for AtrEvaluator {
1392    clone_eval!(Self);
1393    fn evaluate(
1394        &mut self,
1395        _: &[Value],
1396        context: &MaterialEvalContext<'_>,
1397    ) -> Result<Value, String> {
1398        let bar = &context
1399            .input
1400            .completed_bars
1401            .iter()
1402            .find(|item| item.source == self.source)
1403            .ok_or_else(|| "configured ATR source did not update".to_string())?
1404            .bar;
1405        let range = bar.high - bar.low;
1406        let true_range = self.previous_close.map_or(range, |close| {
1407            range
1408                .max((bar.high - close).abs())
1409                .max((bar.low - close).abs())
1410        });
1411        self.value = Some(self.value.map_or(true_range, |previous| {
1412            self.alpha * true_range + (1.0 - self.alpha) * previous
1413        }));
1414        self.previous_close = Some(bar.close);
1415        Ok(Value::Price(self.value.unwrap()))
1416    }
1417}
1418
1419#[derive(Clone)]
1420struct CrossEvaluator {
1421    above: bool,
1422    previous: Option<(f64, f64)>,
1423}
1424impl MaterialEvaluator for CrossEvaluator {
1425    clone_eval!(Self);
1426    fn evaluate(&mut self, inputs: &[Value], _: &MaterialEvalContext<'_>) -> Result<Value, String> {
1427        let (Some(left), Some(right)) = (numeric_value(&inputs[0])?, numeric_value(&inputs[1])?)
1428        else {
1429            return Ok(Value::Bool(false));
1430        };
1431        let crossed = self.previous.is_some_and(|(old_left, old_right)| {
1432            if self.above {
1433                old_left <= old_right && left > right
1434            } else {
1435                old_left >= old_right && left < right
1436            }
1437        });
1438        self.previous = Some((left, right));
1439        Ok(Value::Bool(crossed))
1440    }
1441}
1442
1443#[derive(Clone)]
1444struct PositionEvaluator {
1445    slot: String,
1446    field: PositionField,
1447}
1448impl MaterialEvaluator for PositionEvaluator {
1449    clone_eval!(Self);
1450    fn evaluate(
1451        &mut self,
1452        _: &[Value],
1453        context: &MaterialEvalContext<'_>,
1454    ) -> Result<Value, String> {
1455        let facts = context
1456            .input
1457            .trade_slots
1458            .iter()
1459            .find(|item| item.slot == self.slot)
1460            .ok_or_else(|| "declared trade slot facts are missing".to_string())?;
1461        Ok(trade_slot_value(&facts.state, self.field))
1462    }
1463}
1464
1465/// Count completed bars of one source that arrive after the slot's entry fill, restarting whenever the slot holds a different position.
1466#[derive(Clone)]
1467struct BarsSinceOpenEvaluator {
1468    slot: String,
1469    source: SourceId,
1470    opened_at: Option<NaiveDateTime>,
1471    count: i64,
1472}
1473impl MaterialEvaluator for BarsSinceOpenEvaluator {
1474    clone_eval!(Self);
1475    fn evaluate(
1476        &mut self,
1477        _: &[Value],
1478        context: &MaterialEvalContext<'_>,
1479    ) -> Result<Value, String> {
1480        let facts = context
1481            .input
1482            .trade_slots
1483            .iter()
1484            .find(|item| item.slot == self.slot)
1485            .ok_or_else(|| "declared trade slot facts are missing".to_string())?;
1486        let TradeSlotState::Open { opened_at, .. } = facts.state else {
1487            self.opened_at = None;
1488            self.count = 0;
1489            return Ok(Value::Missing(ScalarType::Integer));
1490        };
1491        if self.opened_at != Some(opened_at) {
1492            self.opened_at = Some(opened_at);
1493            self.count = 0;
1494        }
1495        if context.input.time > opened_at {
1496            let arrived = context
1497                .input
1498                .completed_bars
1499                .iter()
1500                .filter(|update| update.source == self.source)
1501                .count();
1502            self.count = self
1503                .count
1504                .checked_add(
1505                    i64::try_from(arrived).map_err(|_| "bar count overflowed".to_string())?,
1506                )
1507                .ok_or_else(|| "bar count overflowed".to_string())?;
1508        }
1509        Ok(Value::Integer(self.count))
1510    }
1511}
1512
1513#[derive(Clone)]
1514struct FeedbackEvaluator {
1515    slot: String,
1516    action: ConfiguredActionKind,
1517    field: FeedbackField,
1518}
1519impl MaterialEvaluator for FeedbackEvaluator {
1520    clone_eval!(Self);
1521    fn evaluate(
1522        &mut self,
1523        _: &[Value],
1524        context: &MaterialEvalContext<'_>,
1525    ) -> Result<Value, String> {
1526        Ok(Value::Bool(context.visible_feedback_matches(
1527            &self.slot,
1528            self.action,
1529            self.field,
1530        )))
1531    }
1532}
1533
1534pub(crate) fn bar_value(bar: &CompletedBar, field: BarField) -> Value {
1535    match field {
1536        BarField::Open => Value::Price(bar.open),
1537        BarField::High => Value::Price(bar.high),
1538        BarField::Low => Value::Price(bar.low),
1539        BarField::Close => Value::Price(bar.close),
1540        BarField::Volume => bar
1541            .volume
1542            .map(Value::Number)
1543            .unwrap_or(Value::Missing(ScalarType::Number)),
1544    }
1545}
1546
1547pub(crate) fn trade_slot_value(state: &TradeSlotState, field: PositionField) -> Value {
1548    match field {
1549        PositionField::Exists => Value::Bool(!matches!(state, TradeSlotState::Vacant)),
1550        PositionField::IsPending => Value::Bool(matches!(state, TradeSlotState::Pending { .. })),
1551        PositionField::IsOpen => Value::Bool(matches!(state, TradeSlotState::Open { .. })),
1552        PositionField::Side => match state {
1553            TradeSlotState::Pending { side, .. } | TradeSlotState::Open { side, .. } => {
1554                Value::Side(*side)
1555            }
1556            TradeSlotState::Vacant => Value::Missing(ScalarType::Side),
1557        },
1558        PositionField::EntryPrice => match state {
1559            TradeSlotState::Open { entry_price, .. } => Value::Price(*entry_price),
1560            _ => Value::Missing(ScalarType::Price),
1561        },
1562        PositionField::RemainingSize => match state {
1563            TradeSlotState::Open { remaining_size, .. } => Value::Number(*remaining_size),
1564            _ => Value::Missing(ScalarType::Number),
1565        },
1566        PositionField::Stoploss => match state {
1567            TradeSlotState::Pending { stoploss, .. } | TradeSlotState::Open { stoploss, .. } => {
1568                stoploss
1569                    .map(Value::Price)
1570                    .unwrap_or(Value::Missing(ScalarType::Price))
1571            }
1572            TradeSlotState::Vacant => Value::Missing(ScalarType::Price),
1573        },
1574        PositionField::OpenedAt => match state {
1575            TradeSlotState::Open { opened_at, .. } => Value::Timestamp(*opened_at),
1576            _ => Value::Missing(ScalarType::Timestamp),
1577        },
1578        PositionField::FavorableExcursion => open_number(state, |facts| facts.0),
1579        PositionField::AdverseExcursion => open_number(state, |facts| facts.1),
1580        PositionField::InitialRisk => open_number(state, |facts| facts.2),
1581    }
1582}
1583
1584/// Read one optional open-position economic fact as a number, missing unless the slot is open and the adapter supplied it.
1585fn open_number(
1586    state: &TradeSlotState,
1587    select: impl Fn((Option<f64>, Option<f64>, Option<f64>)) -> Option<f64>,
1588) -> Value {
1589    match state {
1590        TradeSlotState::Open {
1591            favorable_excursion,
1592            adverse_excursion,
1593            initial_risk,
1594            ..
1595        } => select((*favorable_excursion, *adverse_excursion, *initial_risk))
1596            .map(Value::Number)
1597            .unwrap_or(Value::Missing(ScalarType::Number)),
1598        _ => Value::Missing(ScalarType::Number),
1599    }
1600}
1601
1602fn numeric(scalar: ScalarType, value: f64) -> Value {
1603    match scalar {
1604        ScalarType::Price => Value::Price(value),
1605        ScalarType::Ratio => Value::Ratio(value),
1606        ScalarType::Percent => Value::Percent(value),
1607        ScalarType::PricePerObservation => Value::PricePerObservation(value),
1608        ScalarType::PricePerObservationSquared => Value::PricePerObservationSquared(value),
1609        ScalarType::RatioPerObservation => Value::RatioPerObservation(value),
1610        ScalarType::RatioPerObservationSquared => Value::RatioPerObservationSquared(value),
1611        ScalarType::LogReturn => Value::LogReturn(value),
1612        ScalarType::LogReturnVariance => Value::LogReturnVariance(value),
1613        _ => Value::Number(value),
1614    }
1615}
1616
1617fn numeric_value(value: &Value) -> Result<Option<f64>, String> {
1618    match value {
1619        Value::Missing(_) => Ok(None),
1620        Value::Integer(value) => Ok(Some(*value as f64)),
1621        Value::Number(value)
1622        | Value::Price(value)
1623        | Value::Ratio(value)
1624        | Value::Percent(value)
1625        | Value::PricePerObservation(value)
1626        | Value::PricePerObservationSquared(value)
1627        | Value::RatioPerObservation(value)
1628        | Value::RatioPerObservationSquared(value)
1629        | Value::LogReturn(value)
1630        | Value::LogReturnVariance(value)
1631            if value.is_finite() =>
1632        {
1633            Ok(Some(*value))
1634        }
1635        Value::Number(_)
1636        | Value::Price(_)
1637        | Value::Ratio(_)
1638        | Value::Percent(_)
1639        | Value::PricePerObservation(_)
1640        | Value::PricePerObservationSquared(_)
1641        | Value::RatioPerObservation(_)
1642        | Value::RatioPerObservationSquared(_)
1643        | Value::LogReturn(_)
1644        | Value::LogReturnVariance(_) => Err("numeric material input must be finite".into()),
1645        _ => Err("material input must be numeric".into()),
1646    }
1647}
1648
1649pub(crate) fn material_error(id: &str, reason: String) -> EvaluationError {
1650    EvaluationError::Material {
1651        material: id.into(),
1652        reason,
1653    }
1654}
1655
1656#[cfg(test)]
1657mod tests {
1658    use super::*;
1659
1660    #[test]
1661    fn strict_averages_preserve_extreme_values_through_seed_recursion_and_clone() {
1662        let input = input();
1663        let context = MaterialEvalContext {
1664            input: &input,
1665            input_updates: &[true],
1666            any_input_updates: &[true],
1667            feedback: &[],
1668            retained_feedback: &[],
1669        };
1670        for key in [MATERIAL_STRICT_SMA, MATERIAL_STRICT_EMA] {
1671            let factory = BuiltinFactory { key };
1672            let params = MaterialArgs::new([
1673                (
1674                    "source",
1675                    MaterialArg::Source(SourceId::new("fast").unwrap()),
1676                ),
1677                ("period", MaterialArg::Integer(3)),
1678            ]);
1679            for value in [f64::from_bits(1), -f64::from_bits(1), f64::MAX, -f64::MAX] {
1680                let mut evaluator = factory
1681                    .build(&params, &[ValueType::optional(ScalarType::Number)])
1682                    .unwrap()
1683                    .evaluator;
1684                for step in 0..16 {
1685                    evaluator = evaluator.clone_box();
1686                    let actual = evaluator
1687                        .evaluate(&[Value::Number(value)], &context)
1688                        .unwrap();
1689                    assert_eq!(
1690                        actual,
1691                        if step < 2 {
1692                            Value::Missing(ScalarType::Number)
1693                        } else {
1694                            Value::Number(value)
1695                        },
1696                        "{key}, sample {step}"
1697                    );
1698                }
1699                let mut fork = evaluator.clone_box();
1700                assert_eq!(
1701                    fork.evaluate(&[Value::Missing(ScalarType::Number)], &context)
1702                        .unwrap(),
1703                    Value::Missing(ScalarType::Number)
1704                );
1705                assert_eq!(
1706                    evaluator
1707                        .evaluate(&[Value::Number(value)], &context)
1708                        .unwrap(),
1709                    Value::Number(value)
1710                );
1711            }
1712            let mut evaluator = factory
1713                .build(&params, &[ValueType::optional(ScalarType::Number)])
1714                .unwrap()
1715                .evaluator;
1716            for value in [f64::MAX, -f64::MAX] {
1717                assert_eq!(
1718                    evaluator
1719                        .evaluate(&[Value::Number(value)], &context)
1720                        .unwrap(),
1721                    Value::Missing(ScalarType::Number)
1722                );
1723            }
1724            assert_eq!(
1725                evaluator
1726                    .evaluate(&[Value::Number(3.0 * f64::from_bits(1))], &context)
1727                    .unwrap(),
1728                Value::Number(f64::from_bits(1))
1729            );
1730        }
1731    }
1732
1733    #[test]
1734    fn strict_average_seeds_do_not_overflow_for_a_representable_mean() {
1735        let library = MaterialLibrary::builtins();
1736        let arguments = MaterialArgs::new([
1737            (
1738                "source",
1739                MaterialArg::Source(SourceId::new("fast").unwrap()),
1740            ),
1741            ("period", MaterialArg::Integer(2)),
1742        ]);
1743        let types = [ValueType::optional(ScalarType::Number)];
1744        let input = input();
1745        let context = MaterialEvalContext {
1746            input: &input,
1747            input_updates: &[true],
1748            any_input_updates: &[true],
1749            feedback: &[],
1750            retained_feedback: &[],
1751        };
1752        for key in [MATERIAL_STRICT_SMA, MATERIAL_STRICT_EMA] {
1753            let mut evaluator = library
1754                .factory(key)
1755                .unwrap()
1756                .build(&arguments, &types)
1757                .unwrap()
1758                .evaluator;
1759            assert_eq!(
1760                evaluator
1761                    .evaluate(&[Value::Number(f64::MAX)], &context)
1762                    .unwrap(),
1763                Value::Missing(ScalarType::Number)
1764            );
1765            assert_eq!(
1766                evaluator
1767                    .evaluate(&[Value::Number(f64::MAX)], &context)
1768                    .unwrap(),
1769                Value::Number(f64::MAX)
1770            );
1771        }
1772    }
1773
1774    #[test]
1775    fn strict_average_rounds_representable_subnormal_means_without_early_underflow() {
1776        let least = f64::from_bits(1);
1777        assert_eq!(stable_mean([least, least, 0.0, 0.0]).unwrap(), 0.0);
1778        assert_eq!(stable_mean([least, least, least]).unwrap(), least);
1779        let library = MaterialLibrary::builtins();
1780        let arguments = MaterialArgs::new([
1781            (
1782                "source",
1783                MaterialArg::Source(SourceId::new("fast").unwrap()),
1784            ),
1785            ("period", MaterialArg::Integer(4)),
1786        ]);
1787        let types = [ValueType::optional(ScalarType::Number)];
1788        let input = input();
1789        let context = MaterialEvalContext {
1790            input: &input,
1791            input_updates: &[true],
1792            any_input_updates: &[true],
1793            feedback: &[],
1794            retained_feedback: &[],
1795        };
1796        for key in [MATERIAL_STRICT_SMA, MATERIAL_STRICT_EMA] {
1797            let mut evaluator = library
1798                .factory(key)
1799                .unwrap()
1800                .build(&arguments, &types)
1801                .unwrap()
1802                .evaluator;
1803            for value in [least, least, 0.0] {
1804                assert_eq!(
1805                    evaluator
1806                        .evaluate(&[Value::Number(value)], &context)
1807                        .unwrap(),
1808                    Value::Missing(ScalarType::Number)
1809                );
1810            }
1811            assert_eq!(
1812                evaluator.evaluate(&[Value::Number(0.0)], &context).unwrap(),
1813                Value::Number(0.0)
1814            );
1815        }
1816    }
1817
1818    #[test]
1819    fn strict_averages_consume_missing_samples_and_reseed_only_recursive_state() {
1820        let library = MaterialLibrary::builtins();
1821        let arguments = MaterialArgs::new([
1822            (
1823                "source",
1824                MaterialArg::Source(SourceId::new("fast").unwrap()),
1825            ),
1826            ("period", MaterialArg::Integer(3)),
1827        ]);
1828        let types = [ValueType::optional(ScalarType::Price)];
1829        let mut sma = library
1830            .factory(MATERIAL_STRICT_SMA)
1831            .unwrap()
1832            .build(&arguments, &types)
1833            .unwrap()
1834            .evaluator;
1835        let mut ema = library
1836            .factory(MATERIAL_STRICT_EMA)
1837            .unwrap()
1838            .build(&arguments, &types)
1839            .unwrap()
1840            .evaluator;
1841        let input = input();
1842        let context = MaterialEvalContext {
1843            input: &input,
1844            input_updates: &[true],
1845            any_input_updates: &[true],
1846            feedback: &[],
1847            retained_feedback: &[],
1848        };
1849        for (sample, expected_sma, expected_ema) in [
1850            (
1851                Value::Price(1.0),
1852                Value::Missing(ScalarType::Price),
1853                Value::Missing(ScalarType::Price),
1854            ),
1855            (
1856                Value::Price(2.0),
1857                Value::Missing(ScalarType::Price),
1858                Value::Missing(ScalarType::Price),
1859            ),
1860            (Value::Price(3.0), Value::Price(2.0), Value::Price(2.0)),
1861            (
1862                Value::Missing(ScalarType::Price),
1863                Value::Missing(ScalarType::Price),
1864                Value::Missing(ScalarType::Price),
1865            ),
1866            (
1867                Value::Price(4.0),
1868                Value::Missing(ScalarType::Price),
1869                Value::Missing(ScalarType::Price),
1870            ),
1871            (
1872                Value::Price(5.0),
1873                Value::Missing(ScalarType::Price),
1874                Value::Missing(ScalarType::Price),
1875            ),
1876            (Value::Price(6.0), Value::Price(5.0), Value::Price(5.0)),
1877            (
1878                Value::Price(8.0),
1879                Value::Price(19.0 / 3.0),
1880                Value::Price(6.5),
1881            ),
1882        ] {
1883            assert_eq!(
1884                sma.evaluate(std::slice::from_ref(&sample), &context)
1885                    .unwrap(),
1886                expected_sma
1887            );
1888            assert_eq!(ema.evaluate(&[sample], &context).unwrap(), expected_ema);
1889        }
1890    }
1891
1892    fn input() -> StrategyInput {
1893        StrategyInput {
1894            time: NaiveDateTime::default(),
1895            ready: true,
1896            completed_bars: vec![],
1897            values: vec![],
1898            trade_slots: vec![],
1899            feedback: vec![],
1900        }
1901    }
1902
1903    fn evaluate_values(evaluator: &mut dyn MaterialEvaluator, values: &[f64]) -> Vec<Value> {
1904        let input = input();
1905        let context = MaterialEvalContext {
1906            input: &input,
1907            input_updates: &[true],
1908            any_input_updates: &[true],
1909            feedback: &[],
1910            retained_feedback: &[],
1911        };
1912        values
1913            .iter()
1914            .map(|value| {
1915                evaluator
1916                    .evaluate(&[Value::Number(*value)], &context)
1917                    .unwrap()
1918            })
1919            .collect()
1920    }
1921
1922    fn final_number(values: Vec<Value>) -> f64 {
1923        match values.last().unwrap() {
1924            Value::Number(value)
1925            | Value::Price(value)
1926            | Value::Ratio(value)
1927            | Value::Percent(value)
1928            | Value::PricePerObservation(value)
1929            | Value::PricePerObservationSquared(value)
1930            | Value::RatioPerObservation(value)
1931            | Value::RatioPerObservationSquared(value)
1932            | Value::LogReturn(value)
1933            | Value::LogReturnVariance(value) => *value,
1934            value => panic!("unexpected material output {value:?}"),
1935        }
1936    }
1937
1938    #[test]
1939    fn ema_matches_a_known_recursive_update() {
1940        let mut ema = EmaEvaluator {
1941            alpha: 0.5,
1942            value: None,
1943            scalar: ScalarType::Number,
1944        };
1945        assert!((final_number(evaluate_values(&mut ema, &[1.0, 2.0, 3.0])) - 2.25).abs() < 1e-12);
1946    }
1947
1948    #[test]
1949    fn legacy_seed_and_valid_sample_missing_behavior_is_stable() {
1950        let input = input();
1951        let context = MaterialEvalContext {
1952            input: &input,
1953            input_updates: &[true],
1954            any_input_updates: &[true],
1955            feedback: &[],
1956            retained_feedback: &[],
1957        };
1958
1959        let mut ema = EmaEvaluator {
1960            alpha: 0.5,
1961            value: None,
1962            scalar: ScalarType::Number,
1963        };
1964        assert_eq!(
1965            ema.evaluate(&[Value::Missing(ScalarType::Number)], &context)
1966                .unwrap(),
1967            Value::Missing(ScalarType::Number)
1968        );
1969        assert_eq!(
1970            ema.evaluate(&[Value::Number(10.0)], &context).unwrap(),
1971            Value::Number(10.0)
1972        );
1973
1974        let mut rolling = RollingEvaluator {
1975            period: 2,
1976            values: VecDeque::new(),
1977            scalar: ScalarType::Number,
1978            kind: RollingKind::Mean,
1979        };
1980        assert_eq!(
1981            rolling.evaluate(&[Value::Number(1.0)], &context).unwrap(),
1982            Value::Missing(ScalarType::Number)
1983        );
1984        assert_eq!(
1985            rolling
1986                .evaluate(&[Value::Missing(ScalarType::Number)], &context)
1987                .unwrap(),
1988            Value::Missing(ScalarType::Number)
1989        );
1990        assert_eq!(
1991            rolling.evaluate(&[Value::Number(3.0)], &context).unwrap(),
1992            Value::Number(2.0)
1993        );
1994
1995        let mut lag = LagEvaluator {
1996            period: 1,
1997            values: VecDeque::new(),
1998            scalar: ScalarType::Number,
1999        };
2000        assert_eq!(
2001            lag.evaluate(&[Value::Number(1.0)], &context).unwrap(),
2002            Value::Missing(ScalarType::Number)
2003        );
2004        assert_eq!(
2005            lag.evaluate(&[Value::Missing(ScalarType::Number)], &context)
2006                .unwrap(),
2007            Value::Missing(ScalarType::Number)
2008        );
2009        assert_eq!(
2010            lag.evaluate(&[Value::Number(3.0)], &context).unwrap(),
2011            Value::Number(1.0)
2012        );
2013    }
2014
2015    #[test]
2016    fn rolling_indicators_evict_the_oldest_sample() {
2017        let mut sma = RollingEvaluator {
2018            period: 3,
2019            values: VecDeque::new(),
2020            scalar: ScalarType::Number,
2021            kind: RollingKind::Mean,
2022        };
2023        assert_eq!(
2024            final_number(evaluate_values(&mut sma, &[1.0, 2.0, 3.0, 4.0])),
2025            3.0
2026        );
2027
2028        let mut stddev = RollingEvaluator {
2029            period: 3,
2030            values: VecDeque::new(),
2031            scalar: ScalarType::Number,
2032            kind: RollingKind::PopulationStdDev,
2033        };
2034        assert!(
2035            (final_number(evaluate_values(&mut stddev, &[1.0, 2.0, 3.0, 7.0]))
2036                - (14.0_f64 / 3.0).sqrt())
2037            .abs()
2038                < 1e-12
2039        );
2040
2041        let mut minimum = RollingEvaluator {
2042            period: 3,
2043            values: VecDeque::new(),
2044            scalar: ScalarType::Number,
2045            kind: RollingKind::Min,
2046        };
2047        assert_eq!(
2048            final_number(evaluate_values(&mut minimum, &[0.0, 3.0, 1.0, 2.0])),
2049            1.0
2050        );
2051
2052        let mut maximum = RollingEvaluator {
2053            period: 3,
2054            values: VecDeque::new(),
2055            scalar: ScalarType::Number,
2056            kind: RollingKind::Max,
2057        };
2058        assert_eq!(
2059            final_number(evaluate_values(&mut maximum, &[9.0, 1.0, 3.0, 2.0])),
2060            3.0
2061        );
2062    }
2063
2064    #[test]
2065    fn lag_evicts_values_after_the_requested_distance() {
2066        let mut lag = LagEvaluator {
2067            period: 2,
2068            values: VecDeque::new(),
2069            scalar: ScalarType::Number,
2070        };
2071        assert_eq!(
2072            final_number(evaluate_values(&mut lag, &[1.0, 2.0, 3.0, 4.0])),
2073            2.0
2074        );
2075    }
2076
2077    #[test]
2078    fn rsi_applies_wilder_smoothing_after_its_seed_window() {
2079        let mut rsi = RsiEvaluator {
2080            period: 2,
2081            previous: None,
2082            seed_gains: 0.0,
2083            seed_losses: 0.0,
2084            seed_changes: 0,
2085            average_gain: None,
2086            average_loss: None,
2087        };
2088        let values = evaluate_values(&mut rsi, &[1.0, 2.0, 1.0, 3.0]);
2089        assert_eq!(values[2], Value::Number(50.0));
2090        assert!((final_number(values) - 83.333_333_333_333_33).abs() < 1e-12);
2091        assert_eq!(rsi.average_gain, Some(1.25));
2092        assert_eq!(rsi.average_loss, Some(0.25));
2093    }
2094
2095    #[test]
2096    fn atr_matches_a_hand_computed_wilder_update() {
2097        let source = SourceId::new("primary").unwrap();
2098        let mut atr = AtrEvaluator {
2099            source: source.clone(),
2100            alpha: 0.5,
2101            previous_close: None,
2102            value: None,
2103        };
2104        let first = StrategyInput {
2105            completed_bars: vec![CompletedBarUpdate {
2106                source: source.clone(),
2107                bar: CompletedBar {
2108                    open: 10.0,
2109                    high: 10.0,
2110                    low: 10.0,
2111                    close: 10.0,
2112                    volume: Some(1.0),
2113                },
2114            }],
2115            ..input()
2116        };
2117        let second = StrategyInput {
2118            completed_bars: vec![CompletedBarUpdate {
2119                source,
2120                bar: CompletedBar {
2121                    open: 11.0,
2122                    high: 13.0,
2123                    low: 9.0,
2124                    close: 12.0,
2125                    volume: Some(1.0),
2126                },
2127            }],
2128            ..input()
2129        };
2130        let first_output = atr
2131            .evaluate(
2132                &[],
2133                &MaterialEvalContext {
2134                    input: &first,
2135                    input_updates: &[],
2136                    any_input_updates: &[],
2137                    feedback: &[],
2138                    retained_feedback: &[],
2139                },
2140            )
2141            .unwrap();
2142        assert_eq!(first_output, Value::Price(0.0));
2143        atr.evaluate(
2144            &[],
2145            &MaterialEvalContext {
2146                input: &second,
2147                input_updates: &[],
2148                any_input_updates: &[],
2149                feedback: &[],
2150                retained_feedback: &[],
2151            },
2152        )
2153        .unwrap();
2154        assert_eq!(atr.value, Some(2.0));
2155    }
2156}