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