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("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
55pub 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
162pub 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 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 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}