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 entry_class: None,
1532 },
1533 slot.clone(),
1534 )
1535 }
1536 CompiledAction::Close { slot } => (
1537 RawSignal::Close {
1538 ts: input.time,
1539 position: position_ref(identity, slot)?,
1540 },
1541 slot.clone(),
1542 ),
1543 CompiledAction::ClosePartial { slot, ratio } => (
1544 RawSignal::ClosePartial {
1545 ts: input.time,
1546 position: position_ref(identity, slot)?,
1547 ratio: required_number(
1548 ratio.eval(scope, "action.close_partial.ratio")?,
1549 "action.close_partial.ratio",
1550 )?,
1551 },
1552 slot.clone(),
1553 ),
1554 CompiledAction::MoveStoplossToEntry { slot } => (
1555 RawSignal::MoveStoplossToEntry {
1556 ts: input.time,
1557 position: position_ref(identity, slot)?,
1558 },
1559 slot.clone(),
1560 ),
1561 CompiledAction::ModifyStoploss { slot, price } => (
1562 RawSignal::ModifyStoploss {
1563 ts: input.time,
1564 position: position_ref(identity, slot)?,
1565 price: required_price(
1566 price.eval(scope, "action.modify_stoploss.price")?,
1567 "action.modify_stoploss.price",
1568 )?,
1569 },
1570 slot.clone(),
1571 ),
1572 CompiledAction::CancelPending { slot } => (
1573 RawSignal::CancelPending {
1574 ts: input.time,
1575 position: position_ref(identity, slot)?,
1576 },
1577 slot.clone(),
1578 ),
1579 };
1580 validate_raw_signal(&signal).map_err(|error| EvaluationError::InvalidAction {
1581 path: format!("actions[{index}]"),
1582 reason: error.to_string(),
1583 })?;
1584 if identity.commands.len() >= crate::MAX_COMMAND_CORRELATIONS {
1585 return Err(EvaluationError::OutputBound {
1586 kind: "command correlations",
1587 });
1588 }
1589 identity.command_counter = checked_next(identity.command_counter, "command")?;
1590 let command_id = generated_id(
1591 ids.strategy_id,
1592 ids.instance_id,
1593 "command",
1594 identity.command_counter,
1595 )?;
1596 identity.commands.push(CommandBinding {
1597 command_id: command_id.clone(),
1598 slot: slot.clone(),
1599 action: kind,
1600 terminal: None,
1601 facts: BTreeSet::new(),
1602 });
1603 Ok(ConfiguredCommand {
1604 command_id,
1605 action_kind: kind,
1606 trade_slot: slot,
1607 signal,
1608 })
1609}
1610
1611fn process_feedback(
1612 identity: &mut IdentityState,
1613 feedback: &[CommandFeedback],
1614) -> Result<Vec<FeedbackObservation>, EvaluationError> {
1615 let mut observations = Vec::new();
1616 for event in feedback {
1617 let command_id = event.command_id();
1618 let index = identity
1619 .commands
1620 .iter()
1621 .position(|item| item.command_id == command_id)
1622 .ok_or_else(|| EvaluationError::InvalidAction {
1623 path: "feedback.command_id".into(),
1624 reason: format!("unknown or replayed command ID {command_id}"),
1625 })?;
1626 let binding = identity.commands[index].clone();
1627 match event {
1628 CommandFeedback::Fact { fact, .. } => {
1629 validate_fact(binding.action, *fact)?;
1630 if !identity.commands[index].facts.insert(*fact) {
1631 return Err(EvaluationError::InvalidAction {
1632 path: "feedback.fact".into(),
1633 reason: "duplicate command fact".into(),
1634 });
1635 }
1636 let field = match fact {
1637 CommandFact::EntryFilled => Some(FeedbackField::EntryFilled),
1638 CommandFact::PositionClosed => Some(FeedbackField::PositionClosed),
1639 CommandFact::PendingCancelled => Some(FeedbackField::CancellationApplied),
1640 CommandFact::PositionReduced | CommandFact::StoplossModified => None,
1641 };
1642 if let Some(field) = field {
1643 observations.push(FeedbackObservation {
1644 slot: binding.slot.clone(),
1645 action: binding.action,
1646 field,
1647 });
1648 }
1649 }
1650 CommandFeedback::Terminal { status, reason, .. } => {
1651 validate_terminal_reason(*status, reason.as_deref())?;
1652 if identity.commands[index].terminal.replace(*status).is_some() {
1653 return Err(EvaluationError::InvalidAction {
1654 path: "feedback.terminal".into(),
1655 reason: "duplicate command terminal".into(),
1656 });
1657 }
1658 if *status != CommandTerminalStatus::Applied {
1659 if binding.action == ConfiguredActionKind::Entry {
1660 observations.push(FeedbackObservation {
1661 slot: binding.slot.clone(),
1662 action: binding.action,
1663 field: FeedbackField::EntryRejected,
1664 });
1665 } else if binding.action == ConfiguredActionKind::CancelPending {
1666 observations.push(FeedbackObservation {
1667 slot: binding.slot.clone(),
1668 action: binding.action,
1669 field: FeedbackField::CancellationRejected,
1670 });
1671 }
1672 }
1673 }
1674 }
1675
1676 let completed = command_completed(&identity.commands[index]);
1677 if completed {
1678 let successful =
1679 identity.commands[index].terminal == Some(CommandTerminalStatus::Applied);
1680 let release_slot = if successful {
1681 matches!(
1682 binding.action,
1683 ConfiguredActionKind::Close | ConfiguredActionKind::CancelPending
1684 )
1685 } else {
1686 binding.action == ConfiguredActionKind::Entry
1687 };
1688 if release_slot {
1689 release_trade_slot(identity, &binding.slot);
1690 }
1691 if successful && binding.action == ConfiguredActionKind::CancelPending {
1692 identity.commands.retain(|item| {
1693 !(item.slot == binding.slot && item.action == ConfiguredActionKind::Entry)
1694 });
1695 }
1696 if let Some(position) = identity
1697 .commands
1698 .iter()
1699 .position(|item| item.command_id == command_id)
1700 {
1701 identity.commands.remove(position);
1702 }
1703 }
1704 }
1705 Ok(observations)
1706}
1707
1708fn validate_terminal_reason(
1709 status: CommandTerminalStatus,
1710 reason: Option<&str>,
1711) -> Result<(), EvaluationError> {
1712 match (status, reason) {
1713 (CommandTerminalStatus::Applied, None) => Ok(()),
1714 (CommandTerminalStatus::Applied, Some(_)) => Err(EvaluationError::InvalidAction {
1715 path: "feedback.reason".into(),
1716 reason: "applied command terminal must not include a reason".into(),
1717 }),
1718 (_, Some(reason)) => {
1719 crate::validate_text(reason, crate::MAX_TEXT_BYTES).map_err(|reason| {
1720 EvaluationError::InvalidAction {
1721 path: "feedback.reason".into(),
1722 reason,
1723 }
1724 })
1725 }
1726 (_, None) => Err(EvaluationError::MissingRequired {
1727 path: "feedback.reason".into(),
1728 }),
1729 }
1730}
1731
1732fn command_completed(binding: &CommandBinding) -> bool {
1733 match binding.terminal {
1734 Some(CommandTerminalStatus::Applied) => {
1735 binding.facts.contains(&required_fact(binding.action))
1736 }
1737 Some(_) => true,
1738 None => false,
1739 }
1740}
1741
1742fn required_fact(action: ConfiguredActionKind) -> CommandFact {
1743 match action {
1744 ConfiguredActionKind::Entry => CommandFact::EntryFilled,
1745 ConfiguredActionKind::Close => CommandFact::PositionClosed,
1746 ConfiguredActionKind::ClosePartial => CommandFact::PositionReduced,
1747 ConfiguredActionKind::MoveStoplossToEntry | ConfiguredActionKind::ModifyStoploss => {
1748 CommandFact::StoplossModified
1749 }
1750 ConfiguredActionKind::CancelPending => CommandFact::PendingCancelled,
1751 }
1752}
1753
1754fn validate_fact(action: ConfiguredActionKind, fact: CommandFact) -> Result<(), EvaluationError> {
1755 let compatible = matches!(
1756 (action, fact),
1757 (ConfiguredActionKind::Entry, CommandFact::EntryFilled)
1758 | (ConfiguredActionKind::Close, CommandFact::PositionClosed)
1759 | (
1760 ConfiguredActionKind::ClosePartial,
1761 CommandFact::PositionReduced
1762 )
1763 | (
1764 ConfiguredActionKind::MoveStoplossToEntry,
1765 CommandFact::StoplossModified
1766 )
1767 | (
1768 ConfiguredActionKind::ModifyStoploss,
1769 CommandFact::StoplossModified
1770 )
1771 | (
1772 ConfiguredActionKind::CancelPending,
1773 CommandFact::PendingCancelled
1774 )
1775 );
1776 if compatible {
1777 Ok(())
1778 } else {
1779 Err(EvaluationError::InvalidAction {
1780 path: "feedback.fact".into(),
1781 reason: "command fact is incompatible with the original action".into(),
1782 })
1783 }
1784}
1785
1786fn release_trade_slot(identity: &mut IdentityState, slot: &str) {
1787 identity.slots.retain(|item| item.slot != slot);
1788 if identity.slots.is_empty() {
1789 identity.campaign_id = None;
1790 }
1791}
1792
1793fn evaluate_decision(
1794 template: &CompiledDecision,
1795 scope: &EvalScope<'_>,
1796 identity: &IdentityState,
1797 prior_slots: &[SlotBinding],
1798) -> Result<Decision, EvaluationError> {
1799 Ok(Decision {
1800 kind: template.kind,
1801 reason: template.reason.clone(),
1802 related_trade: resolve_related_trade(
1803 template.trade_slot.as_deref(),
1804 identity,
1805 prior_slots,
1806 )?,
1807 values: evaluate_outputs(&template.values, scope)?,
1808 })
1809}
1810
1811fn evaluate_note(
1812 template: &CompiledNote,
1813 scope: &EvalScope<'_>,
1814 identity: &IdentityState,
1815 prior_slots: &[SlotBinding],
1816) -> Result<Note, EvaluationError> {
1817 Ok(Note {
1818 kind: template.kind,
1819 reason: template.reason.clone(),
1820 related_trade: resolve_related_trade(
1821 template.trade_slot.as_deref(),
1822 identity,
1823 prior_slots,
1824 )?,
1825 values: evaluate_outputs(&template.values, scope)?,
1826 })
1827}
1828
1829fn resolve_related_trade(
1830 slot: Option<&str>,
1831 identity: &IdentityState,
1832 prior_slots: &[SlotBinding],
1833) -> Result<Option<RelatedTrade>, EvaluationError> {
1834 slot.map(|slot| {
1835 identity
1836 .slots
1837 .iter()
1838 .chain(prior_slots)
1839 .find(|item| item.slot == slot)
1840 .map(|item| RelatedTrade {
1841 slot: slot.into(),
1842 trade_id: item.trade_id.clone(),
1843 })
1844 .ok_or_else(|| EvaluationError::MissingRequired {
1845 path: format!("related trade slot {slot}"),
1846 })
1847 })
1848 .transpose()
1849}
1850
1851fn evaluate_outputs(
1852 values: &[CompiledNamedExpr],
1853 scope: &EvalScope<'_>,
1854) -> Result<Vec<NamedOutput>, EvaluationError> {
1855 values
1856 .iter()
1857 .map(|item| {
1858 let value = item.value.eval(scope, "output.value")?;
1859 let value = match value {
1860 Value::Integer(value) if value.unsigned_abs() <= (1_u64 << 53) => {
1861 OutputScalar::Integer(value)
1862 }
1863 Value::Number(value) if value.is_finite() => OutputScalar::Number(value),
1864 Value::Price(value) if value.is_finite() => OutputScalar::Price(value),
1865 Value::Integer(_) => {
1866 return Err(EvaluationError::InvalidAction {
1867 path: "output.value".into(),
1868 reason: "integer is not exactly representable as f64".into(),
1869 });
1870 }
1871 Value::Missing(_) => {
1872 return Err(EvaluationError::MissingRequired {
1873 path: "output.value".into(),
1874 });
1875 }
1876 value => {
1877 return Err(EvaluationError::TypeMismatch {
1878 path: "output.value".into(),
1879 expected: ScalarType::Number,
1880 actual: Some(value.scalar_type()),
1881 });
1882 }
1883 };
1884 Ok(NamedOutput {
1885 name: item.name.clone(),
1886 value,
1887 })
1888 })
1889 .collect()
1890}
1891
1892fn validate_input(
1893 input: &StrategyInput,
1894 requirements: &ConfiguredStrategyRequirements,
1895 declared_sources: &BTreeSet<SourceId>,
1896) -> Result<(), EvaluationError> {
1897 check_runtime_bound(
1898 "completed bars",
1899 input.completed_bars.len(),
1900 crate::MAX_COMPLETED_BARS,
1901 )?;
1902 check_runtime_bound("input values", input.values.len(), crate::MAX_NAMED_VALUES)?;
1903 check_runtime_bound("trade slots", input.trade_slots.len(), crate::MAX_LEGS)?;
1904 check_runtime_bound(
1905 "feedback",
1906 input.feedback.len(),
1907 crate::MAX_PENDING_FEEDBACK,
1908 )?;
1909
1910 let required_sources = requirements
1911 .completed_bars
1912 .iter()
1913 .map(|item| &item.source)
1914 .collect::<BTreeSet<_>>();
1915 let mut seen_sources = BTreeSet::new();
1916 for update in &input.completed_bars {
1917 if !declared_sources.contains(&update.source) || !required_sources.contains(&update.source)
1918 {
1919 return Err(EvaluationError::Material {
1920 material: "completed_bars".into(),
1921 reason: format!("undeclared or unrequired source {}", update.source),
1922 });
1923 }
1924 if !seen_sources.insert(&update.source) {
1925 return Err(EvaluationError::Material {
1926 material: "completed_bars".into(),
1927 reason: format!("duplicate source update {}", update.source),
1928 });
1929 }
1930 validate_bar(&update.bar)?;
1931 }
1932
1933 let named_requirements = requirements
1934 .named_inputs
1935 .iter()
1936 .map(|item| (item.name.as_str(), item.value_type))
1937 .collect::<BTreeMap<_, _>>();
1938 let mut seen_names = BTreeSet::new();
1939 for value in &input.values {
1940 let expected = named_requirements.get(value.name.as_str()).ok_or_else(|| {
1941 EvaluationError::Material {
1942 material: "input".into(),
1943 reason: format!("unknown named input {}", value.name),
1944 }
1945 })?;
1946 if !seen_names.insert(value.name.as_str()) {
1947 return Err(EvaluationError::Material {
1948 material: "input".into(),
1949 reason: format!("duplicate named input {}", value.name),
1950 });
1951 }
1952 ensure_runtime_type(&value.value, *expected, &format!("input.{}", value.name))?;
1953 }
1954 for requirement in &requirements.named_inputs {
1955 if !requirement.value_type.optional
1956 && !input
1957 .values
1958 .iter()
1959 .any(|item| item.name == requirement.name)
1960 {
1961 return Err(EvaluationError::MissingRequired {
1962 path: format!("input.{}", requirement.name),
1963 });
1964 }
1965 }
1966
1967 let mut seen_slots = BTreeSet::new();
1968 for facts in &input.trade_slots {
1969 if !requirements.trade_slots.contains(&facts.slot) {
1970 return Err(EvaluationError::Material {
1971 material: "trade_slots".into(),
1972 reason: format!("undeclared trade slot {}", facts.slot),
1973 });
1974 }
1975 if !seen_slots.insert(facts.slot.as_str()) {
1976 return Err(EvaluationError::Material {
1977 material: "trade_slots".into(),
1978 reason: format!("duplicate trade slot {}", facts.slot),
1979 });
1980 }
1981 validate_trade_slot_state(&facts.state)?;
1982 }
1983 for slot in &requirements.trade_slots {
1984 if !input.trade_slots.iter().any(|item| item.slot == *slot) {
1985 return Err(EvaluationError::MissingRequired {
1986 path: format!("trade_slots.{slot}"),
1987 });
1988 }
1989 }
1990 Ok(())
1991}
1992
1993fn validate_bar(bar: &crate::CompletedBar) -> Result<(), EvaluationError> {
1994 if ![bar.open, bar.high, bar.low, bar.close, bar.volume]
1995 .into_iter()
1996 .all(f64::is_finite)
1997 || bar.open <= 0.0
1998 || bar.high <= 0.0
1999 || bar.low <= 0.0
2000 || bar.close <= 0.0
2001 || bar.volume < 0.0
2002 || bar.high < bar.low
2003 || bar.high < bar.open.max(bar.close)
2004 || bar.low > bar.open.min(bar.close)
2005 {
2006 return Err(EvaluationError::Material {
2007 material: "completed_bars".into(),
2008 reason: "invalid completed bar".into(),
2009 });
2010 }
2011 Ok(())
2012}
2013
2014fn validate_trade_slot_state(state: &TradeSlotState) -> Result<(), EvaluationError> {
2015 let positive = |value: f64| value.is_finite() && value > 0.0;
2016 match state {
2017 TradeSlotState::Vacant => Ok(()),
2018 TradeSlotState::Pending {
2019 side,
2020 requested_price,
2021 stoploss,
2022 } => {
2023 if requested_price.is_some_and(|value| !positive(value))
2024 || stoploss.is_some_and(|value| !positive(value))
2025 || requested_price
2026 .zip(*stoploss)
2027 .is_some_and(|(entry, stop)| match side {
2028 qs_core::Side::Buy => stop >= entry,
2029 qs_core::Side::Sell => stop <= entry,
2030 })
2031 {
2032 Err(EvaluationError::Material {
2033 material: "trade_slots".into(),
2034 reason: "invalid pending trade slot geometry".into(),
2035 })
2036 } else {
2037 Ok(())
2038 }
2039 }
2040 TradeSlotState::Open {
2041 entry_price,
2042 remaining_size,
2043 stoploss,
2044 ..
2045 } => {
2046 if !positive(*entry_price)
2047 || !positive(*remaining_size)
2048 || stoploss.is_some_and(|value| !positive(value))
2049 {
2050 Err(EvaluationError::Material {
2051 material: "trade_slots".into(),
2052 reason: "invalid open trade slot facts".into(),
2053 })
2054 } else {
2055 Ok(())
2056 }
2057 }
2058 }
2059}
2060
2061fn append_pending_feedback(
2062 pending: &mut Vec<FeedbackObservation>,
2063 incoming: &[FeedbackObservation],
2064) -> Result<(), EvaluationError> {
2065 if pending.len().saturating_add(incoming.len()) > crate::MAX_PENDING_FEEDBACK {
2066 return Err(EvaluationError::OutputBound {
2067 kind: "pending feedback",
2068 });
2069 }
2070 pending.extend_from_slice(incoming);
2071 Ok(())
2072}
2073
2074fn validate_reachable(states: &[CompiledState], initial: usize) -> Result<(), CompileError> {
2075 let mut reached = BTreeSet::new();
2076 let mut queue = VecDeque::from([initial]);
2077 while let Some(state) = queue.pop_front() {
2078 if reached.insert(state) {
2079 for transition in &states[state].transitions {
2080 queue.push_back(transition.target);
2081 }
2082 }
2083 }
2084 for (index, state) in states.iter().enumerate() {
2085 if !reached.contains(&index) {
2086 return Err(CompileError::UnreachableState {
2087 state: state.id.clone(),
2088 });
2089 }
2090 }
2091 Ok(())
2092}
2093
2094fn validate_config_bounds(config: &StrategyConfig) -> Result<(), CompileError> {
2095 check_bound("sources", config.sources.len(), crate::MAX_SOURCES)?;
2096 check_bound("trade_slots", config.trade_slots.len(), crate::MAX_LEGS)?;
2097 check_bound("materials", config.materials.len(), crate::MAX_MATERIALS)?;
2098 check_bound("variables", config.variables.len(), crate::MAX_VARIABLES)?;
2099 check_bound("states", config.states.len(), crate::MAX_STATES)?;
2100 for (index, material) in config.materials.iter().enumerate() {
2101 check_bound(
2102 &format!("materials[{index}].inputs"),
2103 material.inputs.len(),
2104 crate::MAX_MATERIAL_INPUTS,
2105 )?;
2106 }
2107 for (state_index, state) in config.states.iter().enumerate() {
2108 check_bound(
2109 &format!("states[{state_index}].transitions"),
2110 state.transitions.len(),
2111 crate::MAX_TRANSITIONS,
2112 )?;
2113 for (transition_index, transition) in state.transitions.iter().enumerate() {
2114 let path = format!("states[{state_index}].transitions[{transition_index}]");
2115 check_bound(
2116 &format!("{path}.assignments"),
2117 transition.assignments.len(),
2118 crate::MAX_ASSIGNMENTS,
2119 )?;
2120 check_bound(
2121 &format!("{path}.actions"),
2122 transition.actions.len(),
2123 crate::MAX_ACTIONS,
2124 )?;
2125 check_bound(
2126 &format!("{path}.notes"),
2127 transition.notes.len(),
2128 crate::MAX_NOTES,
2129 )?;
2130 for (action_index, action) in transition.actions.iter().enumerate() {
2131 if let ActionTemplate::Entry { targets, .. } = action {
2132 check_bound(
2133 &format!("{path}.actions[{action_index}].targets"),
2134 targets.len(),
2135 crate::MAX_ENTRY_TARGETS,
2136 )?;
2137 }
2138 }
2139 }
2140 }
2141 Ok(())
2142}
2143
2144fn merge_lookbacks(target: &mut LookbackMap, source: &LookbackMap) {
2145 for (source, lookback) in source {
2146 merge_lookback(target, source.clone(), *lookback);
2147 }
2148}
2149
2150fn merge_lookback(target: &mut LookbackMap, source: SourceId, lookback: usize) {
2151 target
2152 .entry(source)
2153 .and_modify(|value| *value = (*value).max(lookback))
2154 .or_insert(lookback);
2155}
2156
2157fn require_source(
2158 sources: &BTreeSet<SourceId>,
2159 source: &SourceId,
2160 path: &str,
2161) -> Result<(), CompileError> {
2162 if sources.contains(source) {
2163 Ok(())
2164 } else {
2165 Err(CompileError::UnknownReference {
2166 path: path.into(),
2167 reference: source.to_string(),
2168 })
2169 }
2170}
2171
2172fn require_trade_slot(
2173 trade_slots: &BTreeSet<String>,
2174 slot: &str,
2175 path: &str,
2176) -> Result<(), CompileError> {
2177 validate_id_at(slot, path)?;
2178 if trade_slots.contains(slot) {
2179 Ok(())
2180 } else {
2181 Err(CompileError::UnknownReference {
2182 path: path.into(),
2183 reference: slot.into(),
2184 })
2185 }
2186}
2187
2188fn validate_id_at(value: &str, path: &str) -> Result<(), CompileError> {
2189 crate::validate_id(value).map_err(|reason| CompileError::InvalidIdentifier {
2190 path: path.into(),
2191 reason,
2192 })
2193}
2194
2195fn check_bound(path: &str, actual: usize, limit: usize) -> Result<(), CompileError> {
2196 if actual > limit {
2197 Err(CompileError::ExcessiveBound {
2198 path: path.into(),
2199 actual,
2200 limit,
2201 })
2202 } else {
2203 Ok(())
2204 }
2205}
2206
2207fn check_runtime_bound(
2208 kind: &'static str,
2209 actual: usize,
2210 limit: usize,
2211) -> Result<(), EvaluationError> {
2212 if actual > limit {
2213 Err(EvaluationError::OutputBound { kind })
2214 } else {
2215 Ok(())
2216 }
2217}
2218
2219fn require_type(actual: ValueType, expected: ValueType, path: &str) -> Result<(), CompileError> {
2220 if actual.scalar != expected.scalar {
2221 Err(CompileError::TypeMismatch {
2222 path: path.into(),
2223 expected,
2224 actual,
2225 })
2226 } else if !expected.optional && actual.optional {
2227 Err(CompileError::OptionalToRequired { path: path.into() })
2228 } else {
2229 Ok(())
2230 }
2231}
2232
2233fn ensure_runtime_type(
2234 value: &Value,
2235 expected: ValueType,
2236 path: &str,
2237) -> Result<(), EvaluationError> {
2238 if value.scalar_type() != expected.scalar || (!expected.optional && value.is_missing()) {
2239 return Err(EvaluationError::TypeMismatch {
2240 path: path.into(),
2241 expected: expected.scalar,
2242 actual: if value.is_missing() {
2243 None
2244 } else {
2245 Some(value.scalar_type())
2246 },
2247 });
2248 }
2249 match value {
2250 Value::Number(value) | Value::Price(value) if !value.is_finite() => {
2251 Err(EvaluationError::NonFinite { path: path.into() })
2252 }
2253 Value::Text(value) => {
2254 crate::validate_text(value, crate::MAX_TEXT_BYTES).map_err(|reason| {
2255 EvaluationError::Material {
2256 material: path.into(),
2257 reason,
2258 }
2259 })
2260 }
2261 _ => Ok(()),
2262 }
2263}
2264
2265fn position_ref(identity: &IdentityState, slot: &str) -> Result<PositionRef, EvaluationError> {
2266 identity
2267 .slots
2268 .iter()
2269 .find(|item| item.slot == slot)
2270 .map(|item| PositionRef::ByTradeId {
2271 trade_id: item.trade_id.clone(),
2272 })
2273 .ok_or_else(|| EvaluationError::InvalidAction {
2274 path: "action.slot".into(),
2275 reason: format!("trade slot {slot} is not reserved"),
2276 })
2277}
2278
2279fn checked_next(value: u64, kind: &'static str) -> Result<u64, EvaluationError> {
2280 value
2281 .checked_add(1)
2282 .ok_or(EvaluationError::CounterExhausted { kind })
2283}
2284
2285fn generated_id(
2286 strategy: &str,
2287 instance: &str,
2288 kind: &str,
2289 counter: u64,
2290) -> Result<String, EvaluationError> {
2291 let value = format!(
2292 "{}:{strategy}|{}:{instance}|{kind}:{counter}",
2293 strategy.len(),
2294 instance.len()
2295 );
2296 if value.len() > crate::MAX_GENERATED_ID_BYTES {
2297 Err(EvaluationError::IdCapacity)
2298 } else {
2299 Ok(value)
2300 }
2301}
2302
2303fn generated_scoped_id(
2304 strategy: &str,
2305 instance: &str,
2306 campaign: u64,
2307 kind: &str,
2308 counter: u64,
2309) -> Result<String, EvaluationError> {
2310 let value = format!(
2311 "{}:{strategy}|{}:{instance}|campaign:{campaign}|{kind}:{counter}",
2312 strategy.len(),
2313 instance.len()
2314 );
2315 if value.len() > crate::MAX_GENERATED_ID_BYTES {
2316 Err(EvaluationError::IdCapacity)
2317 } else {
2318 Ok(value)
2319 }
2320}
2321
2322fn required_number(value: Value, path: &str) -> Result<f64, EvaluationError> {
2323 match value {
2324 Value::Number(value) if value.is_finite() => Ok(value),
2325 Value::Missing(_) => Err(EvaluationError::MissingRequired { path: path.into() }),
2326 value => Err(EvaluationError::TypeMismatch {
2327 path: path.into(),
2328 expected: ScalarType::Number,
2329 actual: Some(value.scalar_type()),
2330 }),
2331 }
2332}
2333
2334fn required_price(value: Value, path: &str) -> Result<f64, EvaluationError> {
2335 match value {
2336 Value::Price(value) if value.is_finite() => Ok(value),
2337 Value::Missing(_) => Err(EvaluationError::MissingRequired { path: path.into() }),
2338 value => Err(EvaluationError::TypeMismatch {
2339 path: path.into(),
2340 expected: ScalarType::Price,
2341 actual: Some(value.scalar_type()),
2342 }),
2343 }
2344}
2345
2346fn optional_price(value: Value, path: &str) -> Result<Option<f64>, EvaluationError> {
2347 match value {
2348 Value::Price(value) if value.is_finite() => Ok(Some(value)),
2349 Value::Missing(ScalarType::Price) => Ok(None),
2350 value => Err(EvaluationError::TypeMismatch {
2351 path: path.into(),
2352 expected: ScalarType::Price,
2353 actual: Some(value.scalar_type()),
2354 }),
2355 }
2356}
2357
2358fn required_side(value: Value, path: &str) -> Result<qs_core::Side, EvaluationError> {
2359 match value {
2360 Value::Side(value) => Ok(value),
2361 Value::Missing(_) => Err(EvaluationError::MissingRequired { path: path.into() }),
2362 value => Err(EvaluationError::TypeMismatch {
2363 path: path.into(),
2364 expected: ScalarType::Side,
2365 actual: Some(value.scalar_type()),
2366 }),
2367 }
2368}