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