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
196pub 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
225pub 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}