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(
37        "entry class must be non-empty, trimmed, control-character-free, and at most 128 UTF-8 bytes"
38    )]
39    EntryClass,
40
41    #[error("stoploss is not protective for the entry side")]
42    StoplossNotProtective,
43
44    #[error("target is on the wrong side of entry")]
45    TargetWrongSide,
46
47    #[error("partial close ratio must be in (0, 1], got {value}")]
48    PartialCloseRatio { value: f64 },
49
50    #[error("management price must be finite and positive, got {value}")]
51    ManagementPrice { value: f64 },
52
53    #[error("target prices must be finite and positive, got {old_price} -> {new_price}")]
54    TargetPricePair { old_price: f64, new_price: f64 },
55
56    #[error("scale-in size/price is invalid")]
57    ScaleIn,
58}
59
60/// Validate one optional semantic entry class without normalizing it.
61pub fn validate_entry_class(value: &str) -> Result<(), RawSignalValidationError> {
62    if value.is_empty()
63        || value.trim() != value
64        || value.len() > 128
65        || value.chars().any(char::is_control)
66    {
67        Err(RawSignalValidationError::EntryClass)
68    } else {
69        Ok(())
70    }
71}
72
73/// Validate one raw signal against the common signal contract.
74///
75/// Entry geometry is only checked when the entry price is known, because a
76/// market Entry may legitimately omit it; the stop and target side rules have no
77/// reference point without it.
78pub fn validate_raw_signal(signal: &RawSignal) -> Result<(), RawSignalValidationError> {
79    match signal {
80        RawSignal::Entry {
81            side,
82            order_type,
83            price,
84            risk_multiplier,
85            stoploss,
86            targets,
87            entry_class,
88            ..
89        } => {
90            if !risk_multiplier.is_finite() || *risk_multiplier <= 0.0 {
91                return Err(RawSignalValidationError::EntryRisk {
92                    value: *risk_multiplier,
93                });
94            }
95            if let Some(entry_class) = entry_class {
96                validate_entry_class(entry_class)?;
97            }
98            if matches!(order_type, OrderType::Limit | OrderType::Stop)
99                && !price.is_some_and(|value| value.is_finite() && value > 0.0)
100            {
101                return Err(RawSignalValidationError::EntryPriceRequired {
102                    order_type: *order_type,
103                });
104            }
105            if let Some(entry) = price {
106                if !entry.is_finite() || *entry <= 0.0 {
107                    return Err(RawSignalValidationError::EntryPrice { value: *entry });
108                }
109                if let Some(stop) = stoploss {
110                    let protective = stop.is_finite()
111                        && *stop > 0.0
112                        && match side {
113                            Side::Buy => *stop < *entry,
114                            Side::Sell => *stop > *entry,
115                        };
116                    if !protective {
117                        return Err(RawSignalValidationError::StoplossNotProtective);
118                    }
119                }
120                for target in targets {
121                    let valid = target.is_finite()
122                        && *target > 0.0
123                        && match side {
124                            Side::Buy => *target > *entry,
125                            Side::Sell => *target < *entry,
126                        };
127                    if !valid {
128                        return Err(RawSignalValidationError::TargetWrongSide);
129                    }
130                }
131            }
132        }
133        RawSignal::ClosePartial { ratio, .. } => {
134            if !ratio.is_finite() || *ratio <= 0.0 || *ratio > 1.0 {
135                return Err(RawSignalValidationError::PartialCloseRatio { value: *ratio });
136            }
137        }
138        RawSignal::ModifyStoploss { price, .. }
139        | RawSignal::AddTarget { price, .. }
140        | RawSignal::RemoveTarget { price, .. }
141        | RawSignal::ModifyAllStoploss { price, .. }
142        | RawSignal::ModifyAllStoplossInGroup { price, .. } => {
143            if !price.is_finite() || *price <= 0.0 {
144                return Err(RawSignalValidationError::ManagementPrice { value: *price });
145            }
146        }
147        RawSignal::ModifyTarget {
148            old_price,
149            new_price,
150            ..
151        } => {
152            if !old_price.is_finite()
153                || *old_price <= 0.0
154                || !new_price.is_finite()
155                || *new_price <= 0.0
156            {
157                return Err(RawSignalValidationError::TargetPricePair {
158                    old_price: *old_price,
159                    new_price: *new_price,
160                });
161            }
162        }
163        RawSignal::ScaleIn { size, price, .. } => {
164            if !size.is_finite()
165                || *size <= 0.0
166                || price.is_some_and(|value| !value.is_finite() || value <= 0.0)
167            {
168                return Err(RawSignalValidationError::ScaleIn);
169            }
170        }
171        RawSignal::Close { .. }
172        | RawSignal::MoveStoplossToEntry { .. }
173        | RawSignal::AddRule { .. }
174        | RawSignal::RemoveRule { .. }
175        | RawSignal::CancelPending { .. }
176        | RawSignal::CloseAllOf { .. }
177        | RawSignal::CloseAll { .. }
178        | RawSignal::CancelAllPending { .. }
179        | RawSignal::CloseAllInGroup { .. } => {}
180    }
181    Ok(())
182}
183
184/// Validate a batch, returning the first violation in slice order.
185pub fn validate_raw_signals(signals: &[RawSignal]) -> Result<(), RawSignalValidationError> {
186    signals.iter().try_for_each(validate_raw_signal)
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::profile::PositionRef;
193    use chrono::NaiveDate;
194
195    fn ts() -> chrono::NaiveDateTime {
196        NaiveDate::from_ymd_opt(2026, 3, 10)
197            .unwrap()
198            .and_hms_opt(10, 0, 0)
199            .unwrap()
200    }
201
202    fn entry(
203        side: Side,
204        order_type: OrderType,
205        price: Option<f64>,
206        risk: f64,
207        stoploss: Option<f64>,
208        targets: Vec<f64>,
209    ) -> RawSignal {
210        RawSignal::Entry {
211            ts: ts(),
212            symbol: "xauusd".into(),
213            side,
214            order_type,
215            price,
216            risk_multiplier: risk,
217            stoploss,
218            targets,
219            group: None,
220            trade_id: None,
221            entry_class: None,
222        }
223    }
224
225    fn any_position() -> PositionRef {
226        PositionRef::AllOnSymbol {
227            symbol: "xauusd".into(),
228        }
229    }
230
231    #[test]
232    fn valid_market_entry_passes() {
233        let signal = entry(
234            Side::Buy,
235            OrderType::Market,
236            Some(2000.0),
237            1.0,
238            Some(1990.0),
239            vec![2010.0, 2020.0],
240        );
241        assert_eq!(validate_raw_signal(&signal), Ok(()));
242    }
243
244    #[test]
245    fn entry_class_is_exact_bounded_and_control_free() {
246        let mut signal = entry(
247            Side::Buy,
248            OrderType::Market,
249            Some(2000.0),
250            1.0,
251            Some(1990.0),
252            vec![2010.0],
253        );
254        for value in ["", " spaced", "spaced ", "line\nbreak"] {
255            if let RawSignal::Entry { entry_class, .. } = &mut signal {
256                *entry_class = Some(value.into());
257            }
258            assert_eq!(
259                validate_raw_signal(&signal),
260                Err(RawSignalValidationError::EntryClass)
261            );
262        }
263        if let RawSignal::Entry { entry_class, .. } = &mut signal {
264            *entry_class = Some("x".repeat(128));
265        }
266        assert_eq!(validate_raw_signal(&signal), Ok(()));
267        if let RawSignal::Entry { entry_class, .. } = &mut signal {
268            *entry_class = Some("x".repeat(129));
269        }
270        assert_eq!(
271            validate_raw_signal(&signal),
272            Err(RawSignalValidationError::EntryClass)
273        );
274    }
275
276    #[test]
277    fn market_entry_without_price_skips_geometry() {
278        // A market Entry may omit price; stop/target side rules need a reference
279        // point, so they must not fire.
280        let signal = entry(
281            Side::Buy,
282            OrderType::Market,
283            None,
284            1.0,
285            Some(9999.0),
286            vec![1.0],
287        );
288        assert_eq!(validate_raw_signal(&signal), Ok(()));
289    }
290
291    #[test]
292    fn non_finite_and_non_positive_risk_are_rejected() {
293        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
294            let signal = entry(Side::Buy, OrderType::Market, None, bad, None, vec![]);
295            assert!(matches!(
296                validate_raw_signal(&signal),
297                Err(RawSignalValidationError::EntryRisk { .. })
298            ));
299        }
300    }
301
302    #[test]
303    fn limit_and_stop_entries_require_a_price() {
304        for order_type in [OrderType::Limit, OrderType::Stop] {
305            let signal = entry(Side::Buy, order_type, None, 1.0, None, vec![]);
306            assert!(matches!(
307                validate_raw_signal(&signal),
308                Err(RawSignalValidationError::EntryPriceRequired { .. })
309            ));
310        }
311    }
312
313    #[test]
314    fn non_positive_entry_price_is_rejected() {
315        let signal = entry(Side::Buy, OrderType::Market, Some(0.0), 1.0, None, vec![]);
316        assert!(matches!(
317            validate_raw_signal(&signal),
318            Err(RawSignalValidationError::EntryPrice { .. })
319        ));
320    }
321
322    #[test]
323    fn stoploss_must_be_protective_for_each_side() {
324        let buy = entry(
325            Side::Buy,
326            OrderType::Market,
327            Some(2000.0),
328            1.0,
329            Some(2010.0),
330            vec![],
331        );
332        let sell = entry(
333            Side::Sell,
334            OrderType::Market,
335            Some(2000.0),
336            1.0,
337            Some(1990.0),
338            vec![],
339        );
340        for signal in [buy, sell] {
341            assert_eq!(
342                validate_raw_signal(&signal),
343                Err(RawSignalValidationError::StoplossNotProtective)
344            );
345        }
346    }
347
348    #[test]
349    fn targets_must_be_on_the_profitable_side() {
350        let buy = entry(
351            Side::Buy,
352            OrderType::Market,
353            Some(2000.0),
354            1.0,
355            None,
356            vec![1990.0],
357        );
358        let sell = entry(
359            Side::Sell,
360            OrderType::Market,
361            Some(2000.0),
362            1.0,
363            None,
364            vec![2010.0],
365        );
366        for signal in [buy, sell] {
367            assert_eq!(
368                validate_raw_signal(&signal),
369                Err(RawSignalValidationError::TargetWrongSide)
370            );
371        }
372    }
373
374    #[test]
375    fn partial_close_ratio_bounds_are_inclusive_at_one() {
376        let ok = RawSignal::ClosePartial {
377            ts: ts(),
378            position: any_position(),
379            ratio: 1.0,
380        };
381        assert_eq!(validate_raw_signal(&ok), Ok(()));
382
383        for bad in [0.0, -0.5, 1.000_001, f64::NAN] {
384            let signal = RawSignal::ClosePartial {
385                ts: ts(),
386                position: any_position(),
387                ratio: bad,
388            };
389            assert!(matches!(
390                validate_raw_signal(&signal),
391                Err(RawSignalValidationError::PartialCloseRatio { .. })
392            ));
393        }
394    }
395
396    #[test]
397    fn management_prices_must_be_finite_positive() {
398        let signal = RawSignal::ModifyStoploss {
399            ts: ts(),
400            position: any_position(),
401            price: -1.0,
402        };
403        assert!(matches!(
404            validate_raw_signal(&signal),
405            Err(RawSignalValidationError::ManagementPrice { .. })
406        ));
407    }
408
409    #[test]
410    fn modify_target_rejects_either_bad_price() {
411        for (old, new) in [(0.0, 2010.0), (2000.0, f64::NAN)] {
412            let signal = RawSignal::ModifyTarget {
413                ts: ts(),
414                position: any_position(),
415                old_price: old,
416                new_price: new,
417            };
418            assert!(matches!(
419                validate_raw_signal(&signal),
420                Err(RawSignalValidationError::TargetPricePair { .. })
421            ));
422        }
423    }
424
425    #[test]
426    fn scale_in_rejects_bad_size_or_price_but_allows_absent_price() {
427        let ok = RawSignal::ScaleIn {
428            ts: ts(),
429            position: any_position(),
430            price: None,
431            size: 0.5,
432        };
433        assert_eq!(validate_raw_signal(&ok), Ok(()));
434
435        let bad_size = RawSignal::ScaleIn {
436            ts: ts(),
437            position: any_position(),
438            price: None,
439            size: 0.0,
440        };
441        let bad_price = RawSignal::ScaleIn {
442            ts: ts(),
443            position: any_position(),
444            price: Some(-1.0),
445            size: 0.5,
446        };
447        for signal in [bad_size, bad_price] {
448            assert_eq!(
449                validate_raw_signal(&signal),
450                Err(RawSignalValidationError::ScaleIn)
451            );
452        }
453    }
454
455    #[test]
456    fn variants_without_numeric_payload_always_pass() {
457        let signals = vec![
458            RawSignal::Close {
459                ts: ts(),
460                position: any_position(),
461            },
462            RawSignal::MoveStoplossToEntry {
463                ts: ts(),
464                position: any_position(),
465            },
466            RawSignal::CancelPending {
467                ts: ts(),
468                position: any_position(),
469            },
470            RawSignal::CloseAll { ts: ts() },
471            RawSignal::CancelAllPending { ts: ts() },
472        ];
473        assert_eq!(validate_raw_signals(&signals), Ok(()));
474    }
475
476    #[test]
477    fn batch_reports_the_first_violation_in_slice_order() {
478        let signals = vec![
479            entry(
480                Side::Buy,
481                OrderType::Market,
482                Some(2000.0),
483                1.0,
484                None,
485                vec![],
486            ),
487            RawSignal::ClosePartial {
488                ts: ts(),
489                position: any_position(),
490                ratio: 2.0,
491            },
492            RawSignal::ModifyStoploss {
493                ts: ts(),
494                position: any_position(),
495                price: -1.0,
496            },
497        ];
498        assert!(matches!(
499            validate_raw_signals(&signals),
500            Err(RawSignalValidationError::PartialCloseRatio { .. })
501        ));
502    }
503
504    #[test]
505    fn messages_match_the_strings_embedded_in_parser_goldens() {
506        // These exact strings appear in committed outcome goldens through
507        // ParseFailure::InvalidSignal. Changing them rewrites those files.
508        assert_eq!(
509            RawSignalValidationError::TargetWrongSide.to_string(),
510            "target is on the wrong side of entry"
511        );
512        assert_eq!(
513            RawSignalValidationError::StoplossNotProtective.to_string(),
514            "stoploss is not protective for the entry side"
515        );
516        assert_eq!(
517            RawSignalValidationError::EntryRisk { value: 0.0 }.to_string(),
518            "entry risk multiplier must be finite and positive, got 0"
519        );
520        assert_eq!(
521            RawSignalValidationError::PartialCloseRatio { value: 2.0 }.to_string(),
522            "partial close ratio must be in (0, 1], got 2"
523        );
524        assert_eq!(
525            RawSignalValidationError::EntryPriceRequired {
526                order_type: OrderType::Limit
527            }
528            .to_string(),
529            "Limit entry requires a finite positive price"
530        );
531        assert_eq!(
532            RawSignalValidationError::ScaleIn.to_string(),
533            "scale-in size/price is invalid"
534        );
535        assert_eq!(
536            RawSignalValidationError::ManagementPrice { value: -1.0 }.to_string(),
537            "management price must be finite and positive, got -1"
538        );
539        assert_eq!(
540            RawSignalValidationError::TargetPricePair {
541                old_price: 1.0,
542                new_price: 2.0
543            }
544            .to_string(),
545            "target prices must be finite and positive, got 1 -> 2"
546        );
547    }
548}