Skip to main content

qs_core/
validation.rs

1//! Shared raw-signal validation.
2//!
3//! `RawSignal` is owned by this crate, so its semantic validation belongs here
4//! too. Before this module existed the checks were duplicated: `qs-signal-parser`
5//! held the full set behind a crate-private function and `qs-backtest-server`
6//! re-implemented a narrower subset with a different error type. Neither was
7//! reachable by other consumers, and the two could drift.
8//!
9//! The engine remains the hard backstop for the invariants it owns (a partial
10//! close ratio outside `(0, 1]` is rejected at apply time regardless of what
11//! reaches it). This module exists so a caller can reject a bad signal *early*,
12//! with a useful reason, on every entry path rather than only on the parser path.
13//!
14//! # Message stability
15//!
16//! The `Display` text of [`RawSignalValidationError`] is reproduced verbatim in
17//! committed parser outcome goldens through `ParseFailure::InvalidSignal`.
18//! Changing a message rewrites those goldens, so treat these strings as part of
19//! the observable contract and not as free-form diagnostics.
20
21use crate::profile::RawSignal;
22use crate::types::{OrderType, Side};
23
24/// A raw signal that violates the common signal contract.
25#[derive(Debug, Clone, PartialEq, thiserror::Error)]
26pub enum RawSignalValidationError {
27    #[error("entry risk multiplier must be finite and positive, got {value}")]
28    EntryRisk { value: f64 },
29
30    #[error("{order_type} entry requires a finite positive price")]
31    EntryPriceRequired { order_type: OrderType },
32
33    #[error("entry price must be finite and positive, got {value}")]
34    EntryPrice { value: f64 },
35
36    #[error("stoploss is not protective for the entry side")]
37    StoplossNotProtective,
38
39    #[error("target is on the wrong side of entry")]
40    TargetWrongSide,
41
42    #[error("partial close ratio must be in (0, 1], got {value}")]
43    PartialCloseRatio { value: f64 },
44
45    #[error("management price must be finite and positive, got {value}")]
46    ManagementPrice { value: f64 },
47
48    #[error("target prices must be finite and positive, got {old_price} -> {new_price}")]
49    TargetPricePair { old_price: f64, new_price: f64 },
50
51    #[error("scale-in size/price is invalid")]
52    ScaleIn,
53}
54
55/// Validate one raw signal against the common signal contract.
56///
57/// Entry geometry is only checked when the entry price is known, because a
58/// market Entry may legitimately omit it; the stop and target side rules have no
59/// reference point without it.
60pub fn validate_raw_signal(signal: &RawSignal) -> Result<(), RawSignalValidationError> {
61    match signal {
62        RawSignal::Entry {
63            side,
64            order_type,
65            price,
66            risk_multiplier,
67            stoploss,
68            targets,
69            ..
70        } => {
71            if !risk_multiplier.is_finite() || *risk_multiplier <= 0.0 {
72                return Err(RawSignalValidationError::EntryRisk {
73                    value: *risk_multiplier,
74                });
75            }
76            if matches!(order_type, OrderType::Limit | OrderType::Stop)
77                && !price.is_some_and(|value| value.is_finite() && value > 0.0)
78            {
79                return Err(RawSignalValidationError::EntryPriceRequired {
80                    order_type: *order_type,
81                });
82            }
83            if let Some(entry) = price {
84                if !entry.is_finite() || *entry <= 0.0 {
85                    return Err(RawSignalValidationError::EntryPrice { value: *entry });
86                }
87                if let Some(stop) = stoploss {
88                    let protective = stop.is_finite()
89                        && *stop > 0.0
90                        && match side {
91                            Side::Buy => *stop < *entry,
92                            Side::Sell => *stop > *entry,
93                        };
94                    if !protective {
95                        return Err(RawSignalValidationError::StoplossNotProtective);
96                    }
97                }
98                for target in targets {
99                    let valid = target.is_finite()
100                        && *target > 0.0
101                        && match side {
102                            Side::Buy => *target > *entry,
103                            Side::Sell => *target < *entry,
104                        };
105                    if !valid {
106                        return Err(RawSignalValidationError::TargetWrongSide);
107                    }
108                }
109            }
110        }
111        RawSignal::ClosePartial { ratio, .. } => {
112            if !ratio.is_finite() || *ratio <= 0.0 || *ratio > 1.0 {
113                return Err(RawSignalValidationError::PartialCloseRatio { value: *ratio });
114            }
115        }
116        RawSignal::ModifyStoploss { price, .. }
117        | RawSignal::AddTarget { price, .. }
118        | RawSignal::RemoveTarget { price, .. }
119        | RawSignal::ModifyAllStoploss { price, .. }
120        | RawSignal::ModifyAllStoplossInGroup { price, .. } => {
121            if !price.is_finite() || *price <= 0.0 {
122                return Err(RawSignalValidationError::ManagementPrice { value: *price });
123            }
124        }
125        RawSignal::ModifyTarget {
126            old_price,
127            new_price,
128            ..
129        } => {
130            if !old_price.is_finite()
131                || *old_price <= 0.0
132                || !new_price.is_finite()
133                || *new_price <= 0.0
134            {
135                return Err(RawSignalValidationError::TargetPricePair {
136                    old_price: *old_price,
137                    new_price: *new_price,
138                });
139            }
140        }
141        RawSignal::ScaleIn { size, price, .. } => {
142            if !size.is_finite()
143                || *size <= 0.0
144                || price.is_some_and(|value| !value.is_finite() || value <= 0.0)
145            {
146                return Err(RawSignalValidationError::ScaleIn);
147            }
148        }
149        RawSignal::Close { .. }
150        | RawSignal::MoveStoplossToEntry { .. }
151        | RawSignal::AddRule { .. }
152        | RawSignal::RemoveRule { .. }
153        | RawSignal::CancelPending { .. }
154        | RawSignal::CloseAllOf { .. }
155        | RawSignal::CloseAll { .. }
156        | RawSignal::CancelAllPending { .. }
157        | RawSignal::CloseAllInGroup { .. } => {}
158    }
159    Ok(())
160}
161
162/// Validate a batch, returning the first violation in slice order.
163pub fn validate_raw_signals(signals: &[RawSignal]) -> Result<(), RawSignalValidationError> {
164    signals.iter().try_for_each(validate_raw_signal)
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::profile::PositionRef;
171    use chrono::NaiveDate;
172
173    fn ts() -> chrono::NaiveDateTime {
174        NaiveDate::from_ymd_opt(2026, 3, 10)
175            .unwrap()
176            .and_hms_opt(10, 0, 0)
177            .unwrap()
178    }
179
180    fn entry(
181        side: Side,
182        order_type: OrderType,
183        price: Option<f64>,
184        risk: f64,
185        stoploss: Option<f64>,
186        targets: Vec<f64>,
187    ) -> RawSignal {
188        RawSignal::Entry {
189            ts: ts(),
190            symbol: "xauusd".into(),
191            side,
192            order_type,
193            price,
194            risk_multiplier: risk,
195            stoploss,
196            targets,
197            group: None,
198            trade_id: None,
199        }
200    }
201
202    fn any_position() -> PositionRef {
203        PositionRef::AllOnSymbol {
204            symbol: "xauusd".into(),
205        }
206    }
207
208    #[test]
209    fn valid_market_entry_passes() {
210        let signal = entry(
211            Side::Buy,
212            OrderType::Market,
213            Some(2000.0),
214            1.0,
215            Some(1990.0),
216            vec![2010.0, 2020.0],
217        );
218        assert_eq!(validate_raw_signal(&signal), Ok(()));
219    }
220
221    #[test]
222    fn market_entry_without_price_skips_geometry() {
223        // A market Entry may omit price; stop/target side rules need a reference
224        // point, so they must not fire.
225        let signal = entry(
226            Side::Buy,
227            OrderType::Market,
228            None,
229            1.0,
230            Some(9999.0),
231            vec![1.0],
232        );
233        assert_eq!(validate_raw_signal(&signal), Ok(()));
234    }
235
236    #[test]
237    fn non_finite_and_non_positive_risk_are_rejected() {
238        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
239            let signal = entry(Side::Buy, OrderType::Market, None, bad, None, vec![]);
240            assert!(matches!(
241                validate_raw_signal(&signal),
242                Err(RawSignalValidationError::EntryRisk { .. })
243            ));
244        }
245    }
246
247    #[test]
248    fn limit_and_stop_entries_require_a_price() {
249        for order_type in [OrderType::Limit, OrderType::Stop] {
250            let signal = entry(Side::Buy, order_type, None, 1.0, None, vec![]);
251            assert!(matches!(
252                validate_raw_signal(&signal),
253                Err(RawSignalValidationError::EntryPriceRequired { .. })
254            ));
255        }
256    }
257
258    #[test]
259    fn non_positive_entry_price_is_rejected() {
260        let signal = entry(Side::Buy, OrderType::Market, Some(0.0), 1.0, None, vec![]);
261        assert!(matches!(
262            validate_raw_signal(&signal),
263            Err(RawSignalValidationError::EntryPrice { .. })
264        ));
265    }
266
267    #[test]
268    fn stoploss_must_be_protective_for_each_side() {
269        let buy = entry(
270            Side::Buy,
271            OrderType::Market,
272            Some(2000.0),
273            1.0,
274            Some(2010.0),
275            vec![],
276        );
277        let sell = entry(
278            Side::Sell,
279            OrderType::Market,
280            Some(2000.0),
281            1.0,
282            Some(1990.0),
283            vec![],
284        );
285        for signal in [buy, sell] {
286            assert_eq!(
287                validate_raw_signal(&signal),
288                Err(RawSignalValidationError::StoplossNotProtective)
289            );
290        }
291    }
292
293    #[test]
294    fn targets_must_be_on_the_profitable_side() {
295        let buy = entry(
296            Side::Buy,
297            OrderType::Market,
298            Some(2000.0),
299            1.0,
300            None,
301            vec![1990.0],
302        );
303        let sell = entry(
304            Side::Sell,
305            OrderType::Market,
306            Some(2000.0),
307            1.0,
308            None,
309            vec![2010.0],
310        );
311        for signal in [buy, sell] {
312            assert_eq!(
313                validate_raw_signal(&signal),
314                Err(RawSignalValidationError::TargetWrongSide)
315            );
316        }
317    }
318
319    #[test]
320    fn partial_close_ratio_bounds_are_inclusive_at_one() {
321        let ok = RawSignal::ClosePartial {
322            ts: ts(),
323            position: any_position(),
324            ratio: 1.0,
325        };
326        assert_eq!(validate_raw_signal(&ok), Ok(()));
327
328        for bad in [0.0, -0.5, 1.000_001, f64::NAN] {
329            let signal = RawSignal::ClosePartial {
330                ts: ts(),
331                position: any_position(),
332                ratio: bad,
333            };
334            assert!(matches!(
335                validate_raw_signal(&signal),
336                Err(RawSignalValidationError::PartialCloseRatio { .. })
337            ));
338        }
339    }
340
341    #[test]
342    fn management_prices_must_be_finite_positive() {
343        let signal = RawSignal::ModifyStoploss {
344            ts: ts(),
345            position: any_position(),
346            price: -1.0,
347        };
348        assert!(matches!(
349            validate_raw_signal(&signal),
350            Err(RawSignalValidationError::ManagementPrice { .. })
351        ));
352    }
353
354    #[test]
355    fn modify_target_rejects_either_bad_price() {
356        for (old, new) in [(0.0, 2010.0), (2000.0, f64::NAN)] {
357            let signal = RawSignal::ModifyTarget {
358                ts: ts(),
359                position: any_position(),
360                old_price: old,
361                new_price: new,
362            };
363            assert!(matches!(
364                validate_raw_signal(&signal),
365                Err(RawSignalValidationError::TargetPricePair { .. })
366            ));
367        }
368    }
369
370    #[test]
371    fn scale_in_rejects_bad_size_or_price_but_allows_absent_price() {
372        let ok = RawSignal::ScaleIn {
373            ts: ts(),
374            position: any_position(),
375            price: None,
376            size: 0.5,
377        };
378        assert_eq!(validate_raw_signal(&ok), Ok(()));
379
380        let bad_size = RawSignal::ScaleIn {
381            ts: ts(),
382            position: any_position(),
383            price: None,
384            size: 0.0,
385        };
386        let bad_price = RawSignal::ScaleIn {
387            ts: ts(),
388            position: any_position(),
389            price: Some(-1.0),
390            size: 0.5,
391        };
392        for signal in [bad_size, bad_price] {
393            assert_eq!(
394                validate_raw_signal(&signal),
395                Err(RawSignalValidationError::ScaleIn)
396            );
397        }
398    }
399
400    #[test]
401    fn variants_without_numeric_payload_always_pass() {
402        let signals = vec![
403            RawSignal::Close {
404                ts: ts(),
405                position: any_position(),
406            },
407            RawSignal::MoveStoplossToEntry {
408                ts: ts(),
409                position: any_position(),
410            },
411            RawSignal::CancelPending {
412                ts: ts(),
413                position: any_position(),
414            },
415            RawSignal::CloseAll { ts: ts() },
416            RawSignal::CancelAllPending { ts: ts() },
417        ];
418        assert_eq!(validate_raw_signals(&signals), Ok(()));
419    }
420
421    #[test]
422    fn batch_reports_the_first_violation_in_slice_order() {
423        let signals = vec![
424            entry(
425                Side::Buy,
426                OrderType::Market,
427                Some(2000.0),
428                1.0,
429                None,
430                vec![],
431            ),
432            RawSignal::ClosePartial {
433                ts: ts(),
434                position: any_position(),
435                ratio: 2.0,
436            },
437            RawSignal::ModifyStoploss {
438                ts: ts(),
439                position: any_position(),
440                price: -1.0,
441            },
442        ];
443        assert!(matches!(
444            validate_raw_signals(&signals),
445            Err(RawSignalValidationError::PartialCloseRatio { .. })
446        ));
447    }
448
449    #[test]
450    fn messages_match_the_strings_embedded_in_parser_goldens() {
451        // These exact strings appear in committed outcome goldens through
452        // ParseFailure::InvalidSignal. Changing them rewrites those files.
453        assert_eq!(
454            RawSignalValidationError::TargetWrongSide.to_string(),
455            "target is on the wrong side of entry"
456        );
457        assert_eq!(
458            RawSignalValidationError::StoplossNotProtective.to_string(),
459            "stoploss is not protective for the entry side"
460        );
461        assert_eq!(
462            RawSignalValidationError::EntryRisk { value: 0.0 }.to_string(),
463            "entry risk multiplier must be finite and positive, got 0"
464        );
465        assert_eq!(
466            RawSignalValidationError::PartialCloseRatio { value: 2.0 }.to_string(),
467            "partial close ratio must be in (0, 1], got 2"
468        );
469        assert_eq!(
470            RawSignalValidationError::EntryPriceRequired {
471                order_type: OrderType::Limit
472            }
473            .to_string(),
474            "Limit entry requires a finite positive price"
475        );
476        assert_eq!(
477            RawSignalValidationError::ScaleIn.to_string(),
478            "scale-in size/price is invalid"
479        );
480        assert_eq!(
481            RawSignalValidationError::ManagementPrice { value: -1.0 }.to_string(),
482            "management price must be finite and positive, got -1"
483        );
484        assert_eq!(
485            RawSignalValidationError::TargetPricePair {
486                old_price: 1.0,
487                new_price: 2.0
488            }
489            .to_string(),
490            "target prices must be finite and positive, got 1 -> 2"
491        );
492    }
493}