Skip to main content

qs_strategy/
descriptor.rs

1use crate::{BarField, SourceId, ValueType};
2
3/// Semantic unit carried by a named numeric or predicate output.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5#[non_exhaustive]
6pub enum NumericUnit {
7    Bool,
8    Number,
9    Price,
10    Ratio,
11    Percent,
12    BarCount,
13    PricePerObservation,
14    PricePerObservationSquared,
15    RatioPerObservation,
16    RatioPerObservationSquared,
17    LogReturn,
18    LogReturnVariance,
19}
20
21/// Declared input shape. Factories validate only the inputs needed by their calculation.
22#[derive(Debug, Clone, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum NumericInputs {
25    Scalar(Vec<ValueType>),
26    CompletedBarFields(&'static [BarField]),
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum BarShapeCalculation {
32    BodySignedAtr,
33    BodyAbsAtr,
34    BodyFraction,
35    BodyDirectionFraction,
36    UpperWickFraction,
37    LowerWickFraction,
38    ClosePosition,
39    CloseLocationValue,
40    RangeAtr,
41    GapAtr,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum PriceChangeCalculation {
47    LogReturn,
48    Roc,
49    MoveAtr,
50    HighChangeAtr,
51    LowChangeAtr,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[non_exhaustive]
56pub enum PriceInputCalculation {
57    Close,
58    Hl2,
59    Hlc3,
60    Ohlc4,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64#[non_exhaustive]
65pub enum MovingAverageCalculation {
66    Sma,
67    Ema,
68    Rma,
69    Wma,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73#[non_exhaustive]
74pub enum RecursiveCalculation {
75    Hma,
76    Kama,
77    KamaSmoothingConstant,
78    KeltnerMiddle,
79    KeltnerUpper,
80    KeltnerLower,
81    BollingerKeltnerSqueeze,
82    SuperTrendLevel,
83    SuperTrendDirection,
84    HeikinAshiOpen,
85    HeikinAshiHigh,
86    HeikinAshiLow,
87    HeikinAshiClose,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91#[non_exhaustive]
92pub enum NormalizedCalculation {
93    BodySignedAtr,
94    BodyAbsAtr,
95    RangeAtr,
96    GapAtr,
97    MoveAtr,
98    HighChangeAtr,
99    LowChangeAtr,
100    DonchianUpperDistanceAtr,
101    DonchianLowerDistanceAtr,
102    MacdAtr,
103    MacdSignalAtr,
104    MacdHistogramAtr,
105    MaDistanceAtr,
106    MaSlopeAtr,
107    MaAccelerationAtr,
108    MaGapAtr,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112#[non_exhaustive]
113pub enum StatisticalCalculation {
114    DonchianUpper,
115    DonchianLower,
116    ChannelWidth,
117    ChannelMidDistance,
118    RangePosition,
119    PreviousRangePosition,
120    ZScore,
121    BollingerUpper,
122    BollingerLower,
123    BollingerPercentB,
124    BollingerWidth,
125    RollingMedian,
126    MedianDeviation,
127    RealizedVariance,
128    RealizedVolatility,
129    ReturnRms,
130    ReturnStdDev,
131    EwmaVolatility,
132    PositiveSemivariance,
133    NegativeSemivariance,
134    VolatilityAsymmetry,
135    HistoricalPercentile,
136    EfficiencyRatio,
137    RegressionSlope,
138    RegressionR2,
139    RegressionResidualRms,
140    RegressionDeviation,
141    RollingHighAge,
142    RollingLowAge,
143    AroonUp,
144    AroonDown,
145    Choppiness,
146    ReturnAutocorrelation,
147    RangeExpansion,
148    AtrPercent,
149    AtrRatio,
150    AtrChange,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154#[non_exhaustive]
155pub enum MomentumCalculation {
156    WilderRsi,
157    RsiChange,
158    StochasticFastK,
159    StochasticSlowK,
160    StochasticSlowD,
161    Macd,
162    MacdSignal,
163    MacdHistogram,
164    Cci,
165    PlusDi,
166    MinusDi,
167    DiDifference,
168    Dx,
169    Adx,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173#[non_exhaustive]
174pub enum MaDerivativeCalculation {
175    Distance,
176    Slope,
177    Acceleration,
178    Gap,
179    Alignment,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183#[non_exhaustive]
184pub enum BarStructureCalculation {
185    InsideBar,
186    OutsideBar,
187    BodyEngulfing,
188    EngulfSizeRatio,
189    NarrowRange,
190    WideRange,
191    RelativeRange,
192    BarOverlap,
193    ThreeBarGapUp,
194    ThreeBarGapDown,
195}
196
197/// The complete effective calculation, including bounded lengths and recursive weights.
198#[derive(Debug, Clone, PartialEq)]
199#[non_exhaustive]
200pub enum NumericCalculation {
201    ObservedSma {
202        period: usize,
203    },
204    SmaSeededEma {
205        period: usize,
206        alpha: f64,
207    },
208    BarShape(BarShapeCalculation),
209    PriceChange {
210        calculation: PriceChangeCalculation,
211        horizon: usize,
212    },
213    BarStructure {
214        calculation: BarStructureCalculation,
215        period: Option<usize>,
216    },
217    PriceInput(PriceInputCalculation),
218    MovingAverage {
219        calculation: MovingAverageCalculation,
220        period: usize,
221    },
222    TrueRange,
223    StrictAtr {
224        period: usize,
225    },
226    MaDerivative {
227        calculation: MaDerivativeCalculation,
228        horizon: Option<usize>,
229    },
230    Momentum {
231        calculation: MomentumCalculation,
232        periods: [usize; 3],
233    },
234    Statistical {
235        calculation: StatisticalCalculation,
236        periods: [usize; 3],
237        parameter: Option<u64>,
238    },
239    Recursive {
240        calculation: RecursiveCalculation,
241        periods: [usize; 4],
242        parameters: [u64; 2],
243    },
244    Normalized {
245        calculation: NormalizedCalculation,
246        atr_period: usize,
247        horizon: usize,
248        current_atr: bool,
249    },
250    NormalizedPair {
251        calculation: NormalizedCalculation,
252    },
253}
254
255/// Behavior on an observed Missing input, distinct from an idle source clock.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257#[non_exhaustive]
258pub enum NumericMissingPolicy {
259    ConsumeWindowSlot,
260    ResetAndReseed,
261}
262
263#[derive(Debug, Clone, Copy, PartialEq)]
264#[non_exhaustive]
265pub enum NumericRange {
266    Unbounded,
267    Inclusive { minimum: f64, maximum: f64 },
268}
269
270/// A working factory's effective numeric contract, inspected before state allocation.
271#[derive(Debug, Clone, PartialEq)]
272#[non_exhaustive]
273pub struct NumericDescriptor {
274    pub calculation: NumericCalculation,
275    pub source_clock: SourceId,
276    pub inputs: NumericInputs,
277    pub output_type: ValueType,
278    pub unit: NumericUnit,
279    pub range: NumericRange,
280    pub missing: NumericMissingPolicy,
281    /// Source observations required for the first output after initialization or reset.
282    pub first_output_observations: usize,
283    /// Effective source lookback after composing direct inputs and calculation history.
284    pub required_lookback: usize,
285    /// Upper bound on one evaluator's owned state, including fixed backing storage.
286    pub max_state_bytes: usize,
287    pub exact_aliases: &'static [&'static str],
288}
289
290impl NumericDescriptor {
291    pub(crate) fn validate(&self, inputs: &[ValueType]) -> Result<(), String> {
292        if self.first_output_observations == 0
293            || self.required_lookback < self.first_output_observations
294            || self.max_state_bytes > crate::MAX_MATERIAL_STATE_BYTES
295        {
296            return Err("numeric descriptor has an invalid history or state bound".into());
297        }
298        match &self.inputs {
299            NumericInputs::Scalar(expected) if expected == inputs => {}
300            NumericInputs::CompletedBarFields(fields)
301                if inputs.is_empty() && !fields.is_empty() => {}
302            _ => return Err("numeric descriptor disagrees with its material inputs".into()),
303        }
304        let scalar = self.output_type.scalar;
305        let unit_matches = match self.unit {
306            NumericUnit::Bool => scalar == crate::ScalarType::Bool,
307            NumericUnit::BarCount => scalar == crate::ScalarType::Integer,
308            NumericUnit::Price => scalar == crate::ScalarType::Price,
309            NumericUnit::Ratio => scalar == crate::ScalarType::Ratio,
310            NumericUnit::Percent => scalar == crate::ScalarType::Percent,
311            NumericUnit::PricePerObservation => scalar == crate::ScalarType::PricePerObservation,
312            NumericUnit::PricePerObservationSquared => {
313                scalar == crate::ScalarType::PricePerObservationSquared
314            }
315            NumericUnit::RatioPerObservation => scalar == crate::ScalarType::RatioPerObservation,
316            NumericUnit::RatioPerObservationSquared => {
317                scalar == crate::ScalarType::RatioPerObservationSquared
318            }
319            NumericUnit::LogReturn => scalar == crate::ScalarType::LogReturn,
320            NumericUnit::LogReturnVariance => scalar == crate::ScalarType::LogReturnVariance,
321            NumericUnit::Number => scalar == crate::ScalarType::Number,
322        };
323        if !unit_matches {
324            return Err("numeric descriptor unit disagrees with its output scalar".into());
325        }
326        if let NumericRange::Inclusive { minimum, maximum } = self.range
327            && (!minimum.is_finite() || !maximum.is_finite() || minimum > maximum)
328        {
329            return Err("numeric descriptor has an invalid range".into());
330        }
331        match self.calculation {
332            NumericCalculation::ObservedSma { period } => {
333                self.validate_average(inputs, period, NumericMissingPolicy::ConsumeWindowSlot)?;
334            }
335            NumericCalculation::SmaSeededEma { period, alpha } => {
336                if alpha != 2.0 / (period as f64 + 1.0) {
337                    return Err("numeric descriptor has an inconsistent EMA weight".into());
338                }
339                self.validate_average(inputs, period, NumericMissingPolicy::ResetAndReseed)?;
340            }
341            NumericCalculation::BarShape(_)
342            | NumericCalculation::PriceChange { .. }
343            | NumericCalculation::BarStructure { .. }
344            | NumericCalculation::PriceInput(_)
345            | NumericCalculation::TrueRange
346            | NumericCalculation::StrictAtr { .. } => {
347                if !matches!(self.inputs, NumericInputs::CompletedBarFields(_)) {
348                    return Err("bar calculation requires completed-bar fields".into());
349                }
350            }
351            NumericCalculation::MovingAverage { period, .. } => {
352                self.validate_average(inputs, period, self.missing)?;
353            }
354            NumericCalculation::MaDerivative { .. } => {
355                if !matches!(self.inputs, NumericInputs::Scalar(_)) {
356                    return Err("MA derivative requires scalar material inputs".into());
357                }
358            }
359            NumericCalculation::Momentum { .. }
360            | NumericCalculation::Statistical { .. }
361            | NumericCalculation::Recursive { .. }
362            | NumericCalculation::Normalized { .. }
363            | NumericCalculation::NormalizedPair { .. } => {}
364        }
365        Ok(())
366    }
367
368    fn validate_average(
369        &self,
370        inputs: &[ValueType],
371        period: usize,
372        missing: NumericMissingPolicy,
373    ) -> Result<(), String> {
374        if !(1..=crate::numeric::MAX_STRICT_PERIOD).contains(&period)
375            || inputs.len() != 1
376            || !matches!(
377                inputs[0].scalar,
378                crate::ScalarType::Number
379                    | crate::ScalarType::Price
380                    | crate::ScalarType::Ratio
381                    | crate::ScalarType::Percent
382                    | crate::ScalarType::PricePerObservation
383                    | crate::ScalarType::PricePerObservationSquared
384                    | crate::ScalarType::RatioPerObservation
385                    | crate::ScalarType::RatioPerObservationSquared
386                    | crate::ScalarType::LogReturn
387                    | crate::ScalarType::LogReturnVariance
388            )
389            || self.output_type != ValueType::optional(inputs[0].scalar)
390            || self.missing != missing
391            || self.first_output_observations != period
392            || self.required_lookback != period
393        {
394            return Err("numeric average descriptor is inconsistent".into());
395        }
396        Ok(())
397    }
398
399    pub const fn flat_input_is_defined(&self) -> bool {
400        matches!(
401            self.calculation,
402            NumericCalculation::ObservedSma { .. } | NumericCalculation::SmaSeededEma { .. }
403        )
404    }
405}