1use crate::profile::RawSignal;
22use crate::types::{OrderType, Side};
23
24#[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
60pub 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
73pub 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
184pub 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 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 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}