Skip to main content

qs_strategy/
material.rs

1use std::sync::Arc;
2
3use chrono::NaiveDateTime;
4use qs_core::Side;
5
6use crate::{
7    BarField, CompileError, ConfiguredActionKind, EvaluationError, FeedbackField, MaterialParams,
8    PositionField, ScalarType, SourceId, Value, ValueType,
9};
10
11pub const MATERIAL_BAR_FIELD: &str = "completed_bar_field";
12pub const MATERIAL_INPUT_TIME: &str = "input_time";
13pub const MATERIAL_READINESS: &str = "readiness";
14pub const MATERIAL_EMA: &str = "ema";
15pub const MATERIAL_ATR: &str = "atr";
16pub const MATERIAL_CROSS_ABOVE: &str = "cross_above";
17pub const MATERIAL_CROSS_BELOW: &str = "cross_below";
18pub const MATERIAL_POSITION_EXISTS: &str = "position_exists";
19pub const MATERIAL_POSITION_PENDING: &str = "position_pending";
20pub const MATERIAL_POSITION_OPEN: &str = "position_open";
21pub const MATERIAL_POSITION_ENTRY_PRICE: &str = "position_entry_price";
22pub const MATERIAL_POSITION_SIDE: &str = "position_side";
23pub const MATERIAL_POSITION_REMAINING_SIZE: &str = "position_remaining_size";
24pub const MATERIAL_POSITION_STOPLOSS: &str = "position_stoploss";
25pub const MATERIAL_ENTRY_FILLED: &str = "entry_filled";
26pub const MATERIAL_ENTRY_REJECTED: &str = "entry_rejected";
27pub const MATERIAL_POSITION_CLOSED: &str = "position_closed";
28pub const MATERIAL_CANCELLATION_APPLIED: &str = "cancellation_applied";
29pub const MATERIAL_CANCELLATION_REJECTED: &str = "cancellation_rejected";
30
31#[derive(Debug, Clone, PartialEq)]
32pub struct CompletedBar {
33    pub open: f64,
34    pub high: f64,
35    pub low: f64,
36    pub close: f64,
37    pub volume: f64,
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub struct CompletedBarUpdate {
42    pub source: SourceId,
43    pub bar: CompletedBar,
44}
45
46#[derive(Debug, Clone, PartialEq)]
47pub struct NamedValue {
48    pub name: String,
49    pub value: Value,
50    pub updated: bool,
51}
52
53#[derive(Debug, Clone, PartialEq)]
54pub enum TradeSlotState {
55    Vacant,
56    Pending {
57        side: Side,
58        requested_price: Option<f64>,
59        stoploss: Option<f64>,
60    },
61    Open {
62        side: Side,
63        entry_price: f64,
64        remaining_size: f64,
65        stoploss: Option<f64>,
66    },
67}
68
69#[derive(Debug, Clone, PartialEq)]
70pub struct TradeSlotFacts {
71    pub slot: String,
72    pub state: TradeSlotState,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
76pub enum CommandFact {
77    EntryFilled,
78    PositionReduced,
79    PositionClosed,
80    StoplossModified,
81    PendingCancelled,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum CommandTerminalStatus {
86    Applied,
87    Skipped,
88    Rejected,
89    Failed,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum CommandFeedback {
94    Fact {
95        command_id: String,
96        fact: CommandFact,
97    },
98    Terminal {
99        command_id: String,
100        status: CommandTerminalStatus,
101        reason: Option<String>,
102    },
103}
104
105impl CommandFeedback {
106    pub fn command_id(&self) -> &str {
107        match self {
108            Self::Fact { command_id, .. } | Self::Terminal { command_id, .. } => command_id,
109        }
110    }
111}
112
113#[derive(Debug, Clone, PartialEq)]
114pub struct StrategyInput {
115    pub time: NaiveDateTime,
116    pub ready: bool,
117    pub completed_bars: Vec<CompletedBarUpdate>,
118    pub values: Vec<NamedValue>,
119    pub trade_slots: Vec<TradeSlotFacts>,
120    pub feedback: Vec<CommandFeedback>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct CompletedBarRequirement {
125    pub source: SourceId,
126    pub required_lookback: usize,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct NamedInputRequirement {
131    pub name: String,
132    pub value_type: ValueType,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ConfiguredStrategyRequirements {
137    pub completed_bars: Vec<CompletedBarRequirement>,
138    pub named_inputs: Vec<NamedInputRequirement>,
139    pub trade_slots: Vec<String>,
140    pub needs_command_feedback: bool,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum MaterialLookback {
145    None,
146    Sources(Vec<CompletedBarRequirement>),
147    InheritInputs { minimum: usize },
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub(crate) struct FeedbackObservation {
152    pub slot: String,
153    pub action: ConfiguredActionKind,
154    pub field: FeedbackField,
155}
156
157pub struct MaterialEvalContext<'a> {
158    pub input: &'a StrategyInput,
159    pub input_updates: &'a [bool],
160    pub(crate) feedback: &'a [FeedbackObservation],
161    pub(crate) retained_feedback: &'a [FeedbackObservation],
162}
163
164impl MaterialEvalContext<'_> {
165    pub fn feedback_matches(
166        &self,
167        slot: &str,
168        action: ConfiguredActionKind,
169        field: FeedbackField,
170    ) -> bool {
171        feedback_matches(self.feedback, slot, action, field)
172    }
173
174    fn visible_feedback_matches(
175        &self,
176        slot: &str,
177        action: ConfiguredActionKind,
178        field: FeedbackField,
179    ) -> bool {
180        self.feedback_matches(slot, action, field)
181            || feedback_matches(self.retained_feedback, slot, action, field)
182    }
183}
184
185fn feedback_matches(
186    feedback: &[FeedbackObservation],
187    slot: &str,
188    action: ConfiguredActionKind,
189    field: FeedbackField,
190) -> bool {
191    feedback
192        .iter()
193        .any(|item| item.slot == slot && item.action == action && item.field == field)
194}
195
196/// Stateful deterministic material evaluator.
197///
198/// `clone_box` must deep-clone all semantic evaluator state. Shared mutable semantic state and
199/// external side effects violate this contract. Shared immutable factory data and non-semantic
200/// telemetry are allowed. The runtime clones evaluators before a boundary and commits those clones
201/// only after the complete strategy evaluation succeeds.
202pub trait MaterialEvaluator: Send {
203    fn clone_box(&self) -> Box<dyn MaterialEvaluator>;
204    fn evaluate(
205        &mut self,
206        inputs: &[Value],
207        context: &MaterialEvalContext<'_>,
208    ) -> Result<Value, String>;
209}
210
211impl Clone for Box<dyn MaterialEvaluator> {
212    fn clone(&self) -> Self {
213        self.clone_box()
214    }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum MaterialUpdateTrigger {
219    EveryInput,
220    Source(SourceId),
221    FeedbackPulse,
222    AllInputs,
223}
224
225/// Result of constructing one material evaluator.
226///
227/// `max_state_bytes` is the factory's deterministic upper bound for evaluator-owned semantic state.
228pub struct MaterialBuild {
229    pub output_type: ValueType,
230    pub lookback: MaterialLookback,
231    pub max_state_bytes: usize,
232    pub evaluator: Box<dyn MaterialEvaluator>,
233}
234
235pub trait MaterialFactory: Send + Sync {
236    fn build(
237        &self,
238        params: &MaterialParams,
239        input_types: &[ValueType],
240    ) -> Result<MaterialBuild, String>;
241
242    fn update_trigger(
243        &self,
244        _params: &MaterialParams,
245        _input_types: &[ValueType],
246    ) -> Result<MaterialUpdateTrigger, String> {
247        Ok(MaterialUpdateTrigger::EveryInput)
248    }
249}
250
251#[derive(Clone)]
252struct Registration {
253    key: String,
254    factory: Arc<dyn MaterialFactory>,
255    custom: bool,
256}
257
258#[derive(Clone)]
259pub struct MaterialLibrary {
260    registrations: Vec<Registration>,
261}
262
263impl MaterialLibrary {
264    pub fn builtins() -> Self {
265        let keys = [
266            MATERIAL_BAR_FIELD,
267            MATERIAL_INPUT_TIME,
268            MATERIAL_READINESS,
269            MATERIAL_EMA,
270            MATERIAL_ATR,
271            MATERIAL_CROSS_ABOVE,
272            MATERIAL_CROSS_BELOW,
273            MATERIAL_POSITION_EXISTS,
274            MATERIAL_POSITION_PENDING,
275            MATERIAL_POSITION_OPEN,
276            MATERIAL_POSITION_ENTRY_PRICE,
277            MATERIAL_POSITION_SIDE,
278            MATERIAL_POSITION_REMAINING_SIZE,
279            MATERIAL_POSITION_STOPLOSS,
280            MATERIAL_ENTRY_FILLED,
281            MATERIAL_ENTRY_REJECTED,
282            MATERIAL_POSITION_CLOSED,
283            MATERIAL_CANCELLATION_APPLIED,
284            MATERIAL_CANCELLATION_REJECTED,
285        ];
286        Self {
287            registrations: keys
288                .into_iter()
289                .map(|key| Registration {
290                    key: key.into(),
291                    factory: Arc::new(BuiltinFactory { key }),
292                    custom: false,
293                })
294                .collect(),
295        }
296    }
297
298    pub fn with_factory(
299        mut self,
300        key: impl Into<String>,
301        factory: Arc<dyn MaterialFactory>,
302    ) -> Result<Self, CompileError> {
303        let key = key.into();
304        crate::validate_id(&key).map_err(|reason| CompileError::InvalidIdentifier {
305            path: "material_library.key".into(),
306            reason,
307        })?;
308        if self.registrations.iter().any(|item| item.key == key) {
309            return Err(CompileError::DuplicateIdentifier {
310                path: "material_library".into(),
311                id: key,
312            });
313        }
314        self.registrations.push(Registration {
315            key,
316            factory,
317            custom: true,
318        });
319        Ok(self)
320    }
321
322    pub(crate) fn factory(&self, key: &str) -> Option<&Arc<dyn MaterialFactory>> {
323        self.registration(key).map(|item| &item.factory)
324    }
325
326    pub(crate) fn is_custom(&self, key: &str) -> bool {
327        self.registration(key).is_some_and(|item| item.custom)
328    }
329
330    fn registration(&self, key: &str) -> Option<&Registration> {
331        self.registrations.iter().find(|item| item.key == key)
332    }
333}
334
335struct BuiltinFactory {
336    key: &'static str,
337}
338
339impl MaterialFactory for BuiltinFactory {
340    fn build(
341        &self,
342        params: &MaterialParams,
343        inputs: &[ValueType],
344    ) -> Result<MaterialBuild, String> {
345        let state_bytes = match self.key {
346            MATERIAL_EMA => 32,
347            MATERIAL_ATR | MATERIAL_CROSS_ABOVE | MATERIAL_CROSS_BELOW => 48,
348            _ => crate::MAX_GENERATED_ID_BYTES + 64,
349        };
350        let build = |output_type, lookback, evaluator: Box<dyn MaterialEvaluator>| {
351            Ok(MaterialBuild {
352                output_type,
353                lookback,
354                max_state_bytes: state_bytes,
355                evaluator,
356            })
357        };
358        match self.key {
359            MATERIAL_BAR_FIELD => {
360                require_inputs(inputs, &[])?;
361                let MaterialParams::BarField { source, field } = params else {
362                    return Err("bar field parameters are required".into());
363                };
364                build(
365                    crate::bar_field_type(*field),
366                    source_lookback(source.clone(), 1),
367                    Box::new(BarFieldEvaluator {
368                        source: source.clone(),
369                        field: *field,
370                    }),
371                )
372            }
373            MATERIAL_INPUT_TIME => {
374                require_none(params)?;
375                require_inputs(inputs, &[])?;
376                build(
377                    ValueType::required(ScalarType::Timestamp),
378                    MaterialLookback::None,
379                    Box::new(InputTimeEvaluator),
380                )
381            }
382            MATERIAL_READINESS => {
383                require_none(params)?;
384                require_inputs(inputs, &[])?;
385                build(
386                    ValueType::required(ScalarType::Bool),
387                    MaterialLookback::None,
388                    Box::new(ReadinessEvaluator),
389                )
390            }
391            MATERIAL_EMA => {
392                let MaterialParams::Ema { period } = params else {
393                    return Err("EMA parameters are required".into());
394                };
395                let period = checked_period(*period)?;
396                require_one_numeric(inputs)?;
397                build(
398                    ValueType::optional(inputs[0].scalar),
399                    MaterialLookback::InheritInputs { minimum: period },
400                    Box::new(EmaEvaluator {
401                        alpha: 2.0 / (period as f64 + 1.0),
402                        value: None,
403                        scalar: inputs[0].scalar,
404                    }),
405                )
406            }
407            MATERIAL_ATR => {
408                let MaterialParams::Atr { source, period } = params else {
409                    return Err("ATR parameters are required".into());
410                };
411                let period = checked_period(*period)?;
412                require_inputs(inputs, &[])?;
413                build(
414                    ValueType::optional(ScalarType::Price),
415                    source_lookback(source.clone(), period + 1),
416                    Box::new(AtrEvaluator {
417                        source: source.clone(),
418                        alpha: 1.0 / period as f64,
419                        previous_close: None,
420                        value: None,
421                    }),
422                )
423            }
424            MATERIAL_CROSS_ABOVE | MATERIAL_CROSS_BELOW => {
425                require_none(params)?;
426                require_cross(inputs)?;
427                build(
428                    ValueType::required(ScalarType::Bool),
429                    MaterialLookback::InheritInputs { minimum: 0 },
430                    Box::new(CrossEvaluator {
431                        above: self.key == MATERIAL_CROSS_ABOVE,
432                        previous: None,
433                    }),
434                )
435            }
436            MATERIAL_POSITION_EXISTS => position_build(params, inputs, PositionField::Exists),
437            MATERIAL_POSITION_PENDING => position_build(params, inputs, PositionField::IsPending),
438            MATERIAL_POSITION_OPEN => position_build(params, inputs, PositionField::IsOpen),
439            MATERIAL_POSITION_ENTRY_PRICE => {
440                position_build(params, inputs, PositionField::EntryPrice)
441            }
442            MATERIAL_POSITION_SIDE => position_build(params, inputs, PositionField::Side),
443            MATERIAL_POSITION_REMAINING_SIZE => {
444                position_build(params, inputs, PositionField::RemainingSize)
445            }
446            MATERIAL_POSITION_STOPLOSS => position_build(params, inputs, PositionField::Stoploss),
447            MATERIAL_ENTRY_FILLED => feedback_build(params, inputs, FeedbackField::EntryFilled),
448            MATERIAL_ENTRY_REJECTED => feedback_build(params, inputs, FeedbackField::EntryRejected),
449            MATERIAL_POSITION_CLOSED => {
450                feedback_build(params, inputs, FeedbackField::PositionClosed)
451            }
452            MATERIAL_CANCELLATION_APPLIED => {
453                feedback_build(params, inputs, FeedbackField::CancellationApplied)
454            }
455            MATERIAL_CANCELLATION_REJECTED => {
456                feedback_build(params, inputs, FeedbackField::CancellationRejected)
457            }
458            _ => Err("unknown built-in material".into()),
459        }
460    }
461
462    fn update_trigger(
463        &self,
464        params: &MaterialParams,
465        _inputs: &[ValueType],
466    ) -> Result<MaterialUpdateTrigger, String> {
467        Ok(match (self.key, params) {
468            (MATERIAL_BAR_FIELD, MaterialParams::BarField { source, .. })
469            | (MATERIAL_ATR, MaterialParams::Atr { source, .. }) => {
470                MaterialUpdateTrigger::Source(source.clone())
471            }
472            (MATERIAL_EMA | MATERIAL_CROSS_ABOVE | MATERIAL_CROSS_BELOW, _) => {
473                MaterialUpdateTrigger::AllInputs
474            }
475            (
476                MATERIAL_ENTRY_FILLED
477                | MATERIAL_ENTRY_REJECTED
478                | MATERIAL_POSITION_CLOSED
479                | MATERIAL_CANCELLATION_APPLIED
480                | MATERIAL_CANCELLATION_REJECTED,
481                _,
482            ) => MaterialUpdateTrigger::FeedbackPulse,
483            _ => MaterialUpdateTrigger::EveryInput,
484        })
485    }
486}
487
488fn source_lookback(source: SourceId, required_lookback: usize) -> MaterialLookback {
489    MaterialLookback::Sources(vec![CompletedBarRequirement {
490        source,
491        required_lookback,
492    }])
493}
494
495fn checked_period(period: u16) -> Result<usize, String> {
496    let period = usize::from(period);
497    if period == 0 || period > crate::MAX_MATERIAL_LOOKBACK {
498        Err("period is outside the supported lookback bound".into())
499    } else {
500        Ok(period)
501    }
502}
503
504fn require_none(params: &MaterialParams) -> Result<(), String> {
505    if matches!(params, MaterialParams::None) {
506        Ok(())
507    } else {
508        Err("material takes no parameters".into())
509    }
510}
511
512fn require_inputs(actual: &[ValueType], expected: &[ValueType]) -> Result<(), String> {
513    if actual == expected {
514        Ok(())
515    } else {
516        Err(format!("expected inputs {expected:?}, got {actual:?}"))
517    }
518}
519
520fn require_one_numeric(inputs: &[ValueType]) -> Result<(), String> {
521    if inputs.len() == 1 && matches!(inputs[0].scalar, ScalarType::Number | ScalarType::Price) {
522        Ok(())
523    } else {
524        Err("expected one number or price input".into())
525    }
526}
527
528fn require_cross(inputs: &[ValueType]) -> Result<(), String> {
529    if inputs.len() == 2
530        && inputs[0].scalar == inputs[1].scalar
531        && matches!(
532            inputs[0].scalar,
533            ScalarType::Integer | ScalarType::Number | ScalarType::Price
534        )
535    {
536        Ok(())
537    } else {
538        Err("expected two inputs of the same numeric type".into())
539    }
540}
541
542fn position_build(
543    params: &MaterialParams,
544    inputs: &[ValueType],
545    field: PositionField,
546) -> Result<MaterialBuild, String> {
547    require_inputs(inputs, &[])?;
548    let MaterialParams::Position { slot } = params else {
549        return Err("position parameters are required".into());
550    };
551    crate::validate_id(slot)?;
552    let output_type = position_field_type(field);
553    Ok(MaterialBuild {
554        output_type,
555        lookback: MaterialLookback::None,
556        max_state_bytes: crate::MAX_ID_BYTES + 64,
557        evaluator: Box::new(PositionEvaluator {
558            slot: slot.clone(),
559            field,
560        }),
561    })
562}
563
564fn feedback_build(
565    params: &MaterialParams,
566    inputs: &[ValueType],
567    field: FeedbackField,
568) -> Result<MaterialBuild, String> {
569    require_inputs(inputs, &[])?;
570    let MaterialParams::Feedback { slot, action } = params else {
571        return Err("feedback parameters are required".into());
572    };
573    crate::validate_id(slot)?;
574    Ok(MaterialBuild {
575        output_type: ValueType::required(ScalarType::Bool),
576        lookback: MaterialLookback::None,
577        max_state_bytes: 0,
578        evaluator: Box::new(FeedbackEvaluator {
579            slot: slot.clone(),
580            action: *action,
581            field,
582        }),
583    })
584}
585
586pub(crate) fn position_field_type(field: PositionField) -> ValueType {
587    match field {
588        PositionField::Exists | PositionField::IsPending | PositionField::IsOpen => {
589            ValueType::required(ScalarType::Bool)
590        }
591        PositionField::EntryPrice | PositionField::Stoploss => {
592            ValueType::optional(ScalarType::Price)
593        }
594        PositionField::Side => ValueType::optional(ScalarType::Side),
595        PositionField::RemainingSize => ValueType::optional(ScalarType::Number),
596    }
597}
598
599macro_rules! clone_eval {
600    ($ty:ty) => {
601        fn clone_box(&self) -> Box<dyn MaterialEvaluator> {
602            Box::new(self.clone())
603        }
604    };
605}
606
607#[derive(Clone)]
608struct BarFieldEvaluator {
609    source: SourceId,
610    field: BarField,
611}
612impl MaterialEvaluator for BarFieldEvaluator {
613    clone_eval!(Self);
614    fn evaluate(
615        &mut self,
616        _: &[Value],
617        context: &MaterialEvalContext<'_>,
618    ) -> Result<Value, String> {
619        let update = context
620            .input
621            .completed_bars
622            .iter()
623            .find(|item| item.source == self.source)
624            .ok_or_else(|| "configured bar source did not update".to_string())?;
625        Ok(bar_value(&update.bar, self.field))
626    }
627}
628
629#[derive(Clone)]
630struct InputTimeEvaluator;
631impl MaterialEvaluator for InputTimeEvaluator {
632    clone_eval!(Self);
633    fn evaluate(
634        &mut self,
635        _: &[Value],
636        context: &MaterialEvalContext<'_>,
637    ) -> Result<Value, String> {
638        Ok(Value::Timestamp(context.input.time))
639    }
640}
641
642#[derive(Clone)]
643struct ReadinessEvaluator;
644impl MaterialEvaluator for ReadinessEvaluator {
645    clone_eval!(Self);
646    fn evaluate(
647        &mut self,
648        _: &[Value],
649        context: &MaterialEvalContext<'_>,
650    ) -> Result<Value, String> {
651        Ok(Value::Bool(context.input.ready))
652    }
653}
654
655#[derive(Clone)]
656struct EmaEvaluator {
657    alpha: f64,
658    value: Option<f64>,
659    scalar: ScalarType,
660}
661impl MaterialEvaluator for EmaEvaluator {
662    clone_eval!(Self);
663    fn evaluate(&mut self, inputs: &[Value], _: &MaterialEvalContext<'_>) -> Result<Value, String> {
664        let current = numeric_value(&inputs[0])?;
665        if let Some(current) = current {
666            self.value = Some(self.value.map_or(current, |previous| {
667                self.alpha * current + (1.0 - self.alpha) * previous
668            }));
669        }
670        Ok(self
671            .value
672            .map(|value| numeric(self.scalar, value))
673            .unwrap_or(Value::Missing(self.scalar)))
674    }
675}
676
677#[derive(Clone)]
678struct AtrEvaluator {
679    source: SourceId,
680    alpha: f64,
681    previous_close: Option<f64>,
682    value: Option<f64>,
683}
684impl MaterialEvaluator for AtrEvaluator {
685    clone_eval!(Self);
686    fn evaluate(
687        &mut self,
688        _: &[Value],
689        context: &MaterialEvalContext<'_>,
690    ) -> Result<Value, String> {
691        let bar = &context
692            .input
693            .completed_bars
694            .iter()
695            .find(|item| item.source == self.source)
696            .ok_or_else(|| "configured ATR source did not update".to_string())?
697            .bar;
698        let range = bar.high - bar.low;
699        let true_range = self.previous_close.map_or(range, |close| {
700            range
701                .max((bar.high - close).abs())
702                .max((bar.low - close).abs())
703        });
704        self.value = Some(self.value.map_or(true_range, |previous| {
705            self.alpha * true_range + (1.0 - self.alpha) * previous
706        }));
707        self.previous_close = Some(bar.close);
708        Ok(Value::Price(self.value.unwrap()))
709    }
710}
711
712#[derive(Clone)]
713struct CrossEvaluator {
714    above: bool,
715    previous: Option<(f64, f64)>,
716}
717impl MaterialEvaluator for CrossEvaluator {
718    clone_eval!(Self);
719    fn evaluate(&mut self, inputs: &[Value], _: &MaterialEvalContext<'_>) -> Result<Value, String> {
720        let (Some(left), Some(right)) = (numeric_value(&inputs[0])?, numeric_value(&inputs[1])?)
721        else {
722            return Ok(Value::Bool(false));
723        };
724        let crossed = self.previous.is_some_and(|(old_left, old_right)| {
725            if self.above {
726                old_left <= old_right && left > right
727            } else {
728                old_left >= old_right && left < right
729            }
730        });
731        self.previous = Some((left, right));
732        Ok(Value::Bool(crossed))
733    }
734}
735
736#[derive(Clone)]
737struct PositionEvaluator {
738    slot: String,
739    field: PositionField,
740}
741impl MaterialEvaluator for PositionEvaluator {
742    clone_eval!(Self);
743    fn evaluate(
744        &mut self,
745        _: &[Value],
746        context: &MaterialEvalContext<'_>,
747    ) -> Result<Value, String> {
748        let facts = context
749            .input
750            .trade_slots
751            .iter()
752            .find(|item| item.slot == self.slot)
753            .ok_or_else(|| "declared trade slot facts are missing".to_string())?;
754        Ok(trade_slot_value(&facts.state, self.field))
755    }
756}
757
758#[derive(Clone)]
759struct FeedbackEvaluator {
760    slot: String,
761    action: ConfiguredActionKind,
762    field: FeedbackField,
763}
764impl MaterialEvaluator for FeedbackEvaluator {
765    clone_eval!(Self);
766    fn evaluate(
767        &mut self,
768        _: &[Value],
769        context: &MaterialEvalContext<'_>,
770    ) -> Result<Value, String> {
771        Ok(Value::Bool(context.visible_feedback_matches(
772            &self.slot,
773            self.action,
774            self.field,
775        )))
776    }
777}
778
779pub(crate) fn bar_value(bar: &CompletedBar, field: BarField) -> Value {
780    match field {
781        BarField::Open => Value::Price(bar.open),
782        BarField::High => Value::Price(bar.high),
783        BarField::Low => Value::Price(bar.low),
784        BarField::Close => Value::Price(bar.close),
785        BarField::Volume => Value::Number(bar.volume),
786    }
787}
788
789pub(crate) fn trade_slot_value(state: &TradeSlotState, field: PositionField) -> Value {
790    match field {
791        PositionField::Exists => Value::Bool(!matches!(state, TradeSlotState::Vacant)),
792        PositionField::IsPending => Value::Bool(matches!(state, TradeSlotState::Pending { .. })),
793        PositionField::IsOpen => Value::Bool(matches!(state, TradeSlotState::Open { .. })),
794        PositionField::Side => match state {
795            TradeSlotState::Pending { side, .. } | TradeSlotState::Open { side, .. } => {
796                Value::Side(*side)
797            }
798            TradeSlotState::Vacant => Value::Missing(ScalarType::Side),
799        },
800        PositionField::EntryPrice => match state {
801            TradeSlotState::Open { entry_price, .. } => Value::Price(*entry_price),
802            _ => Value::Missing(ScalarType::Price),
803        },
804        PositionField::RemainingSize => match state {
805            TradeSlotState::Open { remaining_size, .. } => Value::Number(*remaining_size),
806            _ => Value::Missing(ScalarType::Number),
807        },
808        PositionField::Stoploss => match state {
809            TradeSlotState::Pending { stoploss, .. } | TradeSlotState::Open { stoploss, .. } => {
810                stoploss
811                    .map(Value::Price)
812                    .unwrap_or(Value::Missing(ScalarType::Price))
813            }
814            TradeSlotState::Vacant => Value::Missing(ScalarType::Price),
815        },
816    }
817}
818
819fn numeric(scalar: ScalarType, value: f64) -> Value {
820    if scalar == ScalarType::Price {
821        Value::Price(value)
822    } else {
823        Value::Number(value)
824    }
825}
826
827fn numeric_value(value: &Value) -> Result<Option<f64>, String> {
828    match value {
829        Value::Missing(_) => Ok(None),
830        Value::Integer(value) => Ok(Some(*value as f64)),
831        Value::Number(value) | Value::Price(value) if value.is_finite() => Ok(Some(*value)),
832        Value::Number(_) | Value::Price(_) => Err("numeric material input must be finite".into()),
833        _ => Err("material input must be numeric".into()),
834    }
835}
836
837pub(crate) fn material_error(id: &str, reason: String) -> EvaluationError {
838    EvaluationError::Material {
839        material: id.into(),
840        reason,
841    }
842}