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";
15pub const MATERIAL_WEEKDAY: &str = "weekday";
17pub 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 opened_at: NaiveDateTime,
86 favorable_excursion: Option<f64>,
88 adverse_excursion: Option<f64>,
90 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 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 pub entries: Vec<EntryRequirement>,
171 pub stop_managed_slots: Vec<String>,
173}
174
175#[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 pub input_updates: &'a [bool],
200 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
238pub 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 AllInputs,
266 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
287pub 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 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 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#[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#[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
1584fn 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(¶ms, &[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(¶ms, &[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}