Skip to main content

qs_strategy/
config.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use qs_core::OrderType;
5use serde::{Deserialize, Deserializer, Serialize};
6
7use crate::{Literal, ScalarType, ValueType, validate_id};
8
9pub const MAX_SOURCES: usize = 32;
10pub const MAX_COMPLETED_BARS: usize = 32;
11pub const MAX_MATERIALS: usize = 128;
12pub const MAX_MATERIAL_INPUTS: usize = 16;
13pub const MAX_MATERIAL_ARGS: usize = 32;
14pub const MAX_PARAMETERS: usize = 64;
15pub const MAX_PARAMETER_OPTIONS: usize = 64;
16pub const MAX_MATERIAL_LOOKBACK: usize = 4096;
17pub const MAX_MATERIAL_STATE_BYTES: usize = 65_536;
18pub const MAX_STATES: usize = 64;
19pub const MAX_VARIABLES: usize = 64;
20pub const MAX_TRANSITIONS: usize = 64;
21pub const MAX_ACTIONS: usize = 16;
22pub const MAX_ASSIGNMENTS: usize = 32;
23pub const MAX_ENTRY_TARGETS: usize = 16;
24pub const MAX_NOTES: usize = 16;
25pub const MAX_NAMED_VALUES: usize = 32;
26pub const MAX_EXPR_NODES: usize = 512;
27pub const MAX_EXPR_DEPTH: usize = 32;
28pub const MAX_LEGS: usize = 16;
29pub const MAX_OUTPUT_COMMANDS: usize = 16;
30pub const MAX_OUTPUT_NOTES: usize = 16;
31pub const MAX_COMMAND_CORRELATIONS: usize = 64;
32pub const MAX_PENDING_FEEDBACK: usize = 64;
33
34/// Bounded logical completed-bar source identity.
35#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
36#[serde(transparent)]
37pub struct SourceId(String);
38
39impl SourceId {
40    pub fn new(value: impl Into<String>) -> Result<Self, String> {
41        let value = value.into();
42        validate_id(&value)?;
43        Ok(Self(value))
44    }
45
46    pub fn as_str(&self) -> &str {
47        &self.0
48    }
49}
50
51impl fmt::Display for SourceId {
52    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53        formatter.write_str(&self.0)
54    }
55}
56
57impl<'de> Deserialize<'de> for SourceId {
58    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
59    where
60        D: Deserializer<'de>,
61    {
62        let value = String::deserialize(deserializer)?;
63        Self::new(value).map_err(serde::de::Error::custom)
64    }
65}
66
67/// Strict configured strategy document.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct StrategyConfig {
71    pub strategy_id: String,
72    pub title: String,
73    #[serde(default)]
74    pub parameters: Vec<ParameterConfig>,
75    pub initial_state: String,
76    pub sources: Vec<SourceId>,
77    pub trade_slots: Vec<String>,
78    #[serde(default)]
79    pub materials: Vec<MaterialConfig>,
80    #[serde(default)]
81    pub variables: Vec<VariableConfig>,
82    pub states: Vec<StateConfig>,
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct MaterialConfig {
88    pub id: String,
89    pub key: String,
90    #[serde(default)]
91    pub inputs: Vec<Expr>,
92    #[serde(default)]
93    pub params: MaterialArgs,
94}
95
96/// One typed material argument or a reference substituted while binding a template.
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98#[serde(
99    tag = "type",
100    content = "value",
101    rename_all = "snake_case",
102    deny_unknown_fields
103)]
104pub enum MaterialArg {
105    Integer(i64),
106    Number(f64),
107    Source(SourceId),
108    Slot(String),
109    BarField(BarField),
110    ActionKind(ConfiguredActionKind),
111    Param(String),
112}
113
114/// Strict named arguments accepted by a material factory.
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
116#[serde(transparent)]
117pub struct MaterialArgs(pub BTreeMap<String, MaterialArg>);
118
119impl MaterialArgs {
120    pub fn new(values: impl IntoIterator<Item = (impl Into<String>, MaterialArg)>) -> Self {
121        Self(
122            values
123                .into_iter()
124                .map(|(name, value)| (name.into(), value))
125                .collect(),
126        )
127    }
128
129    pub fn get(&self, name: &str) -> Option<&MaterialArg> {
130        self.0.get(name)
131    }
132
133    pub fn is_empty(&self) -> bool {
134        self.0.is_empty()
135    }
136
137    pub fn len(&self) -> usize {
138        self.0.len()
139    }
140
141    pub fn iter(&self) -> impl Iterator<Item = (&String, &MaterialArg)> {
142        self.0.iter()
143    }
144}
145
146/// Compatibility builder for Rust callers migrating from the original closed parameter enum.
147#[derive(Debug, Clone, PartialEq, Default)]
148pub enum MaterialParams {
149    #[default]
150    None,
151    BarField {
152        source: SourceId,
153        field: BarField,
154    },
155    Ema {
156        period: u16,
157    },
158    Atr {
159        source: SourceId,
160        period: u16,
161    },
162    Position {
163        slot: String,
164    },
165    Feedback {
166        slot: String,
167        action: ConfiguredActionKind,
168    },
169}
170
171impl From<MaterialParams> for MaterialArgs {
172    fn from(value: MaterialParams) -> Self {
173        match value {
174            MaterialParams::None => Self::default(),
175            MaterialParams::BarField { source, field } => Self::new([
176                ("source", MaterialArg::Source(source)),
177                ("field", MaterialArg::BarField(field)),
178            ]),
179            MaterialParams::Ema { period } => {
180                Self::new([("period", MaterialArg::Integer(i64::from(period)))])
181            }
182            MaterialParams::Atr { source, period } => Self::new([
183                ("source", MaterialArg::Source(source)),
184                ("period", MaterialArg::Integer(i64::from(period))),
185            ]),
186            MaterialParams::Position { slot } => Self::new([("slot", MaterialArg::Slot(slot))]),
187            MaterialParams::Feedback { slot, action } => Self::new([
188                ("slot", MaterialArg::Slot(slot)),
189                ("action", MaterialArg::ActionKind(action)),
190            ]),
191        }
192    }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
197pub enum ParameterKind {
198    Integer,
199    Number,
200    Choice { options: Vec<String> },
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct ParameterConfig {
206    pub id: String,
207    pub kind: ParameterKind,
208}
209
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211#[serde(
212    tag = "type",
213    content = "value",
214    rename_all = "snake_case",
215    deny_unknown_fields
216)]
217pub enum ParameterValue {
218    Integer(i64),
219    Number(f64),
220    Choice(String),
221}
222
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
224#[serde(transparent)]
225pub struct ParameterBinding(pub BTreeMap<String, ParameterValue>);
226
227impl ParameterBinding {
228    pub fn new(values: impl IntoIterator<Item = (impl Into<String>, ParameterValue)>) -> Self {
229        Self(
230            values
231                .into_iter()
232                .map(|(name, value)| (name.into(), value))
233                .collect(),
234        )
235    }
236
237    pub fn get(&self, name: &str) -> Option<&ParameterValue> {
238        self.0.get(name)
239    }
240
241    pub fn iter(&self) -> impl Iterator<Item = (&String, &ParameterValue)> {
242        self.0.iter()
243    }
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "snake_case")]
248pub enum BarField {
249    Open,
250    High,
251    Low,
252    Close,
253    Volume,
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(rename_all = "snake_case")]
258pub enum PositionField {
259    Exists,
260    IsPending,
261    IsOpen,
262    EntryPrice,
263    Side,
264    RemainingSize,
265    Stoploss,
266    OpenedAt,
267    FavorableExcursion,
268    AdverseExcursion,
269    InitialRisk,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "snake_case")]
274pub enum FeedbackField {
275    EntryFilled,
276    EntryRejected,
277    PositionClosed,
278    CancellationApplied,
279    CancellationRejected,
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "snake_case")]
284pub enum ConfiguredActionKind {
285    Entry,
286    Close,
287    ClosePartial,
288    MoveStoplossToEntry,
289    ModifyStoploss,
290    CancelPending,
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(rename_all = "snake_case")]
295pub enum DecisionKind {
296    Entry,
297    Management,
298    Exit,
299    Observation,
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "snake_case")]
304pub enum NoteKind {
305    Observation,
306    Risk,
307    Execution,
308    Lifecycle,
309}
310
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312#[serde(deny_unknown_fields)]
313pub struct VariableConfig {
314    pub id: String,
315    pub value_type: ValueType,
316    pub initial: Literal,
317}
318
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
320#[serde(deny_unknown_fields)]
321pub struct StateConfig {
322    pub id: String,
323    #[serde(default)]
324    pub transitions: Vec<TransitionConfig>,
325}
326
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct TransitionConfig {
330    pub priority: i32,
331    pub target: String,
332    pub when: Expr,
333    #[serde(default)]
334    pub assignments: Vec<AssignmentConfig>,
335    pub decision: Option<DecisionTemplate>,
336    #[serde(default)]
337    pub actions: Vec<ActionTemplate>,
338    #[serde(default)]
339    pub notes: Vec<NoteTemplate>,
340}
341
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343#[serde(deny_unknown_fields)]
344pub struct AssignmentConfig {
345    pub variable: String,
346    pub value: Expr,
347}
348
349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
350#[serde(deny_unknown_fields)]
351pub struct DecisionTemplate {
352    pub kind: DecisionKind,
353    pub reason: String,
354    pub trade_slot: Option<String>,
355    #[serde(default)]
356    pub values: Vec<NamedExpr>,
357}
358
359#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
360#[serde(deny_unknown_fields)]
361pub struct NoteTemplate {
362    pub kind: NoteKind,
363    pub reason: String,
364    pub trade_slot: Option<String>,
365    #[serde(default)]
366    pub values: Vec<NamedExpr>,
367}
368
369#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
370#[serde(deny_unknown_fields)]
371pub struct NamedExpr {
372    pub name: String,
373    pub value: Expr,
374}
375
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
377#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
378pub enum Expr {
379    Literal {
380        value: Literal,
381    },
382    Variable {
383        id: String,
384    },
385    Material {
386        id: String,
387    },
388    Param {
389        id: String,
390    },
391    Select {
392        param: String,
393        cases: BTreeMap<String, Expr>,
394    },
395    Input {
396        field: String,
397        value_type: ValueType,
398    },
399    Bar {
400        source: SourceId,
401        field: BarField,
402    },
403    Position {
404        slot: String,
405        field: PositionField,
406    },
407    Feedback {
408        slot: String,
409        action: ConfiguredActionKind,
410        field: FeedbackField,
411    },
412    InputTime,
413    Readiness,
414    Eq {
415        left: Box<Expr>,
416        right: Box<Expr>,
417    },
418    Ne {
419        left: Box<Expr>,
420        right: Box<Expr>,
421    },
422    Lt {
423        left: Box<Expr>,
424        right: Box<Expr>,
425    },
426    Le {
427        left: Box<Expr>,
428        right: Box<Expr>,
429    },
430    Gt {
431        left: Box<Expr>,
432        right: Box<Expr>,
433    },
434    Ge {
435        left: Box<Expr>,
436        right: Box<Expr>,
437    },
438    All {
439        items: Vec<Expr>,
440    },
441    Any {
442        items: Vec<Expr>,
443    },
444    Not {
445        value: Box<Expr>,
446    },
447    /// Evaluate comparisons and Boolean operators with strict Missing propagation.
448    Strict {
449        value: Box<Expr>,
450    },
451    Add {
452        left: Box<Expr>,
453        right: Box<Expr>,
454    },
455    Sub {
456        left: Box<Expr>,
457        right: Box<Expr>,
458    },
459    Mul {
460        left: Box<Expr>,
461        right: Box<Expr>,
462    },
463    Div {
464        left: Box<Expr>,
465        right: Box<Expr>,
466    },
467    Min {
468        left: Box<Expr>,
469        right: Box<Expr>,
470    },
471    Max {
472        left: Box<Expr>,
473        right: Box<Expr>,
474    },
475    Abs {
476        value: Box<Expr>,
477    },
478    IsPresent {
479        value: Box<Expr>,
480    },
481    IsMissing {
482        value: Box<Expr>,
483    },
484}
485
486#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
487#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
488pub enum ActionTemplate {
489    Entry {
490        slot: String,
491        side: Expr,
492        order_type: OrderType,
493        price: Expr,
494        risk: Expr,
495        stoploss: Expr,
496        #[serde(default)]
497        targets: Vec<Expr>,
498        /// Optional exact class the consuming adapter uses to route the entry to a management profile.
499        #[serde(default, skip_serializing_if = "Option::is_none")]
500        entry_class: Option<String>,
501    },
502    Close {
503        slot: String,
504    },
505    ClosePartial {
506        slot: String,
507        ratio: Expr,
508    },
509    MoveStoplossToEntry {
510        slot: String,
511    },
512    ModifyStoploss {
513        slot: String,
514        price: Expr,
515    },
516    CancelPending {
517        slot: String,
518    },
519}
520
521pub(crate) fn bar_field_type(field: BarField) -> ValueType {
522    match field {
523        BarField::Volume => ValueType::optional(ScalarType::Number),
524        _ => ValueType::optional(ScalarType::Price),
525    }
526}