1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2
3use qs_core::{PositionRef, RawSignal, validate_raw_signal};
4
5use crate::expression::{
6 CompiledExpr, CompiledInputProvenance, EvalScope, ExprScope, collect_material_refs,
7 compile_expr,
8};
9use crate::material::FeedbackObservation;
10use crate::{
11 ActionTemplate, AssignmentConfig, CommandFact, CommandFeedback, CommandTerminalStatus,
12 CompileError, CompletedBarRequirement, ConfiguredActionKind, ConfiguredStrategyRequirements,
13 DecisionKind, DecisionTemplate, EvaluationError, Expr, FeedbackField, MaterialConfig,
14 MaterialEvalContext, MaterialEvaluator, MaterialLibrary, MaterialLookback, MaterialParams,
15 MaterialUpdateTrigger, NamedExpr, NamedInputRequirement, NoteKind, NoteTemplate, ScalarType,
16 SourceId, StrategyConfig, StrategyInput, TradeSlotState, TransitionConfig, Value, ValueType,
17};
18
19type TypedIndexMap = BTreeMap<String, (usize, ValueType)>;
20type LookbackMap = BTreeMap<SourceId, usize>;
21
22#[derive(Debug, Clone, PartialEq)]
23pub enum OutputScalar {
24 Integer(i64),
25 Number(f64),
26 Price(f64),
27}
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct NamedOutput {
31 pub name: String,
32 pub value: OutputScalar,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct RelatedTrade {
37 pub slot: String,
38 pub trade_id: String,
39}
40
41#[derive(Debug, Clone, PartialEq)]
42pub struct Decision {
43 pub kind: DecisionKind,
44 pub reason: String,
45 pub related_trade: Option<RelatedTrade>,
46 pub values: Vec<NamedOutput>,
47}
48
49#[derive(Debug, Clone, PartialEq)]
50pub struct Note {
51 pub kind: NoteKind,
52 pub reason: String,
53 pub related_trade: Option<RelatedTrade>,
54 pub values: Vec<NamedOutput>,
55}
56
57#[derive(Debug, Clone)]
58pub struct ConfiguredCommand {
59 pub command_id: String,
60 pub action_kind: ConfiguredActionKind,
61 pub trade_slot: String,
62 pub signal: RawSignal,
63}
64
65#[derive(Debug, Clone)]
66pub struct StrategyOutput {
67 pub decision: Option<Decision>,
68 pub commands: Vec<ConfiguredCommand>,
69 pub notes: Vec<Note>,
70}
71
72impl StrategyOutput {
73 fn empty() -> Self {
74 Self {
75 decision: None,
76 commands: Vec::new(),
77 notes: Vec::new(),
78 }
79 }
80}
81
82struct CompiledMaterial {
83 id: String,
84 inputs: Vec<CompiledExpr>,
85 input_provenance: Vec<CompiledInputProvenance>,
86 evaluator: Box<dyn MaterialEvaluator>,
87 output_type: ValueType,
88 lookbacks: LookbackMap,
89 update_trigger: MaterialUpdateTrigger,
90 clear_pulse_when_idle: bool,
91}
92
93#[derive(Clone)]
94struct CompiledNamedExpr {
95 name: String,
96 value: CompiledExpr,
97}
98
99#[derive(Clone)]
100struct CompiledDecision {
101 kind: DecisionKind,
102 reason: String,
103 trade_slot: Option<String>,
104 values: Vec<CompiledNamedExpr>,
105}
106
107#[derive(Clone)]
108struct CompiledNote {
109 kind: NoteKind,
110 reason: String,
111 trade_slot: Option<String>,
112 values: Vec<CompiledNamedExpr>,
113}
114
115#[derive(Clone)]
116struct CompiledAssignment {
117 variable: usize,
118 value_type: ValueType,
119 value: CompiledExpr,
120}
121
122#[derive(Clone)]
123enum CompiledAction {
124 Entry {
125 slot: String,
126 side: CompiledExpr,
127 order_type: qs_core::OrderType,
128 price: CompiledExpr,
129 risk: CompiledExpr,
130 stoploss: CompiledExpr,
131 targets: Vec<CompiledExpr>,
132 },
133 Close {
134 slot: String,
135 },
136 ClosePartial {
137 slot: String,
138 ratio: CompiledExpr,
139 },
140 MoveStoplossToEntry {
141 slot: String,
142 },
143 ModifyStoploss {
144 slot: String,
145 price: CompiledExpr,
146 },
147 CancelPending {
148 slot: String,
149 },
150}
151
152impl CompiledAction {
153 fn kind(&self) -> ConfiguredActionKind {
154 match self {
155 Self::Entry { .. } => ConfiguredActionKind::Entry,
156 Self::Close { .. } => ConfiguredActionKind::Close,
157 Self::ClosePartial { .. } => ConfiguredActionKind::ClosePartial,
158 Self::MoveStoplossToEntry { .. } => ConfiguredActionKind::MoveStoplossToEntry,
159 Self::ModifyStoploss { .. } => ConfiguredActionKind::ModifyStoploss,
160 Self::CancelPending { .. } => ConfiguredActionKind::CancelPending,
161 }
162 }
163}
164
165#[derive(Clone)]
166struct CompiledTransition {
167 priority: i32,
168 target: usize,
169 when: CompiledExpr,
170 assignments: Vec<CompiledAssignment>,
171 decision: Option<CompiledDecision>,
172 actions: Vec<CompiledAction>,
173 notes: Vec<CompiledNote>,
174}
175
176struct CompiledState {
177 id: String,
178 transitions: Vec<CompiledTransition>,
179}
180
181#[derive(Debug, Clone)]
182struct SlotBinding {
183 slot: String,
184 trade_id: String,
185}
186
187#[derive(Debug, Clone)]
188struct CommandBinding {
189 command_id: String,
190 slot: String,
191 action: ConfiguredActionKind,
192 terminal: Option<CommandTerminalStatus>,
193 facts: BTreeSet<CommandFact>,
194}
195
196#[derive(Debug, Clone)]
197struct IdentityState {
198 campaign_counter: u64,
199 leg_counter: u64,
200 command_counter: u64,
201 campaign_id: Option<String>,
202 slots: Vec<SlotBinding>,
203 commands: Vec<CommandBinding>,
204}
205
206struct ActionIdentityContext<'a> {
207 strategy_id: &'a str,
208 instance_id: &'a str,
209 symbol: &'a str,
210}
211
212struct RequirementCollector {
213 named_order: Vec<String>,
214 named: BTreeMap<String, ValueType>,
215 direct_lookbacks: LookbackMap,
216 needs_feedback: bool,
217}
218
219impl RequirementCollector {
220 fn new() -> Self {
221 Self {
222 named_order: Vec::new(),
223 named: BTreeMap::new(),
224 direct_lookbacks: BTreeMap::new(),
225 needs_feedback: false,
226 }
227 }
228
229 fn add_named(
230 &mut self,
231 name: &str,
232 value_type: ValueType,
233 path: &str,
234 ) -> Result<(), CompileError> {
235 crate::validate_id(name).map_err(|reason| CompileError::InvalidIdentifier {
236 path: path.into(),
237 reason,
238 })?;
239 if let Some(existing) = self.named.get(name) {
240 if *existing != value_type {
241 return Err(CompileError::TypeMismatch {
242 path: path.into(),
243 expected: *existing,
244 actual: value_type,
245 });
246 }
247 } else {
248 self.named_order.push(name.into());
249 self.named.insert(name.into(), value_type);
250 }
251 Ok(())
252 }
253
254 fn add_source(&mut self, source: &SourceId, lookback: usize) {
255 self.direct_lookbacks
256 .entry(source.clone())
257 .and_modify(|current| *current = (*current).max(lookback))
258 .or_insert(lookback);
259 }
260}
261
262pub struct ConfiguredStrategy {
263 strategy_id: String,
264 instance_id: String,
265 primary_symbol: String,
266 declared_sources: Vec<SourceId>,
267 source_set: BTreeSet<SourceId>,
268 materials: Vec<CompiledMaterial>,
269 material_values: Vec<Value>,
270 variables: Vec<Value>,
271 states: Vec<CompiledState>,
272 current_state: usize,
273 identity: IdentityState,
274 pending_feedback: Vec<FeedbackObservation>,
275 requirements: ConfiguredStrategyRequirements,
276 terminal: bool,
277}
278
279impl ConfiguredStrategy {
280 pub fn compile(
281 config: StrategyConfig,
282 library: &MaterialLibrary,
283 instance_id: impl Into<String>,
284 primary_symbol: impl Into<String>,
285 ) -> Result<Self, CompileError> {
286 validate_config_bounds(&config)?;
287 validate_id_at(&config.strategy_id, "strategy_id")?;
288 crate::validate_text(&config.title, crate::MAX_TEXT_BYTES).map_err(|reason| {
289 CompileError::InvalidConfig {
290 path: "title".into(),
291 reason,
292 }
293 })?;
294 let instance_id = instance_id.into();
295 let primary_symbol = primary_symbol.into();
296 validate_id_at(&instance_id, "instance_id")?;
297 validate_id_at(&primary_symbol, "primary_symbol")?;
298 if generated_id(&config.strategy_id, &instance_id, "campaign", u64::MAX).is_err() {
299 return Err(CompileError::InvalidIdCapacity);
300 }
301
302 let source_set = compile_sources(&config)?;
303 let trade_slots = compile_trade_slots(&config)?;
304 let mut collected = collect_requirements(&config, &source_set)?;
305 let (variable_map, variables) = compile_variables(&config)?;
306 let (materials, material_map, material_lookbacks) = compile_materials(
307 &config.materials,
308 library,
309 &variable_map,
310 &trade_slots,
311 &source_set,
312 )?;
313 if materials
314 .iter()
315 .any(|material| material.update_trigger == MaterialUpdateTrigger::FeedbackPulse)
316 {
317 collected.needs_feedback = true;
318 }
319 merge_lookbacks(&mut collected.direct_lookbacks, &material_lookbacks);
320 let states = compile_states(
321 &config,
322 &variable_map,
323 &material_map,
324 &trade_slots,
325 &source_set,
326 )?;
327 let current_state = states
328 .iter()
329 .position(|state| state.id == config.initial_state)
330 .ok_or_else(|| CompileError::InvalidStateTarget {
331 path: "initial_state".into(),
332 target: config.initial_state.clone(),
333 })?;
334 validate_reachable(&states, current_state)?;
335 let material_values = materials
336 .iter()
337 .map(|material| Value::Missing(material.output_type.scalar))
338 .collect();
339 let requirements = ConfiguredStrategyRequirements {
340 completed_bars: config
341 .sources
342 .iter()
343 .filter_map(|source| {
344 collected
345 .direct_lookbacks
346 .get(source)
347 .map(|lookback| CompletedBarRequirement {
348 source: source.clone(),
349 required_lookback: *lookback,
350 })
351 })
352 .collect(),
353 named_inputs: collected
354 .named_order
355 .iter()
356 .map(|name| NamedInputRequirement {
357 name: name.clone(),
358 value_type: collected.named[name],
359 })
360 .collect(),
361 trade_slots: config.trade_slots.clone(),
362 needs_command_feedback: collected.needs_feedback,
363 };
364 Ok(Self {
365 strategy_id: config.strategy_id,
366 instance_id,
367 primary_symbol,
368 declared_sources: config.sources,
369 source_set,
370 materials,
371 material_values,
372 variables,
373 states,
374 current_state,
375 identity: IdentityState {
376 campaign_counter: 0,
377 leg_counter: 0,
378 command_counter: 0,
379 campaign_id: None,
380 slots: Vec::new(),
381 commands: Vec::new(),
382 },
383 pending_feedback: Vec::new(),
384 requirements,
385 terminal: false,
386 })
387 }
388
389 pub fn state_id(&self) -> &str {
390 &self.states[self.current_state].id
391 }
392
393 pub fn declared_sources(&self) -> &[SourceId] {
394 &self.declared_sources
395 }
396
397 pub fn primary_symbol(&self) -> &str {
398 &self.primary_symbol
399 }
400
401 pub fn input_requirements(&self) -> &ConfiguredStrategyRequirements {
402 &self.requirements
403 }
404
405 pub fn trade_id_for_slot(&self, slot: &str) -> Option<&str> {
406 self.identity
407 .slots
408 .iter()
409 .find(|item| item.slot == slot)
410 .map(|item| item.trade_id.as_str())
411 }
412
413 pub fn evaluate(&mut self, input: &StrategyInput) -> Result<StrategyOutput, EvaluationError> {
414 if self.terminal {
415 return Err(EvaluationError::Terminal);
416 }
417 let result = self.evaluate_staged(input);
418 if result.is_err() {
419 self.terminal = true;
420 }
421 result
422 }
423
424 pub fn finalize_command_feedback(
426 &mut self,
427 feedback: &[CommandFeedback],
428 ) -> Result<(), EvaluationError> {
429 if self.terminal {
430 return Err(EvaluationError::Terminal);
431 }
432 let mut identity = self.identity.clone();
433 if let Err(error) = process_feedback(&mut identity, feedback) {
434 self.terminal = true;
435 return Err(error);
436 }
437 identity.commands.clear();
438 self.identity = identity;
439 self.pending_feedback.clear();
440 Ok(())
441 }
442
443 fn evaluate_staged(
444 &mut self,
445 input: &StrategyInput,
446 ) -> Result<StrategyOutput, EvaluationError> {
447 validate_input(input, &self.requirements, &self.source_set)?;
448 let mut pending = self.pending_feedback.clone();
449 let retained_observations = pending.clone();
450 let had_pending = !pending.is_empty();
451 let mut identity = self.identity.clone();
452 let prior_slots = identity.slots.clone();
453 let current_observations = process_feedback(&mut identity, &input.feedback)?;
454 let mut observations = pending.clone();
455 observations.extend(current_observations.iter().cloned());
456 if input.ready {
457 pending.clear();
458 } else {
459 append_pending_feedback(&mut pending, ¤t_observations)?;
460 }
461 let retaining_feedback = had_pending || !pending.is_empty();
462 let mut evaluators: Vec<_> = self
463 .materials
464 .iter()
465 .map(|material| material.evaluator.clone())
466 .collect();
467 let mut material_values = self.material_values.clone();
468 let mut material_updates = vec![false; self.materials.len()];
469 let updated_sources = input
470 .completed_bars
471 .iter()
472 .map(|item| item.source.clone())
473 .collect::<BTreeSet<_>>();
474 for index in 0..self.materials.len() {
475 let input_updates = self.materials[index]
476 .input_provenance
477 .iter()
478 .map(|provenance| {
479 provenance
480 .material_indexes
481 .iter()
482 .all(|dependency| material_updates[*dependency])
483 && provenance
484 .sources
485 .iter()
486 .all(|source| updated_sources.contains(source))
487 && provenance.named_inputs.iter().all(|name| {
488 input
489 .values
490 .iter()
491 .find(|item| item.name == *name)
492 .is_some_and(|item| item.updated)
493 })
494 && !provenance.dynamic
495 })
496 .collect::<Vec<_>>();
497 let triggered = match &self.materials[index].update_trigger {
498 MaterialUpdateTrigger::EveryInput => true,
499 MaterialUpdateTrigger::Source(source) => updated_sources.contains(source),
500 MaterialUpdateTrigger::FeedbackPulse => {
501 !input.feedback.is_empty() || !retaining_feedback
502 }
503 MaterialUpdateTrigger::AllInputs => {
504 !input_updates.is_empty() && input_updates.iter().all(|updated| *updated)
505 }
506 };
507 if !triggered {
508 if self.materials[index].clear_pulse_when_idle {
509 material_values[index] = Value::Bool(false);
510 }
511 continue;
512 }
513 let scope = EvalScope {
514 variables: &self.variables,
515 materials: &material_values,
516 input,
517 feedback: &observations,
518 };
519 let mut values = Vec::with_capacity(self.materials[index].inputs.len());
520 for expression in &self.materials[index].inputs {
521 values.push(
522 expression.eval(&scope, &format!("materials[{}]", self.materials[index].id))?,
523 );
524 }
525 let context = MaterialEvalContext {
526 input,
527 input_updates: &input_updates,
528 feedback: ¤t_observations,
529 retained_feedback: &retained_observations,
530 };
531 let value = evaluators[index]
532 .evaluate(&values, &context)
533 .map_err(|reason| {
534 crate::material::material_error(&self.materials[index].id, reason)
535 })?;
536 ensure_runtime_type(
537 &value,
538 self.materials[index].output_type,
539 &format!("materials[{}]", self.materials[index].id),
540 )?;
541 material_values[index] = value;
542 material_updates[index] = true;
543 }
544
545 if !input.ready {
546 self.commit_all(evaluators, material_values, identity, pending);
547 return Ok(StrategyOutput::empty());
548 }
549
550 let scope = EvalScope {
551 variables: &self.variables,
552 materials: &material_values,
553 input,
554 feedback: &observations,
555 };
556 let mut selected = None;
557 for transition in &self.states[self.current_state].transitions {
558 match transition.when.eval(&scope, "transition.when")? {
559 Value::Bool(true) => {
560 selected = Some(transition.clone());
561 break;
562 }
563 Value::Bool(false) => {}
564 value => {
565 return Err(EvaluationError::TypeMismatch {
566 path: "transition.when".into(),
567 expected: ScalarType::Bool,
568 actual: Some(value.scalar_type()),
569 });
570 }
571 }
572 }
573 let Some(transition) = selected else {
574 self.commit_all(evaluators, material_values, identity, pending);
575 return Ok(StrategyOutput::empty());
576 };
577 let mut variables = self.variables.clone();
578 let assignment_scope = EvalScope {
579 variables: &self.variables,
580 materials: &material_values,
581 input,
582 feedback: &observations,
583 };
584 for assignment in &transition.assignments {
585 let value = assignment
586 .value
587 .eval(&assignment_scope, "transition.assignment")?;
588 ensure_runtime_type(&value, assignment.value_type, "transition.assignment")?;
589 variables[assignment.variable] = value;
590 }
591 let output_scope = EvalScope {
592 variables: &variables,
593 materials: &material_values,
594 input,
595 feedback: &observations,
596 };
597 let action_ids = ActionIdentityContext {
598 strategy_id: &self.strategy_id,
599 instance_id: &self.instance_id,
600 symbol: &self.primary_symbol,
601 };
602 let mut commands = Vec::with_capacity(transition.actions.len());
603 for (index, action) in transition.actions.iter().enumerate() {
604 commands.push(lower_action(
605 action,
606 &output_scope,
607 input,
608 &action_ids,
609 &mut identity,
610 index,
611 )?);
612 }
613 let decision = transition
614 .decision
615 .as_ref()
616 .map(|template| evaluate_decision(template, &output_scope, &identity, &prior_slots))
617 .transpose()?;
618 let notes = transition
619 .notes
620 .iter()
621 .map(|template| evaluate_note(template, &output_scope, &identity, &prior_slots))
622 .collect::<Result<Vec<_>, _>>()?;
623 for (material, evaluator) in self.materials.iter_mut().zip(evaluators) {
624 material.evaluator = evaluator;
625 }
626 self.material_values = material_values;
627 self.variables = variables;
628 self.current_state = transition.target;
629 self.identity = identity;
630 self.pending_feedback = pending;
631 Ok(StrategyOutput {
632 decision,
633 commands,
634 notes,
635 })
636 }
637
638 fn commit_materials(
639 &mut self,
640 evaluators: Vec<Box<dyn MaterialEvaluator>>,
641 values: Vec<Value>,
642 pending: Vec<FeedbackObservation>,
643 ) {
644 for (material, evaluator) in self.materials.iter_mut().zip(evaluators) {
645 material.evaluator = evaluator;
646 }
647 self.material_values = values;
648 self.pending_feedback = pending;
649 }
650
651 fn commit_all(
652 &mut self,
653 evaluators: Vec<Box<dyn MaterialEvaluator>>,
654 values: Vec<Value>,
655 identity: IdentityState,
656 pending: Vec<FeedbackObservation>,
657 ) {
658 self.commit_materials(evaluators, values, pending);
659 self.identity = identity;
660 }
661}
662
663fn compile_sources(config: &StrategyConfig) -> Result<BTreeSet<SourceId>, CompileError> {
664 let mut sources = BTreeSet::new();
665 for source in &config.sources {
666 if !sources.insert(source.clone()) {
667 return Err(CompileError::DuplicateIdentifier {
668 path: "sources".into(),
669 id: source.to_string(),
670 });
671 }
672 }
673 Ok(sources)
674}
675
676fn compile_trade_slots(config: &StrategyConfig) -> Result<BTreeSet<String>, CompileError> {
677 let mut slots = BTreeSet::new();
678 for (index, slot) in config.trade_slots.iter().enumerate() {
679 validate_id_at(slot, &format!("trade_slots[{index}]"))?;
680 if !slots.insert(slot.clone()) {
681 return Err(CompileError::DuplicateIdentifier {
682 path: "trade_slots".into(),
683 id: slot.clone(),
684 });
685 }
686 }
687 Ok(slots)
688}
689
690fn collect_requirements(
691 config: &StrategyConfig,
692 sources: &BTreeSet<SourceId>,
693) -> Result<RequirementCollector, CompileError> {
694 let mut collector = RequirementCollector::new();
695 for (index, material) in config.materials.iter().enumerate() {
696 for (input_index, input) in material.inputs.iter().enumerate() {
697 collect_expr_requirements(
698 input,
699 &format!("materials[{index}].inputs[{input_index}]"),
700 sources,
701 &mut collector,
702 )?;
703 }
704 match &material.params {
705 MaterialParams::BarField { source, .. } => {
706 require_source(
707 sources,
708 source,
709 &format!("materials[{index}].params.source"),
710 )?;
711 collector.add_source(source, 1);
712 }
713 MaterialParams::Atr { source, period } => {
714 require_source(
715 sources,
716 source,
717 &format!("materials[{index}].params.source"),
718 )?;
719 collector.add_source(source, usize::from(*period) + 1);
720 }
721 MaterialParams::Feedback { .. } => collector.needs_feedback = true,
722 _ => {}
723 }
724 }
725 for (state_index, state) in config.states.iter().enumerate() {
726 for (transition_index, transition) in state.transitions.iter().enumerate() {
727 let base = format!("states[{state_index}].transitions[{transition_index}]");
728 collect_expr_requirements(
729 &transition.when,
730 &format!("{base}.when"),
731 sources,
732 &mut collector,
733 )?;
734 for (index, assignment) in transition.assignments.iter().enumerate() {
735 collect_expr_requirements(
736 &assignment.value,
737 &format!("{base}.assignments[{index}]"),
738 sources,
739 &mut collector,
740 )?;
741 }
742 if let Some(decision) = &transition.decision {
743 for (index, value) in decision.values.iter().enumerate() {
744 collect_expr_requirements(
745 &value.value,
746 &format!("{base}.decision.values[{index}]"),
747 sources,
748 &mut collector,
749 )?;
750 }
751 }
752 for (index, note) in transition.notes.iter().enumerate() {
753 for (value_index, value) in note.values.iter().enumerate() {
754 collect_expr_requirements(
755 &value.value,
756 &format!("{base}.notes[{index}].values[{value_index}]"),
757 sources,
758 &mut collector,
759 )?;
760 }
761 }
762 for (index, action) in transition.actions.iter().enumerate() {
763 for expression in action_expressions(action) {
764 collect_expr_requirements(
765 expression,
766 &format!("{base}.actions[{index}]"),
767 sources,
768 &mut collector,
769 )?;
770 }
771 }
772 }
773 }
774 Ok(collector)
775}
776
777fn collect_expr_requirements(
778 expression: &Expr,
779 path: &str,
780 sources: &BTreeSet<SourceId>,
781 collector: &mut RequirementCollector,
782) -> Result<(), CompileError> {
783 match expression {
784 Expr::Input { field, value_type } => collector.add_named(field, *value_type, path)?,
785 Expr::Bar { source, .. } => {
786 require_source(sources, source, path)?;
787 collector.add_source(source, 1);
788 }
789 Expr::Feedback { .. } => collector.needs_feedback = true,
790 Expr::Eq { left, right }
791 | Expr::Ne { left, right }
792 | Expr::Lt { left, right }
793 | Expr::Le { left, right }
794 | Expr::Gt { left, right }
795 | Expr::Ge { left, right }
796 | Expr::Add { left, right }
797 | Expr::Sub { left, right }
798 | Expr::Mul { left, right }
799 | Expr::Div { left, right }
800 | Expr::Min { left, right }
801 | Expr::Max { left, right } => {
802 collect_expr_requirements(left, path, sources, collector)?;
803 collect_expr_requirements(right, path, sources, collector)?;
804 }
805 Expr::All { items } | Expr::Any { items } => {
806 for item in items {
807 collect_expr_requirements(item, path, sources, collector)?;
808 }
809 }
810 Expr::Not { value }
811 | Expr::Abs { value }
812 | Expr::IsPresent { value }
813 | Expr::IsMissing { value } => {
814 collect_expr_requirements(value, path, sources, collector)?;
815 }
816 _ => {}
817 }
818 Ok(())
819}
820
821fn action_expressions(action: &ActionTemplate) -> Vec<&Expr> {
822 match action {
823 ActionTemplate::Entry {
824 side,
825 price,
826 risk,
827 stoploss,
828 targets,
829 ..
830 } => {
831 let mut values = vec![side, price, risk, stoploss];
832 values.extend(targets);
833 values
834 }
835 ActionTemplate::ClosePartial { ratio, .. } => vec![ratio],
836 ActionTemplate::ModifyStoploss { price, .. } => vec![price],
837 _ => Vec::new(),
838 }
839}
840
841fn compile_variables(config: &StrategyConfig) -> Result<(TypedIndexMap, Vec<Value>), CompileError> {
842 let mut map = BTreeMap::new();
843 let mut values = Vec::new();
844 for (index, variable) in config.variables.iter().enumerate() {
845 validate_id_at(&variable.id, &format!("variables[{index}].id"))?;
846 if map.contains_key(&variable.id) {
847 return Err(CompileError::DuplicateIdentifier {
848 path: "variables".into(),
849 id: variable.id.clone(),
850 });
851 }
852 require_type(
853 variable.initial.value_type(),
854 variable.value_type,
855 &format!("variables[{index}].initial"),
856 )?;
857 values.push(variable.initial.to_value()?);
858 map.insert(variable.id.clone(), (index, variable.value_type));
859 }
860 Ok((map, values))
861}
862
863fn compile_materials(
864 configs: &[MaterialConfig],
865 library: &MaterialLibrary,
866 variables: &TypedIndexMap,
867 trade_slots: &BTreeSet<String>,
868 sources: &BTreeSet<SourceId>,
869) -> Result<(Vec<CompiledMaterial>, TypedIndexMap, LookbackMap), CompileError> {
870 let mut ids = BTreeMap::new();
871 for (index, material) in configs.iter().enumerate() {
872 validate_id_at(&material.id, &format!("materials[{index}].id"))?;
873 validate_id_at(&material.key, &format!("materials[{index}].key"))?;
874 if ids.insert(material.id.clone(), index).is_some() {
875 return Err(CompileError::DuplicateIdentifier {
876 path: "materials".into(),
877 id: material.id.clone(),
878 });
879 }
880 if library.factory(&material.key).is_none() {
881 return Err(CompileError::UnknownMaterialKey {
882 path: format!("materials[{index}].key"),
883 key: material.key.clone(),
884 });
885 }
886 }
887 let mut dependents = vec![Vec::new(); configs.len()];
888 let mut indegree = vec![0usize; configs.len()];
889 for (index, material) in configs.iter().enumerate() {
890 let mut references = Vec::new();
891 for (input_index, input) in material.inputs.iter().enumerate() {
892 collect_material_refs(
893 input,
894 &mut references,
895 &format!("materials[{index}].inputs[{input_index}]"),
896 )?;
897 }
898 let mut unique = BTreeSet::new();
899 for reference in references {
900 let dependency =
901 *ids.get(&reference)
902 .ok_or_else(|| CompileError::UnknownReference {
903 path: format!("materials[{index}].inputs"),
904 reference,
905 })?;
906 if unique.insert(dependency) {
907 dependents[dependency].push(index);
908 indegree[index] += 1;
909 }
910 }
911 }
912 let mut ready = indegree
913 .iter()
914 .enumerate()
915 .filter_map(|(index, degree)| (*degree == 0).then_some(index))
916 .collect::<BTreeSet<_>>();
917 let mut order = Vec::new();
918 while let Some(index) = ready.pop_first() {
919 order.push(index);
920 for dependent in &dependents[index] {
921 indegree[*dependent] -= 1;
922 if indegree[*dependent] == 0 {
923 ready.insert(*dependent);
924 }
925 }
926 }
927 if order.len() != configs.len() {
928 return Err(CompileError::DependencyCycle {
929 materials: configs
930 .iter()
931 .enumerate()
932 .filter(|(index, _)| indegree[*index] > 0)
933 .map(|(_, material)| material.id.clone())
934 .collect(),
935 });
936 }
937
938 let mut compiled: Vec<CompiledMaterial> = Vec::new();
939 let mut map = BTreeMap::new();
940 let mut aggregate = BTreeMap::new();
941 for original in order {
942 let material = &configs[original];
943 validate_material_params(&material.params, trade_slots, sources, original)?;
944 let scope = ExprScope {
945 variables,
946 materials: &map,
947 trade_slots,
948 sources,
949 };
950 let mut inputs = Vec::new();
951 let mut input_types = Vec::new();
952 for (index, input) in material.inputs.iter().enumerate() {
953 let (compiled_input, value_type) = compile_expr(
954 input,
955 &scope,
956 &format!("materials[{original}].inputs[{index}]"),
957 )?;
958 inputs.push(compiled_input);
959 input_types.push(value_type);
960 }
961 let provenance = inputs
962 .iter()
963 .map(CompiledExpr::provenance)
964 .collect::<Vec<_>>();
965 if matches!(
966 material.key.as_str(),
967 crate::MATERIAL_CROSS_ABOVE | crate::MATERIAL_CROSS_BELOW
968 ) && inputs
969 .iter()
970 .any(|input| input.direct_material_index().is_none())
971 {
972 return Err(CompileError::InvalidConfig {
973 path: format!("materials[{original}].inputs"),
974 reason: "crossing inputs must be direct material references".into(),
975 });
976 }
977 if library.is_custom(&material.key) && !matches!(&material.params, MaterialParams::None) {
978 return Err(CompileError::InvalidConfig {
979 path: format!("materials[{original}].params"),
980 reason: "custom material factories are parameterless".into(),
981 });
982 }
983 let factory = library.factory(&material.key).unwrap();
984 let trigger = factory
985 .update_trigger(&material.params, &input_types)
986 .map_err(|reason| CompileError::MaterialFactory {
987 path: format!("materials[{original}].update_trigger"),
988 reason,
989 })?;
990 validate_trigger(&trigger, &provenance, original)?;
991 let build = factory
992 .build(&material.params, &input_types)
993 .map_err(|reason| CompileError::MaterialFactory {
994 path: format!("materials[{original}]"),
995 reason,
996 })?;
997 check_bound(
998 &format!("materials[{original}].max_state_bytes"),
999 build.max_state_bytes,
1000 crate::MAX_MATERIAL_STATE_BYTES,
1001 )?;
1002 let mut upstream = LookbackMap::new();
1003 for item in &provenance {
1004 for source in &item.sources {
1005 merge_lookback(&mut upstream, source.clone(), 1);
1006 }
1007 for dependency in &item.material_indexes {
1008 merge_lookbacks(&mut upstream, &compiled[*dependency].lookbacks);
1009 }
1010 }
1011 let lookbacks = apply_lookback_contract(build.lookback, upstream, original, sources)?;
1012 merge_lookbacks(&mut aggregate, &lookbacks);
1013 let index = compiled.len();
1014 map.insert(material.id.clone(), (index, build.output_type));
1015 compiled.push(CompiledMaterial {
1016 id: material.id.clone(),
1017 inputs,
1018 input_provenance: provenance,
1019 evaluator: build.evaluator,
1020 output_type: build.output_type,
1021 lookbacks,
1022 update_trigger: trigger,
1023 clear_pulse_when_idle: matches!(
1024 material.key.as_str(),
1025 crate::MATERIAL_CROSS_ABOVE | crate::MATERIAL_CROSS_BELOW
1026 ),
1027 });
1028 }
1029 Ok((compiled, map, aggregate))
1030}
1031
1032fn validate_trigger(
1033 trigger: &MaterialUpdateTrigger,
1034 provenance: &[CompiledInputProvenance],
1035 index: usize,
1036) -> Result<(), CompileError> {
1037 if matches!(trigger, MaterialUpdateTrigger::AllInputs) {
1038 if provenance.is_empty() {
1039 return Err(CompileError::InvalidConfig {
1040 path: format!("materials[{index}].update_trigger"),
1041 reason: "AllInputs requires at least one input".into(),
1042 });
1043 }
1044 if provenance.iter().any(|item| item.dynamic) {
1045 return Err(CompileError::InvalidConfig {
1046 path: format!("materials[{index}].update_trigger"),
1047 reason: "AllInputs cannot use dynamic position, feedback, time, readiness, or variable dependencies".into(),
1048 });
1049 }
1050 }
1051 Ok(())
1052}
1053
1054fn apply_lookback_contract(
1055 contract: MaterialLookback,
1056 mut upstream: LookbackMap,
1057 index: usize,
1058 sources: &BTreeSet<SourceId>,
1059) -> Result<LookbackMap, CompileError> {
1060 match contract {
1061 MaterialLookback::None => {
1062 if !upstream.is_empty() {
1063 return Err(CompileError::InvalidConfig {
1064 path: format!("materials[{index}].lookback"),
1065 reason: "material with source dependencies must declare inherited or explicit lookback".into(),
1066 });
1067 }
1068 }
1069 MaterialLookback::Sources(requirements) => {
1070 for requirement in requirements {
1071 require_source(
1072 sources,
1073 &requirement.source,
1074 &format!("materials[{index}].lookback"),
1075 )?;
1076 check_bound(
1077 &format!("materials[{index}].lookback"),
1078 requirement.required_lookback,
1079 crate::MAX_MATERIAL_LOOKBACK,
1080 )?;
1081 merge_lookback(
1082 &mut upstream,
1083 requirement.source,
1084 requirement.required_lookback,
1085 );
1086 }
1087 }
1088 MaterialLookback::InheritInputs { minimum } => {
1089 check_bound(
1090 &format!("materials[{index}].lookback"),
1091 minimum,
1092 crate::MAX_MATERIAL_LOOKBACK,
1093 )?;
1094 if upstream.is_empty() && minimum > 0 {
1095 return Err(CompileError::InvalidConfig {
1096 path: format!("materials[{index}].lookback"),
1097 reason:
1098 "positive inherited lookback requires a completed-bar source dependency"
1099 .into(),
1100 });
1101 }
1102 for value in upstream.values_mut() {
1103 *value = (*value).max(minimum);
1104 }
1105 }
1106 }
1107 Ok(upstream)
1108}
1109
1110fn validate_material_params(
1111 params: &MaterialParams,
1112 trade_slots: &BTreeSet<String>,
1113 sources: &BTreeSet<SourceId>,
1114 index: usize,
1115) -> Result<(), CompileError> {
1116 match params {
1117 MaterialParams::BarField { source, .. } | MaterialParams::Atr { source, .. } => {
1118 require_source(
1119 sources,
1120 source,
1121 &format!("materials[{index}].params.source"),
1122 )?;
1123 }
1124 MaterialParams::Position { slot } | MaterialParams::Feedback { slot, .. } => {
1125 require_trade_slot(
1126 trade_slots,
1127 slot,
1128 &format!("materials[{index}].params.slot"),
1129 )?;
1130 }
1131 _ => {}
1132 }
1133 Ok(())
1134}
1135
1136fn compile_states(
1137 config: &StrategyConfig,
1138 variables: &TypedIndexMap,
1139 materials: &TypedIndexMap,
1140 trade_slots: &BTreeSet<String>,
1141 sources: &BTreeSet<SourceId>,
1142) -> Result<Vec<CompiledState>, CompileError> {
1143 let mut state_ids = BTreeMap::new();
1144 for (index, state) in config.states.iter().enumerate() {
1145 validate_id_at(&state.id, &format!("states[{index}].id"))?;
1146 if state_ids.insert(state.id.clone(), index).is_some() {
1147 return Err(CompileError::DuplicateIdentifier {
1148 path: "states".into(),
1149 id: state.id.clone(),
1150 });
1151 }
1152 }
1153 let scope = ExprScope {
1154 variables,
1155 materials,
1156 trade_slots,
1157 sources,
1158 };
1159 let mut states = Vec::new();
1160 for (state_index, state) in config.states.iter().enumerate() {
1161 let mut priorities = BTreeSet::new();
1162 let mut transitions = Vec::new();
1163 for (transition_index, transition) in state.transitions.iter().enumerate() {
1164 if !priorities.insert(transition.priority) {
1165 return Err(CompileError::PriorityConflict {
1166 state: state.id.clone(),
1167 priority: transition.priority,
1168 });
1169 }
1170 let target = *state_ids.get(&transition.target).ok_or_else(|| {
1171 CompileError::InvalidStateTarget {
1172 path: format!("states[{state_index}].transitions[{transition_index}].target"),
1173 target: transition.target.clone(),
1174 }
1175 })?;
1176 if target == state_index {
1177 return Err(CompileError::InvalidStateTarget {
1178 path: format!("states[{state_index}].transitions[{transition_index}].target"),
1179 target: transition.target.clone(),
1180 });
1181 }
1182 transitions.push(compile_transition(
1183 transition,
1184 target,
1185 &scope,
1186 variables,
1187 &format!("states[{state_index}].transitions[{transition_index}]"),
1188 )?);
1189 }
1190 transitions.sort_by_key(|item| std::cmp::Reverse(item.priority));
1191 states.push(CompiledState {
1192 id: state.id.clone(),
1193 transitions,
1194 });
1195 }
1196 Ok(states)
1197}
1198
1199fn compile_transition(
1200 config: &TransitionConfig,
1201 target: usize,
1202 scope: &ExprScope<'_>,
1203 variables: &TypedIndexMap,
1204 path: &str,
1205) -> Result<CompiledTransition, CompileError> {
1206 if !config.actions.is_empty() && config.decision.is_none() {
1207 return Err(CompileError::InvalidConfig {
1208 path: format!("{path}.decision"),
1209 reason: "a transition with actions requires a decision".into(),
1210 });
1211 }
1212 let (when, when_type) = compile_expr(&config.when, scope, &format!("{path}.when"))?;
1213 require_type(
1214 when_type,
1215 ValueType::required(ScalarType::Bool),
1216 &format!("{path}.when"),
1217 )?;
1218 let assignments = config
1219 .assignments
1220 .iter()
1221 .enumerate()
1222 .map(|(index, item)| {
1223 compile_assignment(
1224 item,
1225 scope,
1226 variables,
1227 &format!("{path}.assignments[{index}]"),
1228 )
1229 })
1230 .collect::<Result<_, _>>()?;
1231 let decision = config
1232 .decision
1233 .as_ref()
1234 .map(|item| compile_decision(item, scope, &format!("{path}.decision")))
1235 .transpose()?;
1236 let actions = config
1237 .actions
1238 .iter()
1239 .enumerate()
1240 .map(|(index, item)| compile_action(item, scope, &format!("{path}.actions[{index}]")))
1241 .collect::<Result<_, _>>()?;
1242 let notes = config
1243 .notes
1244 .iter()
1245 .enumerate()
1246 .map(|(index, item)| compile_note(item, scope, &format!("{path}.notes[{index}]")))
1247 .collect::<Result<_, _>>()?;
1248 Ok(CompiledTransition {
1249 priority: config.priority,
1250 target,
1251 when,
1252 assignments,
1253 decision,
1254 actions,
1255 notes,
1256 })
1257}
1258
1259fn compile_assignment(
1260 config: &AssignmentConfig,
1261 scope: &ExprScope<'_>,
1262 variables: &TypedIndexMap,
1263 path: &str,
1264) -> Result<CompiledAssignment, CompileError> {
1265 let (index, expected) =
1266 variables
1267 .get(&config.variable)
1268 .ok_or_else(|| CompileError::UnknownReference {
1269 path: path.into(),
1270 reference: config.variable.clone(),
1271 })?;
1272 let (value, actual) = compile_expr(&config.value, scope, path)?;
1273 require_type(actual, *expected, path)?;
1274 Ok(CompiledAssignment {
1275 variable: *index,
1276 value_type: *expected,
1277 value,
1278 })
1279}
1280
1281fn compile_named(
1282 values: &[NamedExpr],
1283 scope: &ExprScope<'_>,
1284 path: &str,
1285) -> Result<Vec<CompiledNamedExpr>, CompileError> {
1286 check_bound(path, values.len(), crate::MAX_NAMED_VALUES)?;
1287 let mut names = BTreeSet::new();
1288 values
1289 .iter()
1290 .enumerate()
1291 .map(|(index, item)| {
1292 validate_id_at(&item.name, &format!("{path}[{index}].name"))?;
1293 if !names.insert(item.name.clone()) {
1294 return Err(CompileError::DuplicateIdentifier {
1295 path: path.into(),
1296 id: item.name.clone(),
1297 });
1298 }
1299 let (value, value_type) =
1300 compile_expr(&item.value, scope, &format!("{path}[{index}].value"))?;
1301 if value_type.optional
1302 || !matches!(
1303 value_type.scalar,
1304 ScalarType::Integer | ScalarType::Number | ScalarType::Price
1305 )
1306 {
1307 return Err(CompileError::InvalidConfig {
1308 path: format!("{path}[{index}].value"),
1309 reason: "output values must be required Integer, Number, or Price".into(),
1310 });
1311 }
1312 Ok(CompiledNamedExpr {
1313 name: item.name.clone(),
1314 value,
1315 })
1316 })
1317 .collect()
1318}
1319
1320fn compile_decision(
1321 template: &DecisionTemplate,
1322 scope: &ExprScope<'_>,
1323 path: &str,
1324) -> Result<CompiledDecision, CompileError> {
1325 validate_related_slot(template.trade_slot.as_deref(), scope, path)?;
1326 validate_reason(&template.reason, path)?;
1327 Ok(CompiledDecision {
1328 kind: template.kind,
1329 reason: template.reason.clone(),
1330 trade_slot: template.trade_slot.clone(),
1331 values: compile_named(&template.values, scope, &format!("{path}.values"))?,
1332 })
1333}
1334
1335fn compile_note(
1336 template: &NoteTemplate,
1337 scope: &ExprScope<'_>,
1338 path: &str,
1339) -> Result<CompiledNote, CompileError> {
1340 validate_related_slot(template.trade_slot.as_deref(), scope, path)?;
1341 validate_reason(&template.reason, path)?;
1342 Ok(CompiledNote {
1343 kind: template.kind,
1344 reason: template.reason.clone(),
1345 trade_slot: template.trade_slot.clone(),
1346 values: compile_named(&template.values, scope, &format!("{path}.values"))?,
1347 })
1348}
1349
1350fn validate_related_slot(
1351 slot: Option<&str>,
1352 scope: &ExprScope<'_>,
1353 path: &str,
1354) -> Result<(), CompileError> {
1355 if let Some(slot) = slot {
1356 require_trade_slot(scope.trade_slots, slot, &format!("{path}.trade_slot"))?;
1357 }
1358 Ok(())
1359}
1360
1361fn validate_reason(reason: &str, path: &str) -> Result<(), CompileError> {
1362 crate::validate_text(reason, crate::MAX_TEXT_BYTES).map_err(|reason| {
1363 CompileError::InvalidConfig {
1364 path: format!("{path}.reason"),
1365 reason,
1366 }
1367 })
1368}
1369
1370fn compile_action(
1371 action: &ActionTemplate,
1372 scope: &ExprScope<'_>,
1373 path: &str,
1374) -> Result<CompiledAction, CompileError> {
1375 let required = |expression: &Expr, scalar, suffix: &str| {
1376 let (value, actual) = compile_expr(expression, scope, &format!("{path}.{suffix}"))?;
1377 require_type(
1378 actual,
1379 ValueType::required(scalar),
1380 &format!("{path}.{suffix}"),
1381 )?;
1382 Ok(value)
1383 };
1384 let optional = |expression: &Expr, scalar, suffix: &str| {
1385 let (value, actual) = compile_expr(expression, scope, &format!("{path}.{suffix}"))?;
1386 if actual.scalar != scalar {
1387 return Err(CompileError::TypeMismatch {
1388 path: format!("{path}.{suffix}"),
1389 expected: ValueType::optional(scalar),
1390 actual,
1391 });
1392 }
1393 Ok(value)
1394 };
1395 Ok(match action {
1396 ActionTemplate::Entry {
1397 slot,
1398 side,
1399 order_type,
1400 price,
1401 risk,
1402 stoploss,
1403 targets,
1404 } => {
1405 require_trade_slot(scope.trade_slots, slot, &format!("{path}.slot"))?;
1406 let targets = targets
1407 .iter()
1408 .enumerate()
1409 .map(|(index, target)| {
1410 required(target, ScalarType::Price, &format!("targets[{index}]"))
1411 })
1412 .collect::<Result<_, _>>()?;
1413 CompiledAction::Entry {
1414 slot: slot.clone(),
1415 side: required(side, ScalarType::Side, "side")?,
1416 order_type: *order_type,
1417 price: optional(price, ScalarType::Price, "price")?,
1418 risk: required(risk, ScalarType::Number, "risk")?,
1419 stoploss: optional(stoploss, ScalarType::Price, "stoploss")?,
1420 targets,
1421 }
1422 }
1423 ActionTemplate::Close { slot } => {
1424 require_trade_slot(scope.trade_slots, slot, &format!("{path}.slot"))?;
1425 CompiledAction::Close { slot: slot.clone() }
1426 }
1427 ActionTemplate::ClosePartial { slot, ratio } => {
1428 require_trade_slot(scope.trade_slots, slot, &format!("{path}.slot"))?;
1429 CompiledAction::ClosePartial {
1430 slot: slot.clone(),
1431 ratio: required(ratio, ScalarType::Number, "ratio")?,
1432 }
1433 }
1434 ActionTemplate::MoveStoplossToEntry { slot } => {
1435 require_trade_slot(scope.trade_slots, slot, &format!("{path}.slot"))?;
1436 CompiledAction::MoveStoplossToEntry { slot: slot.clone() }
1437 }
1438 ActionTemplate::ModifyStoploss { slot, price } => {
1439 require_trade_slot(scope.trade_slots, slot, &format!("{path}.slot"))?;
1440 CompiledAction::ModifyStoploss {
1441 slot: slot.clone(),
1442 price: required(price, ScalarType::Price, "price")?,
1443 }
1444 }
1445 ActionTemplate::CancelPending { slot } => {
1446 require_trade_slot(scope.trade_slots, slot, &format!("{path}.slot"))?;
1447 CompiledAction::CancelPending { slot: slot.clone() }
1448 }
1449 })
1450}
1451
1452fn lower_action(
1453 action: &CompiledAction,
1454 scope: &EvalScope<'_>,
1455 input: &StrategyInput,
1456 ids: &ActionIdentityContext<'_>,
1457 identity: &mut IdentityState,
1458 index: usize,
1459) -> Result<ConfiguredCommand, EvaluationError> {
1460 let kind = action.kind();
1461 let (signal, slot) = match action {
1462 CompiledAction::Entry {
1463 slot,
1464 side,
1465 order_type,
1466 price,
1467 risk,
1468 stoploss,
1469 targets,
1470 } => {
1471 if identity.slots.iter().any(|item| item.slot == *slot) {
1472 return Err(EvaluationError::InvalidAction {
1473 path: format!("actions[{index}].slot"),
1474 reason: "trade slot is already reserved".into(),
1475 });
1476 }
1477 if identity.campaign_id.is_none() {
1478 identity.campaign_counter = checked_next(identity.campaign_counter, "campaign")?;
1479 identity.leg_counter = 0;
1480 identity.campaign_id = Some(generated_id(
1481 ids.strategy_id,
1482 ids.instance_id,
1483 "campaign",
1484 identity.campaign_counter,
1485 )?);
1486 }
1487 identity.leg_counter = checked_next(identity.leg_counter, "leg")?;
1488 let trade_id = generated_scoped_id(
1489 ids.strategy_id,
1490 ids.instance_id,
1491 identity.campaign_counter,
1492 "trade",
1493 identity.leg_counter,
1494 )?;
1495 identity.slots.push(SlotBinding {
1496 slot: slot.clone(),
1497 trade_id: trade_id.clone(),
1498 });
1499 let side = required_side(side.eval(scope, "action.entry.side")?, "action.entry.side")?;
1500 let price = optional_price(
1501 price.eval(scope, "action.entry.price")?,
1502 "action.entry.price",
1503 )?;
1504 let risk =
1505 required_number(risk.eval(scope, "action.entry.risk")?, "action.entry.risk")?;
1506 let stoploss = optional_price(
1507 stoploss.eval(scope, "action.entry.stoploss")?,
1508 "action.entry.stoploss",
1509 )?;
1510 let targets = targets
1511 .iter()
1512 .map(|target| {
1513 required_price(
1514 target.eval(scope, "action.entry.targets")?,
1515 "action.entry.targets",
1516 )
1517 })
1518 .collect::<Result<_, _>>()?;
1519 (
1520 RawSignal::Entry {
1521 ts: input.time,
1522 symbol: ids.symbol.into(),
1523 side,
1524 order_type: *order_type,
1525 price,
1526 risk_multiplier: risk,
1527 stoploss,
1528 targets,
1529 group: identity.campaign_id.clone(),
1530 trade_id: Some(trade_id),
1531 },
1532 slot.clone(),
1533 )
1534 }
1535 CompiledAction::Close { slot } => (
1536 RawSignal::Close {
1537 ts: input.time,
1538 position: position_ref(identity, slot)?,
1539 },
1540 slot.clone(),
1541 ),
1542 CompiledAction::ClosePartial { slot, ratio } => (
1543 RawSignal::ClosePartial {
1544 ts: input.time,
1545 position: position_ref(identity, slot)?,
1546 ratio: required_number(
1547 ratio.eval(scope, "action.close_partial.ratio")?,
1548 "action.close_partial.ratio",
1549 )?,
1550 },
1551 slot.clone(),
1552 ),
1553 CompiledAction::MoveStoplossToEntry { slot } => (
1554 RawSignal::MoveStoplossToEntry {
1555 ts: input.time,
1556 position: position_ref(identity, slot)?,
1557 },
1558 slot.clone(),
1559 ),
1560 CompiledAction::ModifyStoploss { slot, price } => (
1561 RawSignal::ModifyStoploss {
1562 ts: input.time,
1563 position: position_ref(identity, slot)?,
1564 price: required_price(
1565 price.eval(scope, "action.modify_stoploss.price")?,
1566 "action.modify_stoploss.price",
1567 )?,
1568 },
1569 slot.clone(),
1570 ),
1571 CompiledAction::CancelPending { slot } => (
1572 RawSignal::CancelPending {
1573 ts: input.time,
1574 position: position_ref(identity, slot)?,
1575 },
1576 slot.clone(),
1577 ),
1578 };
1579 validate_raw_signal(&signal).map_err(|error| EvaluationError::InvalidAction {
1580 path: format!("actions[{index}]"),
1581 reason: error.to_string(),
1582 })?;
1583 if identity.commands.len() >= crate::MAX_COMMAND_CORRELATIONS {
1584 return Err(EvaluationError::OutputBound {
1585 kind: "command correlations",
1586 });
1587 }
1588 identity.command_counter = checked_next(identity.command_counter, "command")?;
1589 let command_id = generated_id(
1590 ids.strategy_id,
1591 ids.instance_id,
1592 "command",
1593 identity.command_counter,
1594 )?;
1595 identity.commands.push(CommandBinding {
1596 command_id: command_id.clone(),
1597 slot: slot.clone(),
1598 action: kind,
1599 terminal: None,
1600 facts: BTreeSet::new(),
1601 });
1602 Ok(ConfiguredCommand {
1603 command_id,
1604 action_kind: kind,
1605 trade_slot: slot,
1606 signal,
1607 })
1608}
1609
1610fn process_feedback(
1611 identity: &mut IdentityState,
1612 feedback: &[CommandFeedback],
1613) -> Result<Vec<FeedbackObservation>, EvaluationError> {
1614 let mut observations = Vec::new();
1615 for event in feedback {
1616 let command_id = event.command_id();
1617 let index = identity
1618 .commands
1619 .iter()
1620 .position(|item| item.command_id == command_id)
1621 .ok_or_else(|| EvaluationError::InvalidAction {
1622 path: "feedback.command_id".into(),
1623 reason: format!("unknown or replayed command ID {command_id}"),
1624 })?;
1625 let binding = identity.commands[index].clone();
1626 match event {
1627 CommandFeedback::Fact { fact, .. } => {
1628 validate_fact(binding.action, *fact)?;
1629 if !identity.commands[index].facts.insert(*fact) {
1630 return Err(EvaluationError::InvalidAction {
1631 path: "feedback.fact".into(),
1632 reason: "duplicate command fact".into(),
1633 });
1634 }
1635 let field = match fact {
1636 CommandFact::EntryFilled => Some(FeedbackField::EntryFilled),
1637 CommandFact::PositionClosed => Some(FeedbackField::PositionClosed),
1638 CommandFact::PendingCancelled => Some(FeedbackField::CancellationApplied),
1639 CommandFact::PositionReduced | CommandFact::StoplossModified => None,
1640 };
1641 if let Some(field) = field {
1642 observations.push(FeedbackObservation {
1643 slot: binding.slot.clone(),
1644 action: binding.action,
1645 field,
1646 });
1647 }
1648 }
1649 CommandFeedback::Terminal { status, reason, .. } => {
1650 validate_terminal_reason(*status, reason.as_deref())?;
1651 if identity.commands[index].terminal.replace(*status).is_some() {
1652 return Err(EvaluationError::InvalidAction {
1653 path: "feedback.terminal".into(),
1654 reason: "duplicate command terminal".into(),
1655 });
1656 }
1657 if *status != CommandTerminalStatus::Applied {
1658 if binding.action == ConfiguredActionKind::Entry {
1659 observations.push(FeedbackObservation {
1660 slot: binding.slot.clone(),
1661 action: binding.action,
1662 field: FeedbackField::EntryRejected,
1663 });
1664 } else if binding.action == ConfiguredActionKind::CancelPending {
1665 observations.push(FeedbackObservation {
1666 slot: binding.slot.clone(),
1667 action: binding.action,
1668 field: FeedbackField::CancellationRejected,
1669 });
1670 }
1671 }
1672 }
1673 }
1674
1675 let completed = command_completed(&identity.commands[index]);
1676 if completed {
1677 let successful =
1678 identity.commands[index].terminal == Some(CommandTerminalStatus::Applied);
1679 let release_slot = if successful {
1680 matches!(
1681 binding.action,
1682 ConfiguredActionKind::Close | ConfiguredActionKind::CancelPending
1683 )
1684 } else {
1685 binding.action == ConfiguredActionKind::Entry
1686 };
1687 if release_slot {
1688 release_trade_slot(identity, &binding.slot);
1689 }
1690 if successful && binding.action == ConfiguredActionKind::CancelPending {
1691 identity.commands.retain(|item| {
1692 !(item.slot == binding.slot && item.action == ConfiguredActionKind::Entry)
1693 });
1694 }
1695 if let Some(position) = identity
1696 .commands
1697 .iter()
1698 .position(|item| item.command_id == command_id)
1699 {
1700 identity.commands.remove(position);
1701 }
1702 }
1703 }
1704 Ok(observations)
1705}
1706
1707fn validate_terminal_reason(
1708 status: CommandTerminalStatus,
1709 reason: Option<&str>,
1710) -> Result<(), EvaluationError> {
1711 match (status, reason) {
1712 (CommandTerminalStatus::Applied, None) => Ok(()),
1713 (CommandTerminalStatus::Applied, Some(_)) => Err(EvaluationError::InvalidAction {
1714 path: "feedback.reason".into(),
1715 reason: "applied command terminal must not include a reason".into(),
1716 }),
1717 (_, Some(reason)) => {
1718 crate::validate_text(reason, crate::MAX_TEXT_BYTES).map_err(|reason| {
1719 EvaluationError::InvalidAction {
1720 path: "feedback.reason".into(),
1721 reason,
1722 }
1723 })
1724 }
1725 (_, None) => Err(EvaluationError::MissingRequired {
1726 path: "feedback.reason".into(),
1727 }),
1728 }
1729}
1730
1731fn command_completed(binding: &CommandBinding) -> bool {
1732 match binding.terminal {
1733 Some(CommandTerminalStatus::Applied) => {
1734 binding.facts.contains(&required_fact(binding.action))
1735 }
1736 Some(_) => true,
1737 None => false,
1738 }
1739}
1740
1741fn required_fact(action: ConfiguredActionKind) -> CommandFact {
1742 match action {
1743 ConfiguredActionKind::Entry => CommandFact::EntryFilled,
1744 ConfiguredActionKind::Close => CommandFact::PositionClosed,
1745 ConfiguredActionKind::ClosePartial => CommandFact::PositionReduced,
1746 ConfiguredActionKind::MoveStoplossToEntry | ConfiguredActionKind::ModifyStoploss => {
1747 CommandFact::StoplossModified
1748 }
1749 ConfiguredActionKind::CancelPending => CommandFact::PendingCancelled,
1750 }
1751}
1752
1753fn validate_fact(action: ConfiguredActionKind, fact: CommandFact) -> Result<(), EvaluationError> {
1754 let compatible = matches!(
1755 (action, fact),
1756 (ConfiguredActionKind::Entry, CommandFact::EntryFilled)
1757 | (ConfiguredActionKind::Close, CommandFact::PositionClosed)
1758 | (
1759 ConfiguredActionKind::ClosePartial,
1760 CommandFact::PositionReduced
1761 )
1762 | (
1763 ConfiguredActionKind::MoveStoplossToEntry,
1764 CommandFact::StoplossModified
1765 )
1766 | (
1767 ConfiguredActionKind::ModifyStoploss,
1768 CommandFact::StoplossModified
1769 )
1770 | (
1771 ConfiguredActionKind::CancelPending,
1772 CommandFact::PendingCancelled
1773 )
1774 );
1775 if compatible {
1776 Ok(())
1777 } else {
1778 Err(EvaluationError::InvalidAction {
1779 path: "feedback.fact".into(),
1780 reason: "command fact is incompatible with the original action".into(),
1781 })
1782 }
1783}
1784
1785fn release_trade_slot(identity: &mut IdentityState, slot: &str) {
1786 identity.slots.retain(|item| item.slot != slot);
1787 if identity.slots.is_empty() {
1788 identity.campaign_id = None;
1789 }
1790}
1791
1792fn evaluate_decision(
1793 template: &CompiledDecision,
1794 scope: &EvalScope<'_>,
1795 identity: &IdentityState,
1796 prior_slots: &[SlotBinding],
1797) -> Result<Decision, EvaluationError> {
1798 Ok(Decision {
1799 kind: template.kind,
1800 reason: template.reason.clone(),
1801 related_trade: resolve_related_trade(
1802 template.trade_slot.as_deref(),
1803 identity,
1804 prior_slots,
1805 )?,
1806 values: evaluate_outputs(&template.values, scope)?,
1807 })
1808}
1809
1810fn evaluate_note(
1811 template: &CompiledNote,
1812 scope: &EvalScope<'_>,
1813 identity: &IdentityState,
1814 prior_slots: &[SlotBinding],
1815) -> Result<Note, EvaluationError> {
1816 Ok(Note {
1817 kind: template.kind,
1818 reason: template.reason.clone(),
1819 related_trade: resolve_related_trade(
1820 template.trade_slot.as_deref(),
1821 identity,
1822 prior_slots,
1823 )?,
1824 values: evaluate_outputs(&template.values, scope)?,
1825 })
1826}
1827
1828fn resolve_related_trade(
1829 slot: Option<&str>,
1830 identity: &IdentityState,
1831 prior_slots: &[SlotBinding],
1832) -> Result<Option<RelatedTrade>, EvaluationError> {
1833 slot.map(|slot| {
1834 identity
1835 .slots
1836 .iter()
1837 .chain(prior_slots)
1838 .find(|item| item.slot == slot)
1839 .map(|item| RelatedTrade {
1840 slot: slot.into(),
1841 trade_id: item.trade_id.clone(),
1842 })
1843 .ok_or_else(|| EvaluationError::MissingRequired {
1844 path: format!("related trade slot {slot}"),
1845 })
1846 })
1847 .transpose()
1848}
1849
1850fn evaluate_outputs(
1851 values: &[CompiledNamedExpr],
1852 scope: &EvalScope<'_>,
1853) -> Result<Vec<NamedOutput>, EvaluationError> {
1854 values
1855 .iter()
1856 .map(|item| {
1857 let value = item.value.eval(scope, "output.value")?;
1858 let value = match value {
1859 Value::Integer(value) if value.unsigned_abs() <= (1_u64 << 53) => {
1860 OutputScalar::Integer(value)
1861 }
1862 Value::Number(value) if value.is_finite() => OutputScalar::Number(value),
1863 Value::Price(value) if value.is_finite() => OutputScalar::Price(value),
1864 Value::Integer(_) => {
1865 return Err(EvaluationError::InvalidAction {
1866 path: "output.value".into(),
1867 reason: "integer is not exactly representable as f64".into(),
1868 });
1869 }
1870 Value::Missing(_) => {
1871 return Err(EvaluationError::MissingRequired {
1872 path: "output.value".into(),
1873 });
1874 }
1875 value => {
1876 return Err(EvaluationError::TypeMismatch {
1877 path: "output.value".into(),
1878 expected: ScalarType::Number,
1879 actual: Some(value.scalar_type()),
1880 });
1881 }
1882 };
1883 Ok(NamedOutput {
1884 name: item.name.clone(),
1885 value,
1886 })
1887 })
1888 .collect()
1889}
1890
1891fn validate_input(
1892 input: &StrategyInput,
1893 requirements: &ConfiguredStrategyRequirements,
1894 declared_sources: &BTreeSet<SourceId>,
1895) -> Result<(), EvaluationError> {
1896 check_runtime_bound(
1897 "completed bars",
1898 input.completed_bars.len(),
1899 crate::MAX_COMPLETED_BARS,
1900 )?;
1901 check_runtime_bound("input values", input.values.len(), crate::MAX_NAMED_VALUES)?;
1902 check_runtime_bound("trade slots", input.trade_slots.len(), crate::MAX_LEGS)?;
1903 check_runtime_bound(
1904 "feedback",
1905 input.feedback.len(),
1906 crate::MAX_PENDING_FEEDBACK,
1907 )?;
1908
1909 let required_sources = requirements
1910 .completed_bars
1911 .iter()
1912 .map(|item| &item.source)
1913 .collect::<BTreeSet<_>>();
1914 let mut seen_sources = BTreeSet::new();
1915 for update in &input.completed_bars {
1916 if !declared_sources.contains(&update.source) || !required_sources.contains(&update.source)
1917 {
1918 return Err(EvaluationError::Material {
1919 material: "completed_bars".into(),
1920 reason: format!("undeclared or unrequired source {}", update.source),
1921 });
1922 }
1923 if !seen_sources.insert(&update.source) {
1924 return Err(EvaluationError::Material {
1925 material: "completed_bars".into(),
1926 reason: format!("duplicate source update {}", update.source),
1927 });
1928 }
1929 validate_bar(&update.bar)?;
1930 }
1931
1932 let named_requirements = requirements
1933 .named_inputs
1934 .iter()
1935 .map(|item| (item.name.as_str(), item.value_type))
1936 .collect::<BTreeMap<_, _>>();
1937 let mut seen_names = BTreeSet::new();
1938 for value in &input.values {
1939 let expected = named_requirements.get(value.name.as_str()).ok_or_else(|| {
1940 EvaluationError::Material {
1941 material: "input".into(),
1942 reason: format!("unknown named input {}", value.name),
1943 }
1944 })?;
1945 if !seen_names.insert(value.name.as_str()) {
1946 return Err(EvaluationError::Material {
1947 material: "input".into(),
1948 reason: format!("duplicate named input {}", value.name),
1949 });
1950 }
1951 ensure_runtime_type(&value.value, *expected, &format!("input.{}", value.name))?;
1952 }
1953 for requirement in &requirements.named_inputs {
1954 if !requirement.value_type.optional
1955 && !input
1956 .values
1957 .iter()
1958 .any(|item| item.name == requirement.name)
1959 {
1960 return Err(EvaluationError::MissingRequired {
1961 path: format!("input.{}", requirement.name),
1962 });
1963 }
1964 }
1965
1966 let mut seen_slots = BTreeSet::new();
1967 for facts in &input.trade_slots {
1968 if !requirements.trade_slots.contains(&facts.slot) {
1969 return Err(EvaluationError::Material {
1970 material: "trade_slots".into(),
1971 reason: format!("undeclared trade slot {}", facts.slot),
1972 });
1973 }
1974 if !seen_slots.insert(facts.slot.as_str()) {
1975 return Err(EvaluationError::Material {
1976 material: "trade_slots".into(),
1977 reason: format!("duplicate trade slot {}", facts.slot),
1978 });
1979 }
1980 validate_trade_slot_state(&facts.state)?;
1981 }
1982 for slot in &requirements.trade_slots {
1983 if !input.trade_slots.iter().any(|item| item.slot == *slot) {
1984 return Err(EvaluationError::MissingRequired {
1985 path: format!("trade_slots.{slot}"),
1986 });
1987 }
1988 }
1989 Ok(())
1990}
1991
1992fn validate_bar(bar: &crate::CompletedBar) -> Result<(), EvaluationError> {
1993 if ![bar.open, bar.high, bar.low, bar.close, bar.volume]
1994 .into_iter()
1995 .all(f64::is_finite)
1996 || bar.open <= 0.0
1997 || bar.high <= 0.0
1998 || bar.low <= 0.0
1999 || bar.close <= 0.0
2000 || bar.volume < 0.0
2001 || bar.high < bar.low
2002 || bar.high < bar.open.max(bar.close)
2003 || bar.low > bar.open.min(bar.close)
2004 {
2005 return Err(EvaluationError::Material {
2006 material: "completed_bars".into(),
2007 reason: "invalid completed bar".into(),
2008 });
2009 }
2010 Ok(())
2011}
2012
2013fn validate_trade_slot_state(state: &TradeSlotState) -> Result<(), EvaluationError> {
2014 let positive = |value: f64| value.is_finite() && value > 0.0;
2015 match state {
2016 TradeSlotState::Vacant => Ok(()),
2017 TradeSlotState::Pending {
2018 side,
2019 requested_price,
2020 stoploss,
2021 } => {
2022 if requested_price.is_some_and(|value| !positive(value))
2023 || stoploss.is_some_and(|value| !positive(value))
2024 || requested_price
2025 .zip(*stoploss)
2026 .is_some_and(|(entry, stop)| match side {
2027 qs_core::Side::Buy => stop >= entry,
2028 qs_core::Side::Sell => stop <= entry,
2029 })
2030 {
2031 Err(EvaluationError::Material {
2032 material: "trade_slots".into(),
2033 reason: "invalid pending trade slot geometry".into(),
2034 })
2035 } else {
2036 Ok(())
2037 }
2038 }
2039 TradeSlotState::Open {
2040 entry_price,
2041 remaining_size,
2042 stoploss,
2043 ..
2044 } => {
2045 if !positive(*entry_price)
2046 || !positive(*remaining_size)
2047 || stoploss.is_some_and(|value| !positive(value))
2048 {
2049 Err(EvaluationError::Material {
2050 material: "trade_slots".into(),
2051 reason: "invalid open trade slot facts".into(),
2052 })
2053 } else {
2054 Ok(())
2055 }
2056 }
2057 }
2058}
2059
2060fn append_pending_feedback(
2061 pending: &mut Vec<FeedbackObservation>,
2062 incoming: &[FeedbackObservation],
2063) -> Result<(), EvaluationError> {
2064 if pending.len().saturating_add(incoming.len()) > crate::MAX_PENDING_FEEDBACK {
2065 return Err(EvaluationError::OutputBound {
2066 kind: "pending feedback",
2067 });
2068 }
2069 pending.extend_from_slice(incoming);
2070 Ok(())
2071}
2072
2073fn validate_reachable(states: &[CompiledState], initial: usize) -> Result<(), CompileError> {
2074 let mut reached = BTreeSet::new();
2075 let mut queue = VecDeque::from([initial]);
2076 while let Some(state) = queue.pop_front() {
2077 if reached.insert(state) {
2078 for transition in &states[state].transitions {
2079 queue.push_back(transition.target);
2080 }
2081 }
2082 }
2083 for (index, state) in states.iter().enumerate() {
2084 if !reached.contains(&index) {
2085 return Err(CompileError::UnreachableState {
2086 state: state.id.clone(),
2087 });
2088 }
2089 }
2090 Ok(())
2091}
2092
2093fn validate_config_bounds(config: &StrategyConfig) -> Result<(), CompileError> {
2094 check_bound("sources", config.sources.len(), crate::MAX_SOURCES)?;
2095 check_bound("trade_slots", config.trade_slots.len(), crate::MAX_LEGS)?;
2096 check_bound("materials", config.materials.len(), crate::MAX_MATERIALS)?;
2097 check_bound("variables", config.variables.len(), crate::MAX_VARIABLES)?;
2098 check_bound("states", config.states.len(), crate::MAX_STATES)?;
2099 for (index, material) in config.materials.iter().enumerate() {
2100 check_bound(
2101 &format!("materials[{index}].inputs"),
2102 material.inputs.len(),
2103 crate::MAX_MATERIAL_INPUTS,
2104 )?;
2105 }
2106 for (state_index, state) in config.states.iter().enumerate() {
2107 check_bound(
2108 &format!("states[{state_index}].transitions"),
2109 state.transitions.len(),
2110 crate::MAX_TRANSITIONS,
2111 )?;
2112 for (transition_index, transition) in state.transitions.iter().enumerate() {
2113 let path = format!("states[{state_index}].transitions[{transition_index}]");
2114 check_bound(
2115 &format!("{path}.assignments"),
2116 transition.assignments.len(),
2117 crate::MAX_ASSIGNMENTS,
2118 )?;
2119 check_bound(
2120 &format!("{path}.actions"),
2121 transition.actions.len(),
2122 crate::MAX_ACTIONS,
2123 )?;
2124 check_bound(
2125 &format!("{path}.notes"),
2126 transition.notes.len(),
2127 crate::MAX_NOTES,
2128 )?;
2129 for (action_index, action) in transition.actions.iter().enumerate() {
2130 if let ActionTemplate::Entry { targets, .. } = action {
2131 check_bound(
2132 &format!("{path}.actions[{action_index}].targets"),
2133 targets.len(),
2134 crate::MAX_ENTRY_TARGETS,
2135 )?;
2136 }
2137 }
2138 }
2139 }
2140 Ok(())
2141}
2142
2143fn merge_lookbacks(target: &mut LookbackMap, source: &LookbackMap) {
2144 for (source, lookback) in source {
2145 merge_lookback(target, source.clone(), *lookback);
2146 }
2147}
2148
2149fn merge_lookback(target: &mut LookbackMap, source: SourceId, lookback: usize) {
2150 target
2151 .entry(source)
2152 .and_modify(|value| *value = (*value).max(lookback))
2153 .or_insert(lookback);
2154}
2155
2156fn require_source(
2157 sources: &BTreeSet<SourceId>,
2158 source: &SourceId,
2159 path: &str,
2160) -> Result<(), CompileError> {
2161 if sources.contains(source) {
2162 Ok(())
2163 } else {
2164 Err(CompileError::UnknownReference {
2165 path: path.into(),
2166 reference: source.to_string(),
2167 })
2168 }
2169}
2170
2171fn require_trade_slot(
2172 trade_slots: &BTreeSet<String>,
2173 slot: &str,
2174 path: &str,
2175) -> Result<(), CompileError> {
2176 validate_id_at(slot, path)?;
2177 if trade_slots.contains(slot) {
2178 Ok(())
2179 } else {
2180 Err(CompileError::UnknownReference {
2181 path: path.into(),
2182 reference: slot.into(),
2183 })
2184 }
2185}
2186
2187fn validate_id_at(value: &str, path: &str) -> Result<(), CompileError> {
2188 crate::validate_id(value).map_err(|reason| CompileError::InvalidIdentifier {
2189 path: path.into(),
2190 reason,
2191 })
2192}
2193
2194fn check_bound(path: &str, actual: usize, limit: usize) -> Result<(), CompileError> {
2195 if actual > limit {
2196 Err(CompileError::ExcessiveBound {
2197 path: path.into(),
2198 actual,
2199 limit,
2200 })
2201 } else {
2202 Ok(())
2203 }
2204}
2205
2206fn check_runtime_bound(
2207 kind: &'static str,
2208 actual: usize,
2209 limit: usize,
2210) -> Result<(), EvaluationError> {
2211 if actual > limit {
2212 Err(EvaluationError::OutputBound { kind })
2213 } else {
2214 Ok(())
2215 }
2216}
2217
2218fn require_type(actual: ValueType, expected: ValueType, path: &str) -> Result<(), CompileError> {
2219 if actual.scalar != expected.scalar {
2220 Err(CompileError::TypeMismatch {
2221 path: path.into(),
2222 expected,
2223 actual,
2224 })
2225 } else if !expected.optional && actual.optional {
2226 Err(CompileError::OptionalToRequired { path: path.into() })
2227 } else {
2228 Ok(())
2229 }
2230}
2231
2232fn ensure_runtime_type(
2233 value: &Value,
2234 expected: ValueType,
2235 path: &str,
2236) -> Result<(), EvaluationError> {
2237 if value.scalar_type() != expected.scalar || (!expected.optional && value.is_missing()) {
2238 return Err(EvaluationError::TypeMismatch {
2239 path: path.into(),
2240 expected: expected.scalar,
2241 actual: if value.is_missing() {
2242 None
2243 } else {
2244 Some(value.scalar_type())
2245 },
2246 });
2247 }
2248 match value {
2249 Value::Number(value) | Value::Price(value) if !value.is_finite() => {
2250 Err(EvaluationError::NonFinite { path: path.into() })
2251 }
2252 Value::Text(value) => {
2253 crate::validate_text(value, crate::MAX_TEXT_BYTES).map_err(|reason| {
2254 EvaluationError::Material {
2255 material: path.into(),
2256 reason,
2257 }
2258 })
2259 }
2260 _ => Ok(()),
2261 }
2262}
2263
2264fn position_ref(identity: &IdentityState, slot: &str) -> Result<PositionRef, EvaluationError> {
2265 identity
2266 .slots
2267 .iter()
2268 .find(|item| item.slot == slot)
2269 .map(|item| PositionRef::ByTradeId {
2270 trade_id: item.trade_id.clone(),
2271 })
2272 .ok_or_else(|| EvaluationError::InvalidAction {
2273 path: "action.slot".into(),
2274 reason: format!("trade slot {slot} is not reserved"),
2275 })
2276}
2277
2278fn checked_next(value: u64, kind: &'static str) -> Result<u64, EvaluationError> {
2279 value
2280 .checked_add(1)
2281 .ok_or(EvaluationError::CounterExhausted { kind })
2282}
2283
2284fn generated_id(
2285 strategy: &str,
2286 instance: &str,
2287 kind: &str,
2288 counter: u64,
2289) -> Result<String, EvaluationError> {
2290 let value = format!(
2291 "{}:{strategy}|{}:{instance}|{kind}:{counter}",
2292 strategy.len(),
2293 instance.len()
2294 );
2295 if value.len() > crate::MAX_GENERATED_ID_BYTES {
2296 Err(EvaluationError::IdCapacity)
2297 } else {
2298 Ok(value)
2299 }
2300}
2301
2302fn generated_scoped_id(
2303 strategy: &str,
2304 instance: &str,
2305 campaign: u64,
2306 kind: &str,
2307 counter: u64,
2308) -> Result<String, EvaluationError> {
2309 let value = format!(
2310 "{}:{strategy}|{}:{instance}|campaign:{campaign}|{kind}:{counter}",
2311 strategy.len(),
2312 instance.len()
2313 );
2314 if value.len() > crate::MAX_GENERATED_ID_BYTES {
2315 Err(EvaluationError::IdCapacity)
2316 } else {
2317 Ok(value)
2318 }
2319}
2320
2321fn required_number(value: Value, path: &str) -> Result<f64, EvaluationError> {
2322 match value {
2323 Value::Number(value) if value.is_finite() => Ok(value),
2324 Value::Missing(_) => Err(EvaluationError::MissingRequired { path: path.into() }),
2325 value => Err(EvaluationError::TypeMismatch {
2326 path: path.into(),
2327 expected: ScalarType::Number,
2328 actual: Some(value.scalar_type()),
2329 }),
2330 }
2331}
2332
2333fn required_price(value: Value, path: &str) -> Result<f64, EvaluationError> {
2334 match value {
2335 Value::Price(value) if value.is_finite() => Ok(value),
2336 Value::Missing(_) => Err(EvaluationError::MissingRequired { path: path.into() }),
2337 value => Err(EvaluationError::TypeMismatch {
2338 path: path.into(),
2339 expected: ScalarType::Price,
2340 actual: Some(value.scalar_type()),
2341 }),
2342 }
2343}
2344
2345fn optional_price(value: Value, path: &str) -> Result<Option<f64>, EvaluationError> {
2346 match value {
2347 Value::Price(value) if value.is_finite() => Ok(Some(value)),
2348 Value::Missing(ScalarType::Price) => Ok(None),
2349 value => Err(EvaluationError::TypeMismatch {
2350 path: path.into(),
2351 expected: ScalarType::Price,
2352 actual: Some(value.scalar_type()),
2353 }),
2354 }
2355}
2356
2357fn required_side(value: Value, path: &str) -> Result<qs_core::Side, EvaluationError> {
2358 match value {
2359 Value::Side(value) => Ok(value),
2360 Value::Missing(_) => Err(EvaluationError::MissingRequired { path: path.into() }),
2361 value => Err(EvaluationError::TypeMismatch {
2362 path: path.into(),
2363 expected: ScalarType::Side,
2364 actual: Some(value.scalar_type()),
2365 }),
2366 }
2367}