Skip to main content

qs_strategy/
runtime.rs

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    /// Instance identity; together with the strategy ID it scopes every generated campaign, trade, and command identifier.
434    pub fn instance_id(&self) -> &str {
435        &self.instance_id
436    }
437
438    /// Effective numeric contracts in dependency order, keyed by configured material ID.
439    /// Factories without a descriptor are omitted rather than assigned inferred semantics.
440    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    /// Commit feedback at an adapter-owned final boundary without evaluating market inputs or transitions.
473    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, &current_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: &current_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                // A close or cancellation that was skipped had nothing left to act on, which means the slot's position or order is already gone. Treating that as still reserved would strand the slot for the rest of the run, so a strategy whose protective stop fired could never trade again.
1895                ConfiguredActionKind::Close | ConfiguredActionKind::CancelPending => {
1896                    successful || terminal == Some(CommandTerminalStatus::Skipped)
1897                }
1898                // A failed entry never reserved anything the strategy can act on.
1899                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}