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