1use std::collections::HashMap;
4use std::path::Path;
5
6use serde::Deserialize;
7
8pub use qs_core::profile::*;
9
10#[derive(Debug, thiserror::Error)]
12pub enum ProfileRegistryError {
13 #[error("Failed to read profile file: {0}")]
14 Io(#[from] std::io::Error),
15 #[error("Failed to parse profile TOML: {0}")]
16 Parse(#[from] toml::de::Error),
17 #[error("Duplicate profile name: '{0}'")]
18 DuplicateName(String),
19 #[error(
20 "Profile '{profile}': selected target count ({targets}) does not match close_ratios length ({ratios})"
21 )]
22 TargetRatioMismatch {
23 profile: String,
24 targets: usize,
25 ratios: usize,
26 },
27 #[error("Profile '{profile}': close_ratios sum to {sum:.4}, which exceeds 1.0")]
28 RatioSumExceeded { profile: String, sum: f64 },
29 #[error(
30 "Profile '{profile}': close_ratios sum to {sum:.4}; they must sum to 1.0 when let_remainder_run is false"
31 )]
32 RatioSumIncomplete { profile: String, sum: f64 },
33 #[error("Profile '{profile}': close_ratios contains a non-finite or non-positive value")]
34 ZeroRatio { profile: String },
35 #[error("Profile '{profile}': target selection contains a 0 index (must be 1-indexed)")]
36 ZeroTargetIndex { profile: String },
37 #[error("Profile '{profile}': target index {index} is selected more than once")]
38 DuplicateTargetIndex { profile: String, index: usize },
39 #[error("Profile '{profile}': {reason}")]
40 InvalidConfiguration { profile: String, reason: String },
41 #[error("Profile not found: '{0}'")]
42 NotFound(String),
43}
44
45pub type ProfileError = ProfileRegistryError;
47
48impl From<ProfileValidationError> for ProfileRegistryError {
49 fn from(error: ProfileValidationError) -> Self {
50 match error {
51 ProfileValidationError::TargetRatioMismatch {
52 profile,
53 targets,
54 ratios,
55 } => Self::TargetRatioMismatch {
56 profile,
57 targets,
58 ratios,
59 },
60 ProfileValidationError::RatioSumExceeded { profile, sum } => {
61 Self::RatioSumExceeded { profile, sum }
62 }
63 ProfileValidationError::RatioSumIncomplete { profile, sum } => {
64 Self::RatioSumIncomplete { profile, sum }
65 }
66 ProfileValidationError::ZeroRatio { profile } => Self::ZeroRatio { profile },
67 ProfileValidationError::ZeroTargetIndex { profile } => {
68 Self::ZeroTargetIndex { profile }
69 }
70 ProfileValidationError::DuplicateTargetIndex { profile, index } => {
71 Self::DuplicateTargetIndex { profile, index }
72 }
73 ProfileValidationError::InvalidConfiguration { profile, reason } => {
74 Self::InvalidConfiguration { profile, reason }
75 }
76 }
77 }
78}
79
80#[derive(Debug, Deserialize)]
84struct ProfileFile {
85 profile: Vec<ManagementProfile>,
86}
87
88pub struct ProfileRegistry {
90 profiles: HashMap<String, ManagementProfile>,
91}
92
93impl ProfileRegistry {
94 pub fn load(path: impl AsRef<Path>) -> Result<Self, ProfileError> {
96 let content = std::fs::read_to_string(path)?;
97 Self::from_toml(&content)
98 }
99
100 pub fn from_toml(content: &str) -> Result<Self, ProfileError> {
102 let file: ProfileFile = toml::from_str(content)?;
103 let mut profiles = HashMap::new();
104
105 for p in file.profile {
106 Self::validate(&p)?;
108
109 if profiles.contains_key(&p.name) {
110 return Err(ProfileError::DuplicateName(p.name.clone()));
111 }
112 profiles.insert(p.name.clone(), p);
113 }
114
115 Ok(Self { profiles })
116 }
117
118 pub fn empty() -> Self {
120 Self {
121 profiles: HashMap::new(),
122 }
123 }
124
125 pub fn get(&self, name: &str) -> Option<&ManagementProfile> {
127 self.profiles.get(name)
128 }
129
130 pub fn names(&self) -> Vec<&str> {
132 let mut names: Vec<&str> = self.profiles.keys().map(|s| s.as_str()).collect();
133 names.sort();
134 names
135 }
136
137 pub fn len(&self) -> usize {
139 self.profiles.len()
140 }
141
142 pub fn is_empty(&self) -> bool {
144 self.profiles.is_empty()
145 }
146
147 pub fn validate_profile(p: &ManagementProfile) -> Result<(), ProfileError> {
149 qs_core::profile::validate_profile(p).map_err(Into::into)
150 }
151
152 fn validate(p: &ManagementProfile) -> Result<(), ProfileError> {
154 Self::validate_profile(p)
155 }
156
157 pub fn insert(
160 &mut self,
161 profile: ManagementProfile,
162 overwrite: bool,
163 ) -> Result<(), ProfileError> {
164 Self::validate_profile(&profile)?;
165 if !overwrite && self.profiles.contains_key(&profile.name) {
166 return Err(ProfileError::DuplicateName(profile.name.clone()));
167 }
168 self.profiles.insert(profile.name.clone(), profile);
169 Ok(())
170 }
171
172 pub fn remove(&mut self, name: &str) -> bool {
174 self.profiles.remove(name).is_some()
175 }
176}
177
178impl std::fmt::Debug for ProfileRegistry {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 f.debug_struct("ProfileRegistry")
181 .field("count", &self.profiles.len())
182 .field("names", &self.names())
183 .finish()
184 }
185}
186
187#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::runner::BacktestConfig;
193 use crate::sizing::SizingPolicy;
194 use chrono::{NaiveDate, NaiveDateTime};
195 use qs_core::types::{
196 Action, CloseReason, OrderType, PositionId, RuleConfig, Side, TargetSpec,
197 };
198 use qs_symbols::SymbolSpec;
199
200 const WEIGHT_TOLERANCE: f64 = 1e-12;
201
202 fn ts(h: u32, m: u32, s: u32) -> NaiveDateTime {
205 NaiveDate::from_ymd_opt(2026, 1, 1)
206 .unwrap()
207 .and_hms_opt(h, m, s)
208 .unwrap()
209 }
210
211 fn buy_signal() -> RawSignal {
213 RawSignal::Entry {
214 ts: ts(10, 0, 0),
215 symbol: "eurusd".into(),
216 side: Side::Buy,
217 order_type: OrderType::Market,
218 price: Some(1.0850),
219 risk_multiplier: 1.0,
220 stoploss: Some(1.0800),
221 targets: vec![1.0900, 1.0950],
222 group: None,
223 trade_id: None,
224 }
225 }
226
227 fn sell_signal() -> RawSignal {
229 RawSignal::Entry {
230 ts: ts(10, 0, 0),
231 symbol: "eurusd".into(),
232 side: Side::Sell,
233 order_type: OrderType::Market,
234 price: Some(1.0850),
235 risk_multiplier: 1.0,
236 stoploss: Some(1.0900),
237 targets: vec![1.0800, 1.0750],
238 group: None,
239 trade_id: None,
240 }
241 }
242
243 fn entry_replay_config() -> BacktestConfig {
244 BacktestConfig {
245 initial_balance: 10_000.0,
246 close_on_finish: true,
247 sizing: Some(SizingPolicy::FixedLot { lots: 0.02 }),
248 symbol_specs: HashMap::from([(
249 "eurusd".into(),
250 SymbolSpec {
251 canonical: "eurusd".into(),
252 pip_position: 4,
253 digits: 5,
254 category: "forex".into(),
255 lot_base_units: 100_000,
256 lot_step_units: 1_000,
257 lot_min_steps: 1,
258 lot_max_steps: 0,
259 },
260 )]),
261 ..BacktestConfig::default()
262 }
263 }
264
265 type ResolvedEntryFields<'a> = (
266 &'a str,
267 Side,
268 OrderType,
269 Option<f64>,
270 f64,
271 Option<f64>,
272 &'a [TargetSpec],
273 &'a [RuleConfig],
274 &'a Option<String>,
275 );
276
277 trait ResolvedEntryRef {
278 fn resolved_entry(&self) -> &ResolvedEntry;
279 }
280
281 impl ResolvedEntryRef for ResolvedEntry {
282 fn resolved_entry(&self) -> &ResolvedEntry {
283 self
284 }
285 }
286
287 impl ResolvedEntryRef for Option<ResolvedEntry> {
288 fn resolved_entry(&self) -> &ResolvedEntry {
289 self.as_ref().expect("expected resolved Entry signal")
290 }
291 }
292
293 fn unwrap_open(entry: &impl ResolvedEntryRef) -> ResolvedEntryFields<'_> {
294 let entry = entry.resolved_entry();
295 (
296 entry.symbol.as_str(),
297 entry.side,
298 entry.order_type,
299 entry.price,
300 entry.risk_multiplier,
301 entry.stoploss,
302 entry.targets.as_slice(),
303 entry.rules.as_slice(),
304 &entry.group,
305 )
306 }
307
308 #[test]
311 fn load_from_toml_string() {
312 let toml = r#"
313[[profile]]
314name = "basic"
315use_targets = [1]
316close_ratios = [1.0]
317stoploss_mode = { type = "FromSignal" }
318let_remainder_run = false
319"#;
320 let reg = ProfileRegistry::from_toml(toml).unwrap();
321 assert_eq!(reg.len(), 1);
322 assert!(reg.get("basic").is_some());
323 }
324
325 #[test]
326 fn load_multiple_profiles() {
327 let toml = r#"
328[[profile]]
329name = "a"
330use_targets = [1]
331close_ratios = [1.0]
332
333[[profile]]
334name = "b"
335use_targets = [1, 2]
336close_ratios = [0.5, 0.5]
337
338[[profile]]
339name = "c"
340use_targets = []
341close_ratios = []
342"#;
343 let reg = ProfileRegistry::from_toml(toml).unwrap();
344 assert_eq!(reg.len(), 3);
345 assert!(reg.get("a").is_some());
346 assert!(reg.get("b").is_some());
347 assert!(reg.get("c").is_some());
348 }
349
350 #[test]
351 fn duplicate_name_error() {
352 let toml = r#"
353[[profile]]
354name = "dup"
355use_targets = [1]
356close_ratios = [1.0]
357
358[[profile]]
359name = "dup"
360use_targets = [1]
361close_ratios = [1.0]
362"#;
363 let err = ProfileRegistry::from_toml(toml).unwrap_err();
364 assert!(
365 matches!(err, ProfileError::DuplicateName(ref n) if n == "dup"),
366 "Expected DuplicateName, got: {err:?}"
367 );
368 }
369
370 #[test]
371 fn target_ratio_mismatch_error() {
372 let toml = r#"
373[[profile]]
374name = "bad"
375use_targets = [1, 2]
376close_ratios = [0.5]
377"#;
378 let err = ProfileRegistry::from_toml(toml).unwrap_err();
379 assert!(matches!(err, ProfileError::TargetRatioMismatch { .. }));
380 }
381
382 #[test]
383 fn selected_single_target_with_empty_ratios_resolves_to_full_weight() {
384 let toml = r#"
385[[profile]]
386name = "equal_one"
387use_targets = [1]
388close_ratios = []
389"#;
390 let registry = ProfileRegistry::from_toml(toml).unwrap();
391 let resolved = registry
392 .get("equal_one")
393 .unwrap()
394 .apply_entry_signal(&buy_signal())
395 .unwrap()
396 .unwrap();
397
398 assert_eq!(resolved.target_resolution.weights, vec![1.0]);
399 assert_eq!(resolved.target_resolution.remainder, 0.0);
400 assert_eq!(resolved_targets(&resolved)[0].close_ratio, 1.0);
401 }
402
403 #[test]
404 fn legacy_empty_close_ratios_resolve_to_equal_weights() {
405 let profile = strict_profile(vec![1, 2], vec![], false);
406 let resolved = profile.apply_entry_signal(&buy_signal()).unwrap().unwrap();
407
408 assert_eq!(resolved.target_resolution.weights, vec![0.5, 0.5]);
409 assert_eq!(resolved.target_resolution.remainder, 0.0);
410 assert_eq!(resolved.targets.len(), 2);
411 assert_eq!(resolved.targets[0].close_ratio, 0.5);
412 assert_eq!(resolved.targets[1].close_ratio, 0.5);
413 }
414
415 #[test]
416 fn selected_three_targets_with_empty_ratios_resolve_to_equal_weights() {
417 let toml = r#"
418[[profile]]
419name = "equal_three"
420use_targets = [1, 2, 3]
421close_ratios = []
422"#;
423 let registry = ProfileRegistry::from_toml(toml).unwrap();
424 let mut signal = buy_signal();
425 if let RawSignal::Entry { targets, .. } = &mut signal {
426 targets.push(1.1000);
427 }
428
429 let resolved = registry
430 .get("equal_three")
431 .unwrap()
432 .apply_entry_signal(&signal)
433 .unwrap()
434 .unwrap();
435 let expected_weight = 1.0 / 3.0;
436
437 assert_eq!(resolved.target_resolution.selected_indices, vec![1, 2, 3]);
438 assert_eq!(resolved.target_resolution.remainder, 0.0);
439 assert!(
440 resolved
441 .target_resolution
442 .weights
443 .iter()
444 .all(|weight| (weight - expected_weight).abs() <= WEIGHT_TOLERANCE)
445 );
446 assert!(
447 resolved_targets(&resolved)
448 .iter()
449 .all(|target| (target.close_ratio - expected_weight).abs() <= WEIGHT_TOLERANCE)
450 );
451 }
452
453 #[test]
454 fn explicit_partial_ratios_require_and_honor_remainder_flag() {
455 let rejected = r#"
456[[profile]]
457name = "no_runner"
458use_targets = [1, 2]
459close_ratios = [0.3, 0.3]
460let_remainder_run = false
461"#;
462 assert!(matches!(
463 ProfileRegistry::from_toml(rejected),
464 Err(ProfileError::RatioSumIncomplete { .. })
465 ));
466
467 let accepted = r#"
468[[profile]]
469name = "runner"
470use_targets = [1, 2]
471close_ratios = [0.3, 0.3]
472let_remainder_run = true
473"#;
474 let registry = ProfileRegistry::from_toml(accepted).unwrap();
475 let resolved = registry
476 .get("runner")
477 .unwrap()
478 .apply_entry_signal(&buy_signal())
479 .unwrap()
480 .unwrap();
481
482 assert_eq!(resolved.target_resolution.weights, vec![0.3, 0.3]);
483 assert!((resolved.target_resolution.remainder - 0.4).abs() <= WEIGHT_TOLERANCE);
484 }
485
486 #[test]
487 fn ratio_sum_exceeded_error() {
488 let toml = r#"
489[[profile]]
490name = "bad"
491use_targets = [1, 2]
492close_ratios = [0.6, 0.6]
493"#;
494 let err = ProfileRegistry::from_toml(toml).unwrap_err();
495 assert!(matches!(err, ProfileError::RatioSumExceeded { .. }));
496 }
497
498 #[test]
499 fn zero_ratio_error() {
500 let toml = r#"
501[[profile]]
502name = "bad"
503use_targets = [1]
504close_ratios = [0.0]
505"#;
506 let err = ProfileRegistry::from_toml(toml).unwrap_err();
507 assert!(matches!(err, ProfileError::ZeroRatio { .. }));
508 }
509
510 #[test]
511 fn non_finite_ratios_are_rejected() {
512 for ratio in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
513 let profile = strict_profile(vec![1], vec![ratio], false);
514 assert!(matches!(
515 profile.validate(),
516 Err(ProfileValidationError::ZeroRatio { .. })
517 ));
518 }
519 }
520
521 #[test]
522 fn zero_target_index_error() {
523 let toml = r#"
524[[profile]]
525name = "bad"
526use_targets = [0]
527close_ratios = [1.0]
528"#;
529 let err = ProfileRegistry::from_toml(toml).unwrap_err();
530 assert!(matches!(err, ProfileError::ZeroTargetIndex { .. }));
531 }
532
533 #[test]
534 fn empty_registry() {
535 let reg = ProfileRegistry::empty();
536 assert!(reg.is_empty());
537 assert_eq!(reg.len(), 0);
538 assert!(reg.get("anything").is_none());
539 }
540
541 #[test]
542 fn names_list_sorted() {
543 let toml = r#"
544[[profile]]
545name = "charlie"
546use_targets = []
547close_ratios = []
548
549[[profile]]
550name = "alpha"
551use_targets = []
552close_ratios = []
553
554[[profile]]
555name = "bravo"
556use_targets = []
557close_ratios = []
558"#;
559 let reg = ProfileRegistry::from_toml(toml).unwrap();
560 assert_eq!(reg.names(), vec!["alpha", "bravo", "charlie"]);
561 }
562
563 #[test]
566 fn apply_conservative_single_target() {
567 let toml = r#"
568[[profile]]
569name = "conservative"
570use_targets = [1]
571close_ratios = [1.0]
572"#;
573 let reg = ProfileRegistry::from_toml(toml).unwrap();
574 let profile = reg.get("conservative").unwrap();
575
576 let signal = buy_signal();
577 let action = profile.apply_entry_signal(&signal).unwrap();
578 let (sym, side, _, price, risk_multiplier, sl, targets, _, _) = unwrap_open(&action);
579
580 assert_eq!(sym, "eurusd");
581 assert_eq!(side, Side::Buy);
582 assert_eq!(price, Some(1.0850));
583 assert_eq!(risk_multiplier, 1.0);
584 assert_eq!(sl, Some(1.0800));
585 assert_eq!(targets.len(), 1);
586 assert!((targets[0].price - 1.0900).abs() < f64::EPSILON);
587 assert!((targets[0].close_ratio - 1.0).abs() < f64::EPSILON);
588 }
589
590 #[test]
591 fn apply_aggressive_both_targets() {
592 let toml = r#"
593[[profile]]
594name = "aggressive"
595use_targets = [1, 2]
596close_ratios = [0.5, 0.5]
597"#;
598 let reg = ProfileRegistry::from_toml(toml).unwrap();
599 let profile = reg.get("aggressive").unwrap();
600
601 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
602 let (_, _, _, _, _, _, targets, _, _) = unwrap_open(&action);
603
604 assert_eq!(targets.len(), 2);
605 assert!((targets[0].price - 1.0900).abs() < f64::EPSILON);
606 assert!((targets[0].close_ratio - 0.5).abs() < f64::EPSILON);
607 assert!((targets[1].price - 1.0950).abs() < f64::EPSILON);
608 assert!((targets[1].close_ratio - 0.5).abs() < f64::EPSILON);
609 }
610
611 #[test]
612 fn apply_runner_partial_target() {
613 let toml = r#"
614[[profile]]
615name = "runner"
616use_targets = [1]
617close_ratios = [0.3]
618let_remainder_run = true
619"#;
620 let reg = ProfileRegistry::from_toml(toml).unwrap();
621 let profile = reg.get("runner").unwrap();
622
623 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
624 let (_, _, _, _, _, _, targets, _, _) = unwrap_open(&action);
625
626 assert_eq!(targets.len(), 1);
627 assert!((targets[0].close_ratio - 0.3).abs() < f64::EPSILON);
628 assert!(profile.let_remainder_run);
629 }
630
631 #[test]
632 fn apply_stoploss_from_signal() {
633 let toml = r#"
634[[profile]]
635name = "test"
636use_targets = [1]
637close_ratios = [1.0]
638stoploss_mode = { type = "FromSignal" }
639"#;
640 let reg = ProfileRegistry::from_toml(toml).unwrap();
641 let profile = reg.get("test").unwrap();
642
643 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
644 let (_, _, _, _, _, sl, _, _, _) = unwrap_open(&action);
645 assert_eq!(sl, Some(1.0800));
646 }
647
648 #[test]
649 fn apply_stoploss_none() {
650 let toml = r#"
651[[profile]]
652name = "test"
653use_targets = [1]
654close_ratios = [1.0]
655stoploss_mode = { type = "None" }
656"#;
657 let reg = ProfileRegistry::from_toml(toml).unwrap();
658 let profile = reg.get("test").unwrap();
659
660 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
661 let (_, _, _, _, _, sl, _, _, _) = unwrap_open(&action);
662 assert_eq!(sl, None);
663 }
664
665 #[test]
666 fn apply_stoploss_fixed_distance_buy() {
667 let toml = r#"
668[[profile]]
669name = "test"
670use_targets = [1]
671close_ratios = [1.0]
672
673[profile.stoploss_mode]
674type = "FixedDistance"
675distance = 0.0020
676"#;
677 let reg = ProfileRegistry::from_toml(toml).unwrap();
678 let profile = reg.get("test").unwrap();
679
680 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
681 let (_, _, _, _, _, sl, _, _, _) = unwrap_open(&action);
682 assert!((sl.unwrap() - 1.0830).abs() < 1e-10);
684 }
685
686 #[test]
687 fn apply_stoploss_fixed_distance_sell() {
688 let toml = r#"
689[[profile]]
690name = "test"
691use_targets = [1]
692close_ratios = [1.0]
693
694[profile.stoploss_mode]
695type = "FixedDistance"
696distance = 0.0020
697"#;
698 let reg = ProfileRegistry::from_toml(toml).unwrap();
699 let profile = reg.get("test").unwrap();
700
701 let action = profile.apply_entry_signal(&sell_signal()).unwrap();
702 let (_, _, _, _, _, sl, _, _, _) = unwrap_open(&action);
703 assert!((sl.unwrap() - 1.0870).abs() < 1e-10);
705 }
706
707 #[test]
708 fn apply_stoploss_fixed_price() {
709 let toml = r#"
710[[profile]]
711name = "test"
712use_targets = [1]
713close_ratios = [1.0]
714
715[profile.stoploss_mode]
716type = "FixedPrice"
717price = 1.0780
718"#;
719 let reg = ProfileRegistry::from_toml(toml).unwrap();
720 let profile = reg.get("test").unwrap();
721
722 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
723 let (_, _, _, _, _, sl, _, _, _) = unwrap_open(&action);
724 assert!((sl.unwrap() - 1.0780).abs() < f64::EPSILON);
725 }
726
727 #[test]
728 fn apply_with_breakeven_after_targets_rule() {
729 let toml = r#"
730[[profile]]
731name = "test"
732use_targets = [1, 2]
733close_ratios = [0.5, 0.5]
734
735[[profile.rules]]
736type = "BreakevenAfterTargets"
737after_n = 1
738
739[[profile.rules]]
740type = "TrailingStop"
741distance = 0.0020
742"#;
743 let reg = ProfileRegistry::from_toml(toml).unwrap();
744 let profile = reg.get("test").unwrap();
745
746 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
747 let (_, _, _, _, _, _, _, rules, _) = unwrap_open(&action);
748
749 assert_eq!(rules.len(), 2);
750 assert!(matches!(
751 rules[0],
752 RuleConfig::BreakevenAfterTargets { after_n: 1 }
753 ));
754 assert!(matches!(
755 rules[1],
756 RuleConfig::TrailingStop { distance } if (distance - 0.0020).abs() < f64::EPSILON
757 ));
758 }
759
760 #[test]
761 fn apply_group_override() {
762 let toml = r#"
763[[profile]]
764name = "test"
765use_targets = []
766close_ratios = []
767group_override = "scalp"
768"#;
769 let reg = ProfileRegistry::from_toml(toml).unwrap();
770 let profile = reg.get("test").unwrap();
771
772 let mut signal = buy_signal();
773 if let RawSignal::Entry { ref mut group, .. } = signal {
774 *group = Some("momentum".into());
775 }
776
777 let action = profile.apply_entry_signal(&signal).unwrap();
778 let (_, _, _, _, _, _, _, _, group) = unwrap_open(&action);
779 assert_eq!(group.as_deref(), Some("scalp"));
781 }
782
783 #[test]
784 fn apply_group_from_signal() {
785 let toml = r#"
786[[profile]]
787name = "test"
788use_targets = []
789close_ratios = []
790"#;
791 let reg = ProfileRegistry::from_toml(toml).unwrap();
792 let profile = reg.get("test").unwrap();
793
794 let mut signal = buy_signal();
795 if let RawSignal::Entry { ref mut group, .. } = signal {
796 *group = Some("momentum".into());
797 }
798
799 let action = profile.apply_entry_signal(&signal).unwrap();
800 let (_, _, _, _, _, _, _, _, group) = unwrap_open(&action);
801 assert_eq!(group.as_deref(), Some("momentum"));
802 }
803
804 #[test]
805 fn apply_group_both_none() {
806 let toml = r#"
807[[profile]]
808name = "test"
809use_targets = []
810close_ratios = []
811"#;
812 let reg = ProfileRegistry::from_toml(toml).unwrap();
813 let profile = reg.get("test").unwrap();
814
815 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
816 let (_, _, _, _, _, _, _, _, group) = unwrap_open(&action);
817 assert!(group.is_none());
818 }
819
820 #[test]
821 fn apply_missing_target_index() {
822 let toml = r#"
823[[profile]]
824name = "test"
825use_targets = [3]
826close_ratios = [1.0]
827"#;
828 let reg = ProfileRegistry::from_toml(toml).unwrap();
829 let profile = reg.get("test").unwrap();
830
831 assert!(matches!(
832 profile.apply_entry_signal(&buy_signal()),
833 Err(ProfileApplicationError::MissingTargetIndex {
834 index: 3,
835 available: 2
836 })
837 ));
838 }
839
840 #[test]
841 fn apply_no_targets() {
842 let toml = r#"
843[[profile]]
844name = "test"
845use_targets = []
846close_ratios = []
847"#;
848 let reg = ProfileRegistry::from_toml(toml).unwrap();
849 let profile = reg.get("test").unwrap();
850
851 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
852 let (_, _, _, _, _, _, targets, _, _) = unwrap_open(&action);
853 assert!(targets.is_empty());
854 }
855
856 #[test]
857 fn apply_market_order_no_price() {
858 let toml = r#"
859[[profile]]
860name = "test"
861use_targets = [1]
862close_ratios = [1.0]
863"#;
864 let reg = ProfileRegistry::from_toml(toml).unwrap();
865 let profile = reg.get("test").unwrap();
866
867 let mut signal = buy_signal();
868 if let RawSignal::Entry { ref mut price, .. } = signal {
869 *price = None;
870 }
871
872 let action = profile.apply_entry_signal(&signal).unwrap();
873 let (_, _, _, price, _, _, _, _, _) = unwrap_open(&action);
874 assert_eq!(price, None);
875 }
876
877 #[test]
878 fn apply_limit_order_with_price() {
879 let toml = r#"
880[[profile]]
881name = "test"
882use_targets = [1]
883close_ratios = [1.0]
884"#;
885 let reg = ProfileRegistry::from_toml(toml).unwrap();
886 let profile = reg.get("test").unwrap();
887
888 let mut signal = buy_signal();
889 if let RawSignal::Entry {
890 ref mut order_type,
891 ref mut price,
892 ref mut stoploss,
893 ..
894 } = signal
895 {
896 *order_type = OrderType::Limit;
897 *price = Some(1.0800);
898 *stoploss = Some(1.0750);
899 }
900
901 let action = profile.apply_entry_signal(&signal).unwrap();
902 let (_, _, ot, price, _, _, _, _, _) = unwrap_open(&action);
903 assert_eq!(ot, OrderType::Limit);
904 assert_eq!(price, Some(1.0800));
905 }
906
907 #[test]
908 fn apply_fixed_distance_no_price_returns_none_sl() {
909 let toml = r#"
910[[profile]]
911name = "test"
912use_targets = []
913close_ratios = []
914
915[profile.stoploss_mode]
916type = "FixedDistance"
917distance = 0.0020
918"#;
919 let reg = ProfileRegistry::from_toml(toml).unwrap();
920 let profile = reg.get("test").unwrap();
921
922 let mut signal = buy_signal();
923 if let RawSignal::Entry { ref mut price, .. } = signal {
924 *price = None;
925 }
926
927 let action = profile.apply_entry_signal(&signal).unwrap();
928 let (_, _, _, _, _, sl, _, _, _) = unwrap_open(&action);
929 assert_eq!(sl, None);
931 }
932
933 #[test]
936 fn breakeven_offset_buy() {
937 let toml = r#"
938[[profile]]
939name = "test"
940use_targets = [1]
941close_ratios = [1.0]
942
943[[profile.rules]]
944type = "BreakevenWhenOffset"
945trigger_price_offset = 0.0020
946"#;
947 let reg = ProfileRegistry::from_toml(toml).unwrap();
948 let profile = reg.get("test").unwrap();
949
950 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
951 let (_, _, _, _, _, _, _, rules, _) = unwrap_open(&action);
952
953 assert_eq!(rules.len(), 1);
954 match &rules[0] {
955 RuleConfig::BreakevenWhen { trigger_price } => {
956 assert!(
958 (trigger_price - 1.0870).abs() < 1e-10,
959 "Expected ~1.0870, got {trigger_price}"
960 );
961 }
962 other => panic!("Expected BreakevenWhen, got {other:?}"),
963 }
964 }
965
966 #[test]
967 fn breakeven_offset_sell() {
968 let toml = r#"
969[[profile]]
970name = "test"
971use_targets = [1]
972close_ratios = [1.0]
973
974[[profile.rules]]
975type = "BreakevenWhenOffset"
976trigger_price_offset = 2.0
977"#;
978 let reg = ProfileRegistry::from_toml(toml).unwrap();
979 let profile = reg.get("test").unwrap();
980
981 let mut signal = sell_signal();
982 if let RawSignal::Entry {
983 ref mut price,
984 ref mut stoploss,
985 ref mut targets,
986 ..
987 } = signal
988 {
989 *price = Some(2010.0);
990 *stoploss = Some(2020.0);
991 *targets = vec![2000.0];
992 }
993
994 let action = profile.apply_entry_signal(&signal).unwrap();
995 let (_, _, _, _, _, _, _, rules, _) = unwrap_open(&action);
996
997 match &rules[0] {
998 RuleConfig::BreakevenWhen { trigger_price } => {
999 assert!(
1001 (trigger_price - 2008.0).abs() < 1e-10,
1002 "Expected ~2008.0, got {trigger_price}"
1003 );
1004 }
1005 other => panic!("Expected BreakevenWhen, got {other:?}"),
1006 }
1007 }
1008
1009 #[test]
1010 fn breakeven_offset_no_entry_price_skips_rule() {
1011 let toml = r#"
1012[[profile]]
1013name = "test"
1014use_targets = []
1015close_ratios = []
1016
1017[[profile.rules]]
1018type = "BreakevenWhenOffset"
1019trigger_price_offset = 0.0020
1020"#;
1021 let reg = ProfileRegistry::from_toml(toml).unwrap();
1022 let profile = reg.get("test").unwrap();
1023
1024 let mut signal = buy_signal();
1025 if let RawSignal::Entry { ref mut price, .. } = signal {
1026 *price = None;
1027 }
1028
1029 let action = profile.apply_entry_signal(&signal).unwrap();
1030 let (_, _, _, _, _, _, _, rules, _) = unwrap_open(&action);
1031 assert!(rules.is_empty());
1033 }
1034
1035 #[test]
1038 fn apply_with_time_exit_rule() {
1039 let toml = r#"
1040[[profile]]
1041name = "test"
1042use_targets = [1]
1043close_ratios = [1.0]
1044
1045[[profile.rules]]
1046type = "TimeExit"
1047max_seconds = 3600
1048"#;
1049 let reg = ProfileRegistry::from_toml(toml).unwrap();
1050 let profile = reg.get("test").unwrap();
1051
1052 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
1053 let (_, _, _, _, _, _, _, rules, _) = unwrap_open(&action);
1054
1055 assert_eq!(rules.len(), 1);
1056 assert!(matches!(
1057 rules[0],
1058 RuleConfig::TimeExit { max_seconds: 3600 }
1059 ));
1060 }
1061
1062 #[test]
1065 fn full_profiles_toml_loads() {
1066 let toml = r#"
1067[[profile]]
1068name = "conservative"
1069use_targets = [1]
1070close_ratios = [1.0]
1071stoploss_mode = { type = "FromSignal" }
1072let_remainder_run = false
1073
1074[[profile]]
1075name = "aggressive"
1076use_targets = [1, 2]
1077close_ratios = [0.5, 0.5]
1078stoploss_mode = { type = "FromSignal" }
1079let_remainder_run = false
1080
1081[[profile.rules]]
1082type = "BreakevenAfterTargets"
1083after_n = 1
1084
1085[[profile.rules]]
1086type = "TrailingStop"
1087distance = 0.0020
1088
1089[[profile]]
1090name = "runner"
1091use_targets = [1]
1092close_ratios = [0.3]
1093stoploss_mode = { type = "FromSignal" }
1094let_remainder_run = true
1095
1096[[profile.rules]]
1097type = "BreakevenAfterTargets"
1098after_n = 1
1099
1100[[profile.rules]]
1101type = "TrailingStop"
1102distance = 0.0030
1103
1104[[profile]]
1105name = "scalp_tight"
1106use_targets = [1]
1107close_ratios = [1.0]
1108let_remainder_run = false
1109
1110[profile.stoploss_mode]
1111type = "FixedDistance"
1112distance = 0.0010
1113
1114[[profile]]
1115name = "time_limited"
1116use_targets = [1, 2]
1117close_ratios = [0.5, 0.5]
1118stoploss_mode = { type = "FromSignal" }
1119let_remainder_run = false
1120
1121[[profile.rules]]
1122type = "TimeExit"
1123max_seconds = 3600
1124
1125[[profile.rules]]
1126type = "BreakevenWhenOffset"
1127trigger_price_offset = 0.0020
1128"#;
1129 let reg = ProfileRegistry::from_toml(toml).unwrap();
1130 assert_eq!(reg.len(), 5);
1131 assert_eq!(
1132 reg.names(),
1133 vec![
1134 "aggressive",
1135 "conservative",
1136 "runner",
1137 "scalp_tight",
1138 "time_limited"
1139 ]
1140 );
1141 }
1142
1143 #[test]
1144 fn shipped_profiles_have_explicit_current_target_selection() {
1145 let registry = ProfileRegistry::from_toml(include_str!("../profiles.toml")).unwrap();
1146 assert!(!registry.is_empty());
1147 for name in registry.names() {
1148 let profile = registry.get(name).unwrap();
1149 assert!(
1150 profile.target_selection.is_some(),
1151 "shipped profile `{name}` must state target_selection explicitly"
1152 );
1153 }
1154 }
1155
1156 #[test]
1159 fn same_signal_different_profiles() {
1160 let toml = r#"
1161[[profile]]
1162name = "conservative"
1163use_targets = [1]
1164close_ratios = [1.0]
1165
1166[[profile]]
1167name = "aggressive"
1168use_targets = [1, 2]
1169close_ratios = [0.5, 0.5]
1170"#;
1171 let reg = ProfileRegistry::from_toml(toml).unwrap();
1172 let signal = buy_signal();
1173
1174 let conservative = reg.get("conservative").unwrap();
1175 let aggressive = reg.get("aggressive").unwrap();
1176
1177 let action_c = conservative.apply_entry_signal(&signal).unwrap();
1178 let action_a = aggressive.apply_entry_signal(&signal).unwrap();
1179
1180 let (_, _, _, _, _, _, targets_c, _, _) = unwrap_open(&action_c);
1181 let (_, _, _, _, _, _, targets_a, _, _) = unwrap_open(&action_a);
1182
1183 assert_eq!(targets_c.len(), 1);
1185 assert!((targets_c[0].close_ratio - 1.0).abs() < f64::EPSILON);
1186
1187 assert_eq!(targets_a.len(), 2);
1189 assert!((targets_a[0].close_ratio - 0.5).abs() < f64::EPSILON);
1190 assert!((targets_a[1].close_ratio - 0.5).abs() < f64::EPSILON);
1191 }
1192
1193 #[test]
1196 fn serde_roundtrip_raw_signal() {
1197 let signal = buy_signal();
1198 let json = serde_json::to_string(&signal).unwrap();
1199 let back: RawSignal = serde_json::from_str(&json).unwrap();
1200
1201 assert!(back.is_entry());
1202 assert_eq!(back.ts(), ts(10, 0, 0));
1203 }
1204
1205 #[test]
1206 fn serde_roundtrip_profile() {
1207 let toml_input = r#"
1208[[profile]]
1209name = "test"
1210use_targets = [1, 2]
1211close_ratios = [0.5, 0.5]
1212stoploss_mode = { type = "FromSignal" }
1213group_override = "my_group"
1214let_remainder_run = true
1215
1216[[profile.rules]]
1217type = "BreakevenAfterTargets"
1218after_n = 1
1219"#;
1220 let reg = ProfileRegistry::from_toml(toml_input).unwrap();
1221 let profile = reg.get("test").unwrap();
1222
1223 assert_eq!(profile.name, "test");
1224 assert_eq!(profile.target_selection, None);
1225 assert_eq!(
1226 profile.effective_target_selection(),
1227 TargetSelection::Selected(vec![1, 2])
1228 );
1229 assert_eq!(profile.use_targets, vec![1, 2]);
1230 assert_eq!(profile.close_ratios, vec![0.5, 0.5]);
1231 assert_eq!(profile.group_override.as_deref(), Some("my_group"));
1232 assert!(profile.let_remainder_run);
1233 assert_eq!(profile.rules.len(), 1);
1234 }
1235
1236 #[test]
1237 fn serde_target_selection_variants_are_actual_profile_fields() {
1238 let toml_input = r#"
1239[[profile]]
1240name = "all"
1241target_selection = "All"
1242use_targets = [1]
1243close_ratios = []
1244
1245[[profile]]
1246name = "none"
1247target_selection = "None"
1248use_targets = [1]
1249close_ratios = []
1250
1251[[profile]]
1252name = "selected"
1253target_selection = { Selected = [2, 1] }
1254use_targets = [1]
1255close_ratios = [0.6, 0.4]
1256"#;
1257 let registry = ProfileRegistry::from_toml(toml_input).unwrap();
1258
1259 assert_eq!(
1260 registry.get("all").unwrap().target_selection,
1261 Some(TargetSelection::All)
1262 );
1263 assert_eq!(
1264 registry.get("none").unwrap().target_selection,
1265 Some(TargetSelection::None)
1266 );
1267 assert_eq!(
1268 registry.get("selected").unwrap().target_selection,
1269 Some(TargetSelection::Selected(vec![2, 1]))
1270 );
1271
1272 let json = serde_json::to_value(registry.get("selected").unwrap()).unwrap();
1273 assert_eq!(
1274 json["target_selection"]["Selected"],
1275 serde_json::json!([2, 1])
1276 );
1277 assert_eq!(json["use_targets"], serde_json::json!([1]));
1278 }
1279
1280 #[test]
1281 fn legacy_profile_omits_target_selection_and_keeps_legacy_default() {
1282 let toml_input = r#"
1283[[profile]]
1284name = "legacy"
1285use_targets = [1]
1286close_ratios = [1.0]
1287"#;
1288 let registry = ProfileRegistry::from_toml(toml_input).unwrap();
1289 let profile = registry.get("legacy").unwrap();
1290
1291 assert_eq!(profile.target_selection, None);
1292 assert_eq!(
1293 profile.effective_target_selection(),
1294 TargetSelection::Selected(vec![1])
1295 );
1296 let json = serde_json::to_value(profile).unwrap();
1297 assert!(json.get("target_selection").is_none());
1298 assert_eq!(
1299 unwrap_open(&profile.apply_entry_signal(&buy_signal()).unwrap())
1300 .6
1301 .len(),
1302 1
1303 );
1304 }
1305
1306 #[test]
1309 fn profile_produces_valid_signals_for_runner() {
1310 use crate::data_feed::{MarketEvent, VecFeed};
1311 use crate::runner::BacktestRunner;
1312
1313 let toml = r#"
1314[[profile]]
1315name = "test"
1316use_targets = [1]
1317close_ratios = [1.0]
1318stoploss_mode = { type = "FromSignal" }
1319"#;
1320 let reg = ProfileRegistry::from_toml(toml).unwrap();
1321 let profile = reg.get("test").unwrap();
1322
1323 let raw_signals = vec![buy_signal()];
1324
1325 let events = vec![
1327 MarketEvent::Tick {
1328 symbol: "eurusd".into(),
1329 ts: ts(9, 59, 59),
1330 bid: 1.0848,
1331 ask: 1.0850,
1332 },
1333 MarketEvent::Tick {
1334 symbol: "eurusd".into(),
1335 ts: ts(10, 0, 0),
1336 bid: 1.0848,
1337 ask: 1.0850,
1338 },
1339 MarketEvent::Tick {
1340 symbol: "eurusd".into(),
1341 ts: ts(10, 0, 1),
1342 bid: 1.0860,
1343 ask: 1.0862,
1344 },
1345 MarketEvent::Tick {
1346 symbol: "eurusd".into(),
1347 ts: ts(10, 0, 2),
1348 bid: 1.0900,
1349 ask: 1.0902,
1350 },
1351 ];
1352
1353 let mut feed = VecFeed::new(events);
1354 let runner = BacktestRunner::new(entry_replay_config());
1355 let result = runner.run_raw_signals(&mut feed, raw_signals, Some(profile));
1356
1357 assert_eq!(result.total_trades, 1);
1359 assert!(result.total_pnl > 0.0);
1360 assert_eq!(result.trade_log[0].close_reason, CloseReason::Target);
1361 }
1362
1363 #[test]
1364 fn same_signals_different_profiles_different_results() {
1365 use crate::data_feed::{MarketEvent, VecFeed};
1366 use crate::runner::BacktestRunner;
1367
1368 let toml = r#"
1369[[profile]]
1370name = "tp1_only"
1371use_targets = [1]
1372close_ratios = [1.0]
1373
1374[[profile]]
1375name = "tp1_tp2"
1376use_targets = [1, 2]
1377close_ratios = [0.5, 0.5]
1378"#;
1379 let reg = ProfileRegistry::from_toml(toml).unwrap();
1380
1381 let raw_signals = vec![buy_signal()];
1382
1383 let events = vec![
1385 MarketEvent::Tick {
1386 symbol: "eurusd".into(),
1387 ts: ts(9, 59, 59),
1388 bid: 1.0848,
1389 ask: 1.0850,
1390 },
1391 MarketEvent::Tick {
1392 symbol: "eurusd".into(),
1393 ts: ts(10, 0, 0),
1394 bid: 1.0848,
1395 ask: 1.0850,
1396 },
1397 MarketEvent::Tick {
1398 symbol: "eurusd".into(),
1399 ts: ts(10, 0, 1),
1400 bid: 1.0900,
1401 ask: 1.0902,
1402 },
1403 MarketEvent::Tick {
1404 symbol: "eurusd".into(),
1405 ts: ts(10, 0, 2),
1406 bid: 1.0910,
1407 ask: 1.0912,
1408 },
1409 ];
1410
1411 let profile_a = reg.get("tp1_only").unwrap();
1413 let mut feed_a = VecFeed::new(events.clone());
1414 let config = entry_replay_config();
1415 let result_a = BacktestRunner::new(config.clone()).run_raw_signals(
1416 &mut feed_a,
1417 raw_signals.clone(),
1418 Some(profile_a),
1419 );
1420
1421 let profile_b = reg.get("tp1_tp2").unwrap();
1424 let mut feed_b = VecFeed::new(events);
1425 let result_b = BacktestRunner::new(config).run_raw_signals(
1426 &mut feed_b,
1427 raw_signals.clone(),
1428 Some(profile_b),
1429 );
1430
1431 assert!(result_a.total_trades >= 1);
1433 assert!(result_b.total_trades >= 1);
1434 assert!(
1437 (result_a.total_pnl - result_b.total_pnl).abs() > 1e-10
1438 || result_a.total_trades != result_b.total_trades,
1439 "Profiles should produce different results"
1440 );
1441 }
1442
1443 #[test]
1446 fn negative_ratio_error() {
1447 let toml = r#"
1448[[profile]]
1449name = "bad"
1450use_targets = [1]
1451close_ratios = [-0.5]
1452"#;
1453 let err = ProfileRegistry::from_toml(toml).unwrap_err();
1454 assert!(matches!(err, ProfileError::ZeroRatio { .. }));
1455 }
1456
1457 #[test]
1460 fn ratios_sum_exactly_1_0_is_valid() {
1461 let toml = r#"
1462[[profile]]
1463name = "ok"
1464use_targets = [1, 2]
1465close_ratios = [0.5, 0.5]
1466"#;
1467 let reg = ProfileRegistry::from_toml(toml);
1468 assert!(reg.is_ok());
1469 }
1470
1471 #[test]
1474 fn ratios_sum_less_than_1_0_is_valid() {
1475 let toml = r#"
1476[[profile]]
1477name = "ok"
1478use_targets = [1]
1479close_ratios = [0.3]
1480let_remainder_run = true
1481"#;
1482 let reg = ProfileRegistry::from_toml(toml);
1483 assert!(reg.is_ok());
1484 }
1485
1486 #[test]
1489 fn profile_with_trailing_stop_only() {
1490 let toml = r#"
1491[[profile]]
1492name = "trail_only"
1493use_targets = []
1494close_ratios = []
1495stoploss_mode = { type = "None" }
1496
1497[[profile.rules]]
1498type = "TrailingStop"
1499distance = 0.0030
1500"#;
1501 let reg = ProfileRegistry::from_toml(toml).unwrap();
1502 let profile = reg.get("trail_only").unwrap();
1503
1504 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
1505 let (_, _, _, _, _, sl, targets, rules, _) = unwrap_open(&action);
1506
1507 assert!(targets.is_empty());
1508 assert_eq!(sl, None);
1509 assert_eq!(rules.len(), 1);
1510 assert!(matches!(rules[0], RuleConfig::TrailingStop { .. }));
1511 }
1512
1513 #[test]
1516 fn profile_multiple_mixed_rules() {
1517 let toml = r#"
1518[[profile]]
1519name = "complex"
1520use_targets = [1]
1521close_ratios = [0.5]
1522let_remainder_run = true
1523
1524[[profile.rules]]
1525type = "BreakevenAfterTargets"
1526after_n = 1
1527
1528[[profile.rules]]
1529type = "TrailingStop"
1530distance = 0.0025
1531
1532[[profile.rules]]
1533type = "TimeExit"
1534max_seconds = 7200
1535"#;
1536 let reg = ProfileRegistry::from_toml(toml).unwrap();
1537 let profile = reg.get("complex").unwrap();
1538
1539 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
1540 let (_, _, _, _, _, _, _, rules, _) = unwrap_open(&action);
1541
1542 assert_eq!(rules.len(), 3);
1543 assert!(matches!(
1544 rules[0],
1545 RuleConfig::BreakevenAfterTargets { after_n: 1 }
1546 ));
1547 assert!(matches!(rules[1], RuleConfig::TrailingStop { .. }));
1548 assert!(matches!(
1549 rules[2],
1550 RuleConfig::TimeExit { max_seconds: 7200 }
1551 ));
1552 }
1553
1554 #[test]
1557 fn debug_output_does_not_panic() {
1558 let toml = r#"
1559[[profile]]
1560name = "test"
1561use_targets = [1]
1562close_ratios = [1.0]
1563"#;
1564 let reg = ProfileRegistry::from_toml(toml).unwrap();
1565 let debug = format!("{:?}", reg);
1566 assert!(debug.contains("ProfileRegistry"));
1567 assert!(debug.contains("test"));
1568 }
1569
1570 #[test]
1573 fn load_from_missing_file_returns_io_error() {
1574 let result = ProfileRegistry::load("/nonexistent/path/profiles.toml");
1575 assert!(matches!(result, Err(ProfileError::Io(_))));
1576 }
1577
1578 #[test]
1581 fn apply_with_fixed_stoploss_rule() {
1582 let toml = r#"
1583[[profile]]
1584name = "test"
1585use_targets = [1]
1586close_ratios = [1.0]
1587
1588[[profile.rules]]
1589type = "FixedStoploss"
1590price = 1.0750
1591"#;
1592 let reg = ProfileRegistry::from_toml(toml).unwrap();
1593 let profile = reg.get("test").unwrap();
1594
1595 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
1596 let (_, _, _, _, _, _, _, rules, _) = unwrap_open(&action);
1597
1598 assert_eq!(rules.len(), 1);
1599 assert!(matches!(
1600 rules[0],
1601 RuleConfig::FixedStoploss { price } if (price - 1.0750).abs() < f64::EPSILON
1602 ));
1603 }
1604
1605 #[test]
1608 fn apply_sell_targets_correctly_selected() {
1609 let toml = r#"
1610[[profile]]
1611name = "test"
1612use_targets = [1, 2]
1613close_ratios = [0.5, 0.5]
1614"#;
1615 let reg = ProfileRegistry::from_toml(toml).unwrap();
1616 let profile = reg.get("test").unwrap();
1617
1618 let action = profile.apply_entry_signal(&sell_signal()).unwrap();
1619 let (_, side, _, _, _, sl, targets, _, _) = unwrap_open(&action);
1620
1621 assert_eq!(side, Side::Sell);
1622 assert_eq!(sl, Some(1.0900)); assert_eq!(targets.len(), 2);
1624 assert!((targets[0].price - 1.0800).abs() < f64::EPSILON);
1625 assert!((targets[1].price - 1.0750).abs() < f64::EPSILON);
1626 }
1627
1628 #[test]
1631 fn insert_new_profile() {
1632 let mut reg = ProfileRegistry::empty();
1633 let p = ManagementProfile {
1634 name: "new".into(),
1635 target_selection: None,
1636 use_targets: vec![1],
1637 close_ratios: vec![1.0],
1638 stoploss_mode: StoplossMode::FromSignal,
1639 rules: vec![],
1640 group_override: None,
1641 let_remainder_run: false,
1642 };
1643 assert!(reg.insert(p, false).is_ok());
1644 assert_eq!(reg.len(), 1);
1645 assert!(reg.get("new").is_some());
1646 }
1647
1648 #[test]
1649 fn insert_duplicate_no_overwrite() {
1650 let mut reg = ProfileRegistry::empty();
1651 let p = ManagementProfile {
1652 name: "dup".into(),
1653 target_selection: None,
1654 use_targets: vec![1],
1655 close_ratios: vec![1.0],
1656 stoploss_mode: StoplossMode::FromSignal,
1657 rules: vec![],
1658 group_override: None,
1659 let_remainder_run: false,
1660 };
1661 reg.insert(p.clone(), false).unwrap();
1662 let result = reg.insert(p, false);
1663 assert!(result.is_err());
1664 match result.unwrap_err() {
1665 ProfileError::DuplicateName(n) => assert_eq!(n, "dup"),
1666 other => panic!("Expected DuplicateName, got: {other:?}"),
1667 }
1668 }
1669
1670 #[test]
1671 fn insert_duplicate_with_overwrite() {
1672 let mut reg = ProfileRegistry::empty();
1673 let p1 = ManagementProfile {
1674 name: "ow".into(),
1675 target_selection: None,
1676 use_targets: vec![1],
1677 close_ratios: vec![1.0],
1678 stoploss_mode: StoplossMode::FromSignal,
1679 rules: vec![],
1680 group_override: None,
1681 let_remainder_run: false,
1682 };
1683 reg.insert(p1, false).unwrap();
1684
1685 let p2 = ManagementProfile {
1686 name: "ow".into(),
1687 target_selection: None,
1688 use_targets: vec![1, 2],
1689 close_ratios: vec![0.5, 0.5],
1690 stoploss_mode: StoplossMode::FromSignal,
1691 rules: vec![],
1692 group_override: None,
1693 let_remainder_run: false,
1694 };
1695 assert!(reg.insert(p2, true).is_ok());
1696 assert_eq!(reg.len(), 1);
1697 assert_eq!(reg.get("ow").unwrap().use_targets, vec![1, 2]);
1698 }
1699
1700 #[test]
1701 fn insert_validates_profile() {
1702 let mut reg = ProfileRegistry::empty();
1703 let bad = ManagementProfile {
1704 name: "bad".into(),
1705 target_selection: None,
1706 use_targets: vec![1, 2],
1707 close_ratios: vec![1.0], stoploss_mode: StoplossMode::FromSignal,
1709 rules: vec![],
1710 group_override: None,
1711 let_remainder_run: false,
1712 };
1713 assert!(reg.insert(bad, false).is_err());
1714 assert_eq!(reg.len(), 0);
1715 }
1716
1717 #[test]
1718 fn remove_existing() {
1719 let mut reg = ProfileRegistry::empty();
1720 let p = ManagementProfile {
1721 name: "rm".into(),
1722 target_selection: None,
1723 use_targets: vec![1],
1724 close_ratios: vec![1.0],
1725 stoploss_mode: StoplossMode::FromSignal,
1726 rules: vec![],
1727 group_override: None,
1728 let_remainder_run: false,
1729 };
1730 reg.insert(p, false).unwrap();
1731 assert!(reg.remove("rm"));
1732 assert_eq!(reg.len(), 0);
1733 assert!(reg.get("rm").is_none());
1734 }
1735
1736 #[test]
1737 fn remove_nonexistent() {
1738 let mut reg = ProfileRegistry::empty();
1739 assert!(!reg.remove("nope"));
1740 }
1741
1742 #[test]
1743 fn validate_profile_public() {
1744 let good = ManagementProfile {
1745 name: "ok".into(),
1746 target_selection: None,
1747 use_targets: vec![1],
1748 close_ratios: vec![1.0],
1749 stoploss_mode: StoplossMode::FromSignal,
1750 rules: vec![],
1751 group_override: None,
1752 let_remainder_run: false,
1753 };
1754 assert!(good.validate().is_ok());
1755
1756 let bad = ManagementProfile {
1757 name: "bad".into(),
1758 target_selection: None,
1759 use_targets: vec![0], close_ratios: vec![1.0],
1761 stoploss_mode: StoplossMode::FromSignal,
1762 rules: vec![],
1763 group_override: None,
1764 let_remainder_run: false,
1765 };
1766 assert!(bad.validate().is_err());
1767 }
1768
1769 #[test]
1772 fn default_stoploss_mode_is_from_signal() {
1773 let toml = r#"
1774[[profile]]
1775name = "test"
1776use_targets = [1]
1777close_ratios = [1.0]
1778"#;
1779 let reg = ProfileRegistry::from_toml(toml).unwrap();
1780 let profile = reg.get("test").unwrap();
1781
1782 let action = profile.apply_entry_signal(&buy_signal()).unwrap();
1784 let (_, _, _, _, _, sl, _, _, _) = unwrap_open(&action);
1785 assert_eq!(sl, Some(1.0800));
1786 }
1787
1788 #[test]
1791 fn raw_signal_entry_has_correct_ts() {
1792 let sig = RawSignal::Entry {
1793 ts: ts(10, 0, 0),
1794 symbol: "eurusd".into(),
1795 side: Side::Buy,
1796 order_type: OrderType::Market,
1797 price: Some(1.0850),
1798 risk_multiplier: 1.0,
1799 stoploss: Some(1.0800),
1800 targets: vec![1.0900],
1801 group: None,
1802 trade_id: Some("t1".into()),
1803 };
1804 assert_eq!(sig.ts(), ts(10, 0, 0));
1805 }
1806
1807 #[test]
1808 fn raw_signal_close_has_correct_ts() {
1809 let sig = RawSignal::Close {
1810 ts: ts(11, 30, 0),
1811 position: PositionRef::ByTradeId {
1812 trade_id: "pos1".into(),
1813 },
1814 };
1815 assert_eq!(sig.ts(), ts(11, 30, 0));
1816 }
1817
1818 #[test]
1819 fn raw_signal_is_entry_true() {
1820 let sig = RawSignal::Entry {
1821 ts: ts(10, 0, 0),
1822 symbol: "eurusd".into(),
1823 side: Side::Buy,
1824 order_type: OrderType::Market,
1825 price: Some(1.0850),
1826 risk_multiplier: 1.0,
1827 stoploss: None,
1828 targets: vec![],
1829 group: None,
1830 trade_id: None,
1831 };
1832 assert!(sig.is_entry());
1833 }
1834
1835 #[test]
1836 fn raw_signal_is_entry_false_for_close() {
1837 let sig = RawSignal::Close {
1838 ts: ts(10, 0, 0),
1839 position: PositionRef::ByTradeId {
1840 trade_id: "eurusd".into(),
1841 },
1842 };
1843 assert!(!sig.is_entry());
1844 }
1845
1846 #[test]
1847 fn serde_roundtrip_raw_signal_entry_variant() {
1848 let sig = RawSignal::Entry {
1849 ts: ts(10, 0, 0),
1850 symbol: "eurusd".into(),
1851 side: Side::Buy,
1852 order_type: OrderType::Market,
1853 price: Some(1.0850),
1854 risk_multiplier: 1.25,
1855 stoploss: Some(1.0800),
1856 targets: vec![1.0900],
1857 group: None,
1858 trade_id: Some("t1".into()),
1859 };
1860 let json = serde_json::to_value(&sig).unwrap();
1861 assert_eq!(json["risk"], 1.25);
1862 assert!(json.get("risk_multiplier").is_none());
1863 assert!(json.get("size").is_none());
1864
1865 let back: RawSignal = serde_json::from_value(json).unwrap();
1866 assert!(matches!(
1867 back,
1868 RawSignal::Entry {
1869 risk_multiplier: 1.25,
1870 ..
1871 }
1872 ));
1873 }
1874
1875 #[test]
1876 fn raw_signal_entry_requires_risk() {
1877 let mut json = serde_json::to_value(buy_signal()).unwrap();
1878 json.as_object_mut().unwrap().remove("risk");
1879
1880 let error = serde_json::from_value::<RawSignal>(json).unwrap_err();
1881 assert!(error.to_string().contains("missing field `risk`"));
1882 }
1883
1884 #[test]
1885 fn raw_signal_entry_rejects_obsolete_size_field() {
1886 let mut json = serde_json::to_value(buy_signal()).unwrap();
1887 json.as_object_mut()
1888 .unwrap()
1889 .insert("size".into(), serde_json::json!(0.1));
1890
1891 let error = serde_json::from_value::<RawSignal>(json).unwrap_err();
1892 assert!(error.to_string().contains("unknown field `size`"));
1893 }
1894
1895 #[test]
1896 fn raw_signal_entry_rejects_non_positive_risk() {
1897 for risk in [0.0, -1.0] {
1898 let mut json = serde_json::to_value(buy_signal()).unwrap();
1899 json.as_object_mut()
1900 .unwrap()
1901 .insert("risk".into(), serde_json::json!(risk));
1902
1903 let error = serde_json::from_value::<RawSignal>(json).unwrap_err();
1904 assert!(
1905 error
1906 .to_string()
1907 .contains("risk must be finite and greater than zero")
1908 );
1909 }
1910 }
1911
1912 #[test]
1913 fn raw_signal_scale_in_keeps_size_wire_field() {
1914 let signal = RawSignal::ScaleIn {
1915 ts: ts(10, 0, 0),
1916 position: PositionRef::ByTradeId {
1917 trade_id: "t1".into(),
1918 },
1919 price: Some(1.0860),
1920 size: 0.5,
1921 };
1922 let json = serde_json::to_value(&signal).unwrap();
1923 assert_eq!(json["size"], 0.5);
1924 assert!(json.get("risk").is_none());
1925 assert!(matches!(
1926 serde_json::from_value::<RawSignal>(json).unwrap(),
1927 RawSignal::ScaleIn { size: 0.5, .. }
1928 ));
1929 }
1930
1931 #[test]
1932 fn serde_roundtrip_raw_signal_close() {
1933 let sig = RawSignal::Close {
1934 ts: ts(11, 0, 0),
1935 position: PositionRef::ByTradeId {
1936 trade_id: "pos123".into(),
1937 },
1938 };
1939 let json = serde_json::to_string(&sig).unwrap();
1940 let back: RawSignal = serde_json::from_str(&json).unwrap();
1941 assert!(!back.is_entry());
1942 assert_eq!(back.ts(), ts(11, 0, 0));
1943 }
1944
1945 #[test]
1946 fn serde_roundtrip_position_ref_all_variants() {
1947 let variants: Vec<PositionRef> = vec![
1948 PositionRef::ByTradeId {
1949 trade_id: "abc".into(),
1950 },
1951 PositionRef::AllOnSymbol {
1952 symbol: "eurusd".into(),
1953 },
1954 PositionRef::AllInGroup {
1955 group_id: "g1".into(),
1956 },
1957 ];
1958 for pr in &variants {
1959 let json = serde_json::to_string(pr).unwrap();
1960 let back: PositionRef = serde_json::from_str(&json).unwrap();
1961 let _debug = format!("{:?}", back);
1963 }
1964 }
1965
1966 struct MockResolver {
1970 ids: Vec<PositionId>,
1971 entry_info: Option<(f64, Side)>,
1972 }
1973
1974 impl MockResolver {
1975 fn with_ids(ids: Vec<&str>) -> Self {
1976 Self {
1977 ids: ids.into_iter().map(String::from).collect(),
1978 entry_info: None,
1979 }
1980 }
1981
1982 fn with_ids_and_info(ids: Vec<&str>, entry_price: f64, side: Side) -> Self {
1983 Self {
1984 ids: ids.into_iter().map(String::from).collect(),
1985 entry_info: Some((entry_price, side)),
1986 }
1987 }
1988
1989 fn empty() -> Self {
1990 Self {
1991 ids: vec![],
1992 entry_info: None,
1993 }
1994 }
1995 }
1996
1997 impl PositionResolver for MockResolver {
1998 fn resolve(&self, _pr: &PositionRef) -> Vec<PositionId> {
1999 self.ids.clone()
2000 }
2001
2002 fn position_entry_info(&self, _id: &PositionId) -> Option<(f64, Side)> {
2003 self.entry_info
2004 }
2005 }
2006
2007 #[test]
2008 fn resolve_signal_entry_returns_empty() {
2009 let sig = RawSignal::Entry {
2010 ts: ts(10, 0, 0),
2011 symbol: "eurusd".into(),
2012 side: Side::Buy,
2013 order_type: OrderType::Market,
2014 price: Some(1.0850),
2015 risk_multiplier: 1.0,
2016 stoploss: None,
2017 targets: vec![],
2018 group: None,
2019 trade_id: None,
2020 };
2021 let resolver = MockResolver::with_ids(vec!["pos1"]);
2022 let actions = resolve_signal(&sig, &resolver);
2023 assert!(actions.is_empty());
2024 }
2025
2026 #[test]
2027 fn resolve_signal_close_single() {
2028 let sig = RawSignal::Close {
2029 ts: ts(10, 0, 0),
2030 position: PositionRef::ByTradeId {
2031 trade_id: "pos1".into(),
2032 },
2033 };
2034 let resolver = MockResolver::with_ids(vec!["pos1"]);
2035 let actions = resolve_signal(&sig, &resolver);
2036 assert_eq!(actions.len(), 1);
2037 assert!(matches!(
2038 &actions[0],
2039 Action::ClosePosition { position_id } if position_id == "pos1"
2040 ));
2041 }
2042
2043 #[test]
2044 fn resolve_signal_close_multiple() {
2045 let sig = RawSignal::Close {
2046 ts: ts(10, 0, 0),
2047 position: PositionRef::AllOnSymbol {
2048 symbol: "eurusd".into(),
2049 },
2050 };
2051 let resolver = MockResolver::with_ids(vec!["pos1", "pos2", "pos3"]);
2052 let actions = resolve_signal(&sig, &resolver);
2053 assert_eq!(actions.len(), 3);
2054 }
2055
2056 #[test]
2057 fn resolve_signal_close_empty_resolver() {
2058 let sig = RawSignal::Close {
2059 ts: ts(10, 0, 0),
2060 position: PositionRef::ByTradeId {
2061 trade_id: "eurusd".into(),
2062 },
2063 };
2064 let resolver = MockResolver::empty();
2065 let actions = resolve_signal(&sig, &resolver);
2066 assert!(actions.is_empty());
2067 }
2068
2069 #[test]
2070 fn resolve_signal_close_partial() {
2071 let sig = RawSignal::ClosePartial {
2072 ts: ts(10, 0, 0),
2073 position: PositionRef::ByTradeId {
2074 trade_id: "pos1".into(),
2075 },
2076 ratio: 0.5,
2077 };
2078 let resolver = MockResolver::with_ids(vec!["pos1"]);
2079 let actions = resolve_signal(&sig, &resolver);
2080 assert_eq!(actions.len(), 1);
2081 match &actions[0] {
2082 Action::ClosePartial { position_id, ratio } => {
2083 assert_eq!(position_id, "pos1");
2084 assert!((ratio - 0.5).abs() < f64::EPSILON);
2085 }
2086 other => panic!("Expected ClosePartial, got {other:?}"),
2087 }
2088 }
2089
2090 #[test]
2091 fn resolve_signal_modify_stoploss() {
2092 let sig = RawSignal::ModifyStoploss {
2093 ts: ts(10, 0, 0),
2094 position: PositionRef::ByTradeId {
2095 trade_id: "pos1".into(),
2096 },
2097 price: 1.0820,
2098 };
2099 let resolver = MockResolver::with_ids(vec!["pos1"]);
2100 let actions = resolve_signal(&sig, &resolver);
2101 assert_eq!(actions.len(), 1);
2102 match &actions[0] {
2103 Action::ModifyStoploss { position_id, price } => {
2104 assert_eq!(position_id, "pos1");
2105 assert!((price - 1.0820).abs() < f64::EPSILON);
2106 }
2107 other => panic!("Expected ModifyStoploss, got {other:?}"),
2108 }
2109 }
2110
2111 #[test]
2112 fn resolve_signal_move_sl_to_entry() {
2113 let sig = RawSignal::MoveStoplossToEntry {
2114 ts: ts(10, 0, 0),
2115 position: PositionRef::ByTradeId {
2116 trade_id: "pos1".into(),
2117 },
2118 };
2119 let resolver = MockResolver::with_ids(vec!["pos1"]);
2120 let actions = resolve_signal(&sig, &resolver);
2121 assert_eq!(actions.len(), 1);
2122 assert!(matches!(
2123 &actions[0],
2124 Action::MoveStoplossToEntry { position_id } if position_id == "pos1"
2125 ));
2126 }
2127
2128 #[test]
2129 fn resolve_signal_add_target() {
2130 let sig = RawSignal::AddTarget {
2131 ts: ts(10, 0, 0),
2132 position: PositionRef::ByTradeId {
2133 trade_id: "pos1".into(),
2134 },
2135 price: 1.0950,
2136 close_ratio: 0.5,
2137 };
2138 let resolver = MockResolver::with_ids(vec!["pos1"]);
2139 let actions = resolve_signal(&sig, &resolver);
2140 assert_eq!(actions.len(), 1);
2141 match &actions[0] {
2142 Action::AddTarget {
2143 position_id,
2144 price,
2145 close_ratio,
2146 } => {
2147 assert_eq!(position_id, "pos1");
2148 assert!((price - 1.0950).abs() < f64::EPSILON);
2149 assert!((close_ratio - 0.5).abs() < f64::EPSILON);
2150 }
2151 other => panic!("Expected AddTarget, got {other:?}"),
2152 }
2153 }
2154
2155 #[test]
2156 fn resolve_signal_remove_target() {
2157 let sig = RawSignal::RemoveTarget {
2158 ts: ts(10, 0, 0),
2159 position: PositionRef::ByTradeId {
2160 trade_id: "pos1".into(),
2161 },
2162 price: 1.0950,
2163 };
2164 let resolver = MockResolver::with_ids(vec!["pos1"]);
2165 let actions = resolve_signal(&sig, &resolver);
2166 assert_eq!(actions.len(), 1);
2167 match &actions[0] {
2168 Action::RemoveTarget { position_id, price } => {
2169 assert_eq!(position_id, "pos1");
2170 assert!((price - 1.0950).abs() < f64::EPSILON);
2171 }
2172 other => panic!("Expected RemoveTarget, got {other:?}"),
2173 }
2174 }
2175
2176 #[test]
2177 fn resolve_signal_modify_target() {
2178 let sig = RawSignal::ModifyTarget {
2179 ts: ts(10, 0, 0),
2180 position: PositionRef::ByTradeId {
2181 trade_id: "pos1".into(),
2182 },
2183 old_price: 1.0950,
2184 new_price: 1.0975,
2185 };
2186 let resolver = MockResolver::with_ids(vec!["pos1"]);
2187
2188 let actions = resolve_signal(&sig, &resolver);
2189
2190 assert!(matches!(
2191 actions.as_slice(),
2192 [Action::ModifyTarget {
2193 position_id,
2194 old_price,
2195 new_price,
2196 }] if position_id == "pos1"
2197 && (*old_price - 1.0950).abs() < f64::EPSILON
2198 && (*new_price - 1.0975).abs() < f64::EPSILON
2199 ));
2200 }
2201
2202 #[test]
2203 fn resolve_signal_add_rule_with_entry_info() {
2204 let sig = RawSignal::AddRule {
2205 ts: ts(10, 0, 0),
2206 position: PositionRef::ByTradeId {
2207 trade_id: "pos1".into(),
2208 },
2209 rule: RuleConfigDef::BreakevenWhenOffset {
2210 trigger_price_offset: 0.0050,
2211 },
2212 };
2213 let resolver = MockResolver::with_ids_and_info(vec!["pos1"], 1.0850, Side::Buy);
2214 let actions = resolve_signal(&sig, &resolver);
2215 assert_eq!(actions.len(), 1);
2216 match &actions[0] {
2217 Action::AddRule { position_id, rule } => {
2218 assert_eq!(position_id, "pos1");
2219 match rule {
2220 RuleConfig::BreakevenWhen { trigger_price } => {
2221 assert!((trigger_price - 1.0900).abs() < 1e-10);
2222 }
2223 other => panic!("Expected BreakevenWhen, got {other:?}"),
2224 }
2225 }
2226 other => panic!("Expected AddRule, got {other:?}"),
2227 }
2228 }
2229
2230 #[test]
2231 fn resolve_signal_add_rule_no_entry_info_skips() {
2232 let sig = RawSignal::AddRule {
2233 ts: ts(10, 0, 0),
2234 position: PositionRef::ByTradeId {
2235 trade_id: "pos1".into(),
2236 },
2237 rule: RuleConfigDef::BreakevenWhenOffset {
2238 trigger_price_offset: 0.0050,
2239 },
2240 };
2241 let resolver = MockResolver::with_ids(vec!["pos1"]);
2243 let actions = resolve_signal(&sig, &resolver);
2244 assert!(actions.is_empty());
2245 }
2246
2247 #[test]
2248 fn resolve_signal_add_rule_trailing_stop() {
2249 let sig = RawSignal::AddRule {
2250 ts: ts(10, 0, 0),
2251 position: PositionRef::ByTradeId {
2252 trade_id: "pos1".into(),
2253 },
2254 rule: RuleConfigDef::TrailingStop { distance: 0.0030 },
2255 };
2256 let resolver = MockResolver::with_ids(vec!["pos1"]);
2257 let actions = resolve_signal(&sig, &resolver);
2258 assert_eq!(actions.len(), 1);
2259 match &actions[0] {
2260 Action::AddRule { rule, .. } => {
2261 assert!(
2262 matches!(rule, RuleConfig::TrailingStop { distance } if (*distance - 0.0030).abs() < f64::EPSILON)
2263 );
2264 }
2265 other => panic!("Expected AddRule, got {other:?}"),
2266 }
2267 }
2268
2269 #[test]
2270 fn resolve_signal_remove_rule() {
2271 let sig = RawSignal::RemoveRule {
2272 ts: ts(10, 0, 0),
2273 position: PositionRef::ByTradeId {
2274 trade_id: "pos1".into(),
2275 },
2276 rule_name: "TrailingStop".into(),
2277 };
2278 let resolver = MockResolver::with_ids(vec!["pos1"]);
2279 let actions = resolve_signal(&sig, &resolver);
2280 assert_eq!(actions.len(), 1);
2281 match &actions[0] {
2282 Action::RemoveRule {
2283 position_id,
2284 rule_name,
2285 } => {
2286 assert_eq!(position_id, "pos1");
2287 assert_eq!(rule_name, "TrailingStop");
2288 }
2289 other => panic!("Expected RemoveRule, got {other:?}"),
2290 }
2291 }
2292
2293 #[test]
2294 fn resolve_signal_scale_in() {
2295 let sig = RawSignal::ScaleIn {
2296 ts: ts(10, 0, 0),
2297 position: PositionRef::ByTradeId {
2298 trade_id: "pos1".into(),
2299 },
2300 price: Some(1.0860),
2301 size: 0.5,
2302 };
2303 let resolver = MockResolver::with_ids(vec!["pos1"]);
2304 let actions = resolve_signal(&sig, &resolver);
2305 assert_eq!(actions.len(), 1);
2306 match &actions[0] {
2307 Action::ScaleIn {
2308 position_id,
2309 price,
2310 size,
2311 ..
2312 } => {
2313 assert_eq!(position_id, "pos1");
2314 assert_eq!(*price, Some(1.0860));
2315 assert!((size - 0.5).abs() < f64::EPSILON);
2316 }
2317 other => panic!("Expected ScaleIn, got {other:?}"),
2318 }
2319 }
2320
2321 #[test]
2322 fn resolve_signal_cancel_pending() {
2323 let sig = RawSignal::CancelPending {
2324 ts: ts(10, 0, 0),
2325 position: PositionRef::ByTradeId {
2326 trade_id: "pos1".into(),
2327 },
2328 };
2329 let resolver = MockResolver::with_ids(vec!["pos1"]);
2330 let actions = resolve_signal(&sig, &resolver);
2331 assert_eq!(actions.len(), 1);
2332 assert!(matches!(
2333 &actions[0],
2334 Action::CancelPending { position_id } if position_id == "pos1"
2335 ));
2336 }
2337
2338 #[test]
2339 fn resolve_signal_bulk_close_all_of() {
2340 let sig = RawSignal::CloseAllOf {
2341 ts: ts(10, 0, 0),
2342 symbol: "eurusd".into(),
2343 };
2344 let resolver = MockResolver::empty();
2345 let actions = resolve_signal(&sig, &resolver);
2346 assert_eq!(actions.len(), 1);
2347 assert!(matches!(
2348 &actions[0],
2349 Action::CloseAllOf { symbol } if symbol == "eurusd"
2350 ));
2351 }
2352
2353 #[test]
2354 fn resolve_signal_bulk_close_all() {
2355 let sig = RawSignal::CloseAll { ts: ts(10, 0, 0) };
2356 let resolver = MockResolver::empty();
2357 let actions = resolve_signal(&sig, &resolver);
2358 assert_eq!(actions.len(), 1);
2359 assert!(matches!(&actions[0], Action::CloseAll));
2360 }
2361
2362 #[test]
2363 fn resolve_signal_bulk_cancel_all_pending() {
2364 let sig = RawSignal::CancelAllPending { ts: ts(10, 0, 0) };
2365 let resolver = MockResolver::empty();
2366 let actions = resolve_signal(&sig, &resolver);
2367 assert_eq!(actions.len(), 1);
2368 assert!(matches!(&actions[0], Action::CancelAllPending));
2369 }
2370
2371 #[test]
2372 fn resolve_signal_bulk_modify_all_stoploss() {
2373 let sig = RawSignal::ModifyAllStoploss {
2374 ts: ts(10, 0, 0),
2375 symbol: "eurusd".into(),
2376 price: 1.0780,
2377 };
2378 let resolver = MockResolver::empty();
2379 let actions = resolve_signal(&sig, &resolver);
2380 assert_eq!(actions.len(), 1);
2381 match &actions[0] {
2382 Action::ModifyAllStoploss { symbol, price } => {
2383 assert_eq!(symbol, "eurusd");
2384 assert!((price - 1.0780).abs() < f64::EPSILON);
2385 }
2386 other => panic!("Expected ModifyAllStoploss, got {other:?}"),
2387 }
2388 }
2389
2390 #[test]
2391 fn resolve_signal_bulk_close_all_in_group() {
2392 let sig = RawSignal::CloseAllInGroup {
2393 ts: ts(10, 0, 0),
2394 group_id: "g1".into(),
2395 };
2396 let resolver = MockResolver::empty();
2397 let actions = resolve_signal(&sig, &resolver);
2398 assert_eq!(actions.len(), 1);
2399 assert!(matches!(
2400 &actions[0],
2401 Action::CloseAllInGroup { group_id } if group_id == "g1"
2402 ));
2403 }
2404
2405 #[test]
2406 fn resolve_signal_bulk_modify_all_sl_in_group() {
2407 let sig = RawSignal::ModifyAllStoplossInGroup {
2408 ts: ts(10, 0, 0),
2409 group_id: "g1".into(),
2410 price: 1.0780,
2411 };
2412 let resolver = MockResolver::empty();
2413 let actions = resolve_signal(&sig, &resolver);
2414 assert_eq!(actions.len(), 1);
2415 match &actions[0] {
2416 Action::ModifyAllStoplossInGroup { group_id, price } => {
2417 assert_eq!(group_id, "g1");
2418 assert!((price - 1.0780).abs() < f64::EPSILON);
2419 }
2420 other => panic!("Expected ModifyAllStoplossInGroup, got {other:?}"),
2421 }
2422 }
2423
2424 #[test]
2425 fn apply_entry_signal_preserves_trade_id() {
2426 let profile = ManagementProfile {
2427 name: "test".into(),
2428 target_selection: None,
2429 use_targets: vec![1],
2430 close_ratios: vec![1.0],
2431 stoploss_mode: StoplossMode::FromSignal,
2432 rules: vec![],
2433 group_override: None,
2434 let_remainder_run: false,
2435 };
2436
2437 let signal = RawSignal::Entry {
2438 ts: ts(10, 0, 0),
2439 symbol: "eurusd".into(),
2440 side: Side::Buy,
2441 order_type: OrderType::Market,
2442 price: Some(1.0850),
2443 risk_multiplier: 1.0,
2444 stoploss: Some(1.0800),
2445 targets: vec![1.0900],
2446 group: None,
2447 trade_id: Some("t1".into()),
2448 };
2449
2450 let resolved = profile
2451 .apply_entry_signal(&signal)
2452 .expect("valid profile application")
2453 .expect("Expected resolved entry");
2454 assert_eq!(resolved.trade_id.as_deref(), Some("t1"));
2455 }
2456
2457 fn strict_profile(
2460 use_targets: Vec<usize>,
2461 close_ratios: Vec<f64>,
2462 let_remainder_run: bool,
2463 ) -> ManagementProfile {
2464 ManagementProfile {
2465 name: "strict".into(),
2466 target_selection: None,
2467 use_targets,
2468 close_ratios,
2469 stoploss_mode: StoplossMode::FromSignal,
2470 rules: vec![],
2471 group_override: None,
2472 let_remainder_run,
2473 }
2474 }
2475
2476 fn resolved_targets(resolved: &ResolvedEntry) -> &[TargetSpec] {
2477 &resolved.targets
2478 }
2479
2480 #[test]
2481 fn non_entry_returns_none() {
2482 let signal = RawSignal::CloseAll { ts: ts(10, 0, 0) };
2483 assert!(
2484 strict_profile(vec![1], vec![1.0], false)
2485 .apply_entry_signal(&signal)
2486 .unwrap()
2487 .is_none()
2488 );
2489 assert!(resolve_unprofiled_entry(&signal).unwrap().is_none());
2490 }
2491
2492 #[test]
2493 fn explicit_selection_wins_over_compatibility_field() {
2494 let mut profile = strict_profile(vec![1], vec![1.0], false);
2495 profile.target_selection = Some(TargetSelection::Selected(vec![2]));
2496
2497 let resolved = profile.apply_entry_signal(&buy_signal()).unwrap().unwrap();
2498 assert_eq!(
2499 resolved.target_resolution.selection,
2500 TargetSelection::Selected(vec![2])
2501 );
2502 assert_eq!(resolved_targets(&resolved)[0].price, 1.0950);
2503 }
2504
2505 #[test]
2506 fn explicit_all_and_none_are_honored() {
2507 let mut all = strict_profile(vec![1], vec![], false);
2508 all.target_selection = Some(TargetSelection::All);
2509 let all_resolved = all.apply_entry_signal(&buy_signal()).unwrap().unwrap();
2510 assert_eq!(
2511 all_resolved.target_resolution.selection,
2512 TargetSelection::All
2513 );
2514 assert_eq!(all_resolved.target_resolution.weights, vec![0.5, 0.5]);
2515 assert_eq!(resolved_targets(&all_resolved).len(), 2);
2516
2517 let mut none = strict_profile(vec![1], vec![], false);
2518 none.target_selection = Some(TargetSelection::None);
2519 let none_resolved = none.apply_entry_signal(&buy_signal()).unwrap().unwrap();
2520 assert_eq!(
2521 none_resolved.target_resolution.selection,
2522 TargetSelection::None
2523 );
2524 assert!(resolved_targets(&none_resolved).is_empty());
2525 }
2526
2527 #[test]
2528 fn selected_targets_preserve_selection_order_and_metadata() {
2529 let resolved = strict_profile(vec![2, 1], vec![0.6, 0.4], false)
2530 .apply_entry_signal(&buy_signal())
2531 .unwrap()
2532 .unwrap();
2533
2534 assert_eq!(
2535 resolved.target_resolution.selection,
2536 TargetSelection::Selected(vec![2, 1])
2537 );
2538 assert_eq!(resolved.target_resolution.selected_indices, vec![2, 1]);
2539 assert_eq!(resolved.target_resolution.weights, vec![0.6, 0.4]);
2540 assert_eq!(resolved.target_resolution.remainder, 0.0);
2541 let targets = resolved_targets(&resolved);
2542 assert_eq!(targets.len(), 2);
2543 assert_eq!(targets[0].price, 1.0950);
2544 assert_eq!(targets[1].price, 1.0900);
2545 }
2546
2547 #[test]
2548 fn empty_explicit_weights_default_to_equal_selected_weights() {
2549 let resolved = strict_profile(vec![1, 2], vec![], false)
2550 .apply_entry_signal(&buy_signal())
2551 .unwrap()
2552 .unwrap();
2553
2554 assert_eq!(resolved.target_resolution.weights, vec![0.5, 0.5]);
2555 assert_eq!(resolved.target_resolution.remainder, 0.0);
2556 assert_eq!(resolved_targets(&resolved)[0].close_ratio, 0.5);
2557 assert_eq!(resolved_targets(&resolved)[1].close_ratio, 0.5);
2558 }
2559
2560 #[test]
2561 fn empty_profile_selection_means_none() {
2562 let resolved = strict_profile(vec![], vec![], false)
2563 .apply_entry_signal(&buy_signal())
2564 .unwrap()
2565 .unwrap();
2566
2567 assert_eq!(resolved.target_resolution.selection, TargetSelection::None);
2568 assert!(resolved.target_resolution.selected_indices.is_empty());
2569 assert!(resolved.target_resolution.weights.is_empty());
2570 assert_eq!(resolved.target_resolution.remainder, 1.0);
2571 assert!(resolved_targets(&resolved).is_empty());
2572 }
2573
2574 #[test]
2575 fn unprofiled_uses_all_targets_with_equal_weights() {
2576 let mut signal = buy_signal();
2577 if let RawSignal::Entry {
2578 group, trade_id, ..
2579 } = &mut signal
2580 {
2581 *group = Some("source".into());
2582 *trade_id = Some("trade-1".into());
2583 }
2584 let resolved = resolve_unprofiled_entry(&signal).unwrap().unwrap();
2585
2586 assert_eq!(resolved.target_resolution.selection, TargetSelection::All);
2587 assert_eq!(resolved.target_resolution.selected_indices, vec![1, 2]);
2588 assert_eq!(resolved.target_resolution.weights, vec![0.5, 0.5]);
2589 assert_eq!(resolved.target_resolution.remainder, 0.0);
2590 assert_eq!(resolved.risk_multiplier, 1.0);
2591 assert_eq!(resolved.group.as_deref(), Some("source"));
2592 assert_eq!(resolved.trade_id.as_deref(), Some("trade-1"));
2593 assert_eq!(resolved.targets.len(), 2);
2594
2595 match resolved.into_action(0.25) {
2596 Action::Open {
2597 size,
2598 group,
2599 trade_id,
2600 targets,
2601 ..
2602 } => {
2603 assert_eq!(size, 0.25);
2604 assert_eq!(group.as_deref(), Some("source"));
2605 assert_eq!(trade_id.as_deref(), Some("trade-1"));
2606 assert_eq!(targets.len(), 2);
2607 }
2608 _ => panic!("Expected Action::Open"),
2609 }
2610 }
2611
2612 #[test]
2613 fn unprofiled_with_no_targets_is_valid() {
2614 let mut signal = buy_signal();
2615 if let RawSignal::Entry { targets, .. } = &mut signal {
2616 targets.clear();
2617 }
2618 let resolved = resolve_unprofiled_entry(&signal).unwrap().unwrap();
2619 assert_eq!(resolved.target_resolution.selection, TargetSelection::All);
2620 assert_eq!(resolved.target_resolution.remainder, 1.0);
2621 assert!(resolved_targets(&resolved).is_empty());
2622 }
2623
2624 #[test]
2625 fn rejects_zero_duplicate_and_missing_target_indices() {
2626 let zero = strict_profile(vec![0], vec![1.0], false)
2627 .apply_entry_signal(&buy_signal())
2628 .unwrap_err();
2629 assert_eq!(zero, ProfileApplicationError::ZeroTargetIndex);
2630
2631 let duplicate = strict_profile(vec![1, 1], vec![0.5, 0.5], false)
2632 .apply_entry_signal(&buy_signal())
2633 .unwrap_err();
2634 assert_eq!(
2635 duplicate,
2636 ProfileApplicationError::DuplicateTargetIndex { index: 1 }
2637 );
2638
2639 let missing = strict_profile(vec![3], vec![1.0], false)
2640 .apply_entry_signal(&buy_signal())
2641 .unwrap_err();
2642 assert_eq!(
2643 missing,
2644 ProfileApplicationError::MissingTargetIndex {
2645 index: 3,
2646 available: 2
2647 }
2648 );
2649 }
2650
2651 #[test]
2652 fn rejects_explicit_weight_count_mismatch() {
2653 let error = strict_profile(vec![1, 2], vec![1.0], false)
2654 .apply_entry_signal(&buy_signal())
2655 .unwrap_err();
2656 assert_eq!(
2657 error,
2658 ProfileApplicationError::TargetWeightCountMismatch {
2659 targets: 2,
2660 weights: 1
2661 }
2662 );
2663 }
2664
2665 #[test]
2666 fn rejects_non_positive_and_non_finite_weights() {
2667 for weight in [0.0, -0.1, f64::NAN, f64::INFINITY] {
2668 let error = strict_profile(vec![1], vec![weight], false)
2669 .apply_entry_signal(&buy_signal())
2670 .unwrap_err();
2671 assert!(matches!(
2672 error,
2673 ProfileApplicationError::InvalidTargetWeight { position: 1, .. }
2674 ));
2675 }
2676 }
2677
2678 #[test]
2679 fn enforces_weight_sum_and_reports_remainder() {
2680 let exceeded = strict_profile(vec![1, 2], vec![0.6, 0.5], true)
2681 .apply_entry_signal(&buy_signal())
2682 .unwrap_err();
2683 assert!(matches!(
2684 exceeded,
2685 ProfileApplicationError::TargetWeightSumExceeded { .. }
2686 ));
2687
2688 let incomplete = strict_profile(vec![1, 2], vec![0.3, 0.3], false)
2689 .apply_entry_signal(&buy_signal())
2690 .unwrap_err();
2691 assert!(matches!(
2692 incomplete,
2693 ProfileApplicationError::TargetWeightSumIncomplete { .. }
2694 ));
2695
2696 let resolved = strict_profile(vec![1, 2], vec![0.3, 0.3], true)
2697 .apply_entry_signal(&buy_signal())
2698 .unwrap()
2699 .unwrap();
2700 assert!((resolved.target_resolution.remainder - 0.4).abs() < 1e-12);
2701 }
2702
2703 #[test]
2704 fn validates_buy_and_sell_target_geometry_when_entry_known() {
2705 let mut buy = buy_signal();
2706 if let RawSignal::Entry { targets, .. } = &mut buy {
2707 targets[0] = 1.0800;
2708 }
2709 let buy_error = strict_profile(vec![1], vec![1.0], false)
2710 .apply_entry_signal(&buy)
2711 .unwrap_err();
2712 assert!(matches!(
2713 buy_error,
2714 ProfileApplicationError::InvalidTargetGeometry {
2715 index: 1,
2716 side: Side::Buy,
2717 ..
2718 }
2719 ));
2720
2721 let mut sell = sell_signal();
2722 if let RawSignal::Entry { targets, .. } = &mut sell {
2723 targets[0] = 1.0900;
2724 }
2725 let sell_error = strict_profile(vec![1], vec![1.0], false)
2726 .apply_entry_signal(&sell)
2727 .unwrap_err();
2728 assert!(matches!(
2729 sell_error,
2730 ProfileApplicationError::InvalidTargetGeometry {
2731 index: 1,
2732 side: Side::Sell,
2733 ..
2734 }
2735 ));
2736 }
2737
2738 #[test]
2739 fn skips_geometry_check_when_entry_price_is_unknown() {
2740 let mut signal = buy_signal();
2741 if let RawSignal::Entry { price, targets, .. } = &mut signal {
2742 *price = None;
2743 targets[0] = 1.0;
2744 }
2745 let resolved = strict_profile(vec![1], vec![1.0], false)
2746 .apply_entry_signal(&signal)
2747 .unwrap()
2748 .unwrap();
2749 assert_eq!(resolved_targets(&resolved)[0].price, 1.0);
2750 }
2751
2752 #[test]
2753 fn rejects_invalid_entry_numeric_inputs() {
2754 for risk_multiplier in [0.0, -1.0, f64::NAN, f64::INFINITY] {
2755 let mut signal = buy_signal();
2756 if let RawSignal::Entry {
2757 risk_multiplier: value,
2758 ..
2759 } = &mut signal
2760 {
2761 *value = risk_multiplier;
2762 }
2763 assert!(matches!(
2764 resolve_unprofiled_entry(&signal),
2765 Err(ProfileApplicationError::InvalidNumericInput { .. })
2766 ));
2767 }
2768
2769 let mut signal = buy_signal();
2770 if let RawSignal::Entry { targets, .. } = &mut signal {
2771 targets[1] = f64::NAN;
2772 }
2773 assert!(matches!(
2774 strict_profile(vec![1], vec![1.0], false).apply_entry_signal(&signal),
2775 Err(ProfileApplicationError::InvalidNumericInput { .. })
2776 ));
2777 }
2778
2779 #[test]
2780 fn rejects_invalid_profile_numeric_inputs() {
2781 let mut profile = strict_profile(vec![1], vec![1.0], false);
2782 profile.stoploss_mode = StoplossMode::FixedDistance { distance: 0.0 };
2783 assert!(matches!(
2784 profile.apply_entry_signal(&buy_signal()),
2785 Err(ProfileApplicationError::InvalidNumericInput { .. })
2786 ));
2787
2788 let mut profile = strict_profile(vec![1], vec![1.0], false);
2789 profile.rules = vec![RuleConfigDef::TrailingStop { distance: f64::NAN }];
2790 assert!(matches!(
2791 profile.apply_entry_signal(&buy_signal()),
2792 Err(ProfileApplicationError::InvalidNumericInput { .. })
2793 ));
2794
2795 let mut profile = strict_profile(vec![1], vec![1.0], false);
2796 profile.rules = vec![RuleConfigDef::TimeExit { max_seconds: 0 }];
2797 assert!(matches!(
2798 profile.apply_entry_signal(&buy_signal()),
2799 Err(ProfileApplicationError::InvalidCountInput { .. })
2800 ));
2801 }
2802
2803 #[test]
2804 fn canonical_apply_rejects_missing_target() {
2805 let profile = strict_profile(vec![3], vec![1.0], false);
2806 assert!(matches!(
2807 profile.apply_entry_signal(&buy_signal()),
2808 Err(ProfileApplicationError::MissingTargetIndex { index: 3, .. })
2809 ));
2810 }
2811
2812 #[test]
2815 fn allocate_target_steps_rejects_one_step_for_two_equal_targets() {
2816 assert_eq!(
2817 allocate_target_steps(1, &[0.5, 0.5], 0.0).unwrap_err(),
2818 ProfileApplicationError::ZeroUnitAllocation { position: 1 }
2819 );
2820 }
2821
2822 #[test]
2823 fn allocate_target_steps_splits_two_steps_between_equal_targets() {
2824 assert_eq!(
2825 allocate_target_steps(2, &[0.5, 0.5], 0.0).unwrap(),
2826 vec![1, 1]
2827 );
2828 }
2829
2830 #[test]
2831 fn allocate_target_steps_assigns_final_residue() {
2832 let steps = allocate_target_steps(10, &[0.333, 0.333, 0.334], 0.0).unwrap();
2833 assert_eq!(steps, vec![3, 3, 4]);
2834 assert_eq!(steps.iter().sum::<u64>(), 10);
2835 }
2836
2837 #[test]
2838 fn allocate_target_steps_leaves_runner_remainder_unallocated() {
2839 let steps = allocate_target_steps(10, &[0.3, 0.3], 0.4).unwrap();
2840 assert_eq!(steps, vec![3, 3]);
2841 assert_eq!(steps.iter().sum::<u64>(), 6);
2842 }
2843
2844 #[test]
2845 fn allocate_target_units_assigns_full_sum_residue_to_final_target() {
2846 let units = allocate_target_units(1.0, 0.1, &[0.333, 0.333, 0.334], 0.0).unwrap();
2847 assert_eq!(units, vec![3, 3, 4]);
2848 assert_eq!(units.iter().sum::<u64>(), 10);
2849 }
2850
2851 #[test]
2852 fn allocate_equal_weights_is_deterministic_at_lot_step_edges() {
2853 let equal_weights = vec![1.0 / 3.0; 3];
2854
2855 assert_eq!(
2858 allocate_target_units(0.3, 0.1, &equal_weights, 0.0).unwrap(),
2859 vec![1, 1, 1]
2860 );
2861
2862 assert_eq!(
2865 allocate_target_units(0.05, 0.01, &equal_weights, 0.0).unwrap(),
2866 vec![1, 1, 3]
2867 );
2868 }
2869
2870 #[test]
2871 fn allocate_target_units_leaves_intentional_remainder_unallocated() {
2872 let units = allocate_target_units(1.0, 0.1, &[0.3, 0.3], 0.4).unwrap();
2873 assert_eq!(units, vec![3, 3]);
2874 assert_eq!(units.iter().sum::<u64>(), 6);
2875 }
2876
2877 #[test]
2878 fn allocate_target_units_assigns_residue_even_when_remainder_is_allowed() {
2879 let units = allocate_target_units(1.0, 0.1, &[0.5, 0.5], 0.0).unwrap();
2880 assert_eq!(units, vec![5, 5]);
2881 }
2882
2883 #[test]
2884 fn allocate_target_units_rejects_invalid_size_step_and_alignment() {
2885 for (size, step) in [(0.0, 0.1), (1.0, 0.0), (f64::NAN, 0.1)] {
2886 assert!(matches!(
2887 allocate_target_units(size, step, &[1.0], 0.0),
2888 Err(ProfileApplicationError::InvalidNumericInput { .. })
2889 ));
2890 }
2891 assert!(matches!(
2892 allocate_target_units(1.0, 0.3, &[1.0], 0.0),
2893 Err(ProfileApplicationError::SizeNotMultipleOfLotStep { .. })
2894 ));
2895 }
2896
2897 #[test]
2898 fn allocate_target_units_rejects_zero_unit_allocations() {
2899 let error = allocate_target_units(0.02, 0.01, &[0.1, 0.9], 0.0).unwrap_err();
2900 assert_eq!(
2901 error,
2902 ProfileApplicationError::ZeroUnitAllocation { position: 1 }
2903 );
2904 }
2905
2906 #[test]
2907 fn profile_load_and_insert_validate_duplicates_and_all_numeric_fields_without_ratios() {
2908 let duplicate = r#"
2909[[profile]]
2910name = "duplicate"
2911use_targets = [1, 1]
2912close_ratios = []
2913"#;
2914 assert!(matches!(
2915 ProfileRegistry::from_toml(duplicate),
2916 Err(ProfileError::DuplicateTargetIndex { index: 1, .. })
2917 ));
2918
2919 let base_profile = || ManagementProfile {
2920 name: "invalid".into(),
2921 target_selection: Some(TargetSelection::Selected(vec![1])),
2922 use_targets: vec![1],
2923 close_ratios: vec![],
2924 stoploss_mode: StoplossMode::FromSignal,
2925 rules: vec![],
2926 group_override: None,
2927 let_remainder_run: false,
2928 };
2929
2930 let mut duplicate_selection = base_profile();
2931 duplicate_selection.target_selection = Some(TargetSelection::Selected(vec![1, 1]));
2932 assert!(matches!(
2933 ProfileRegistry::empty().insert(duplicate_selection, false),
2934 Err(ProfileError::DuplicateTargetIndex { index: 1, .. })
2935 ));
2936
2937 for mode in [
2938 StoplossMode::FixedDistance { distance: 0.0 },
2939 StoplossMode::FixedDistance { distance: f64::NAN },
2940 StoplossMode::FixedPrice { price: 0.0 },
2941 StoplossMode::FixedPrice {
2942 price: f64::INFINITY,
2943 },
2944 ] {
2945 let mut profile = base_profile();
2946 profile.stoploss_mode = mode;
2947 assert!(matches!(
2948 ProfileRegistry::empty().insert(profile, false),
2949 Err(ProfileError::InvalidConfiguration { .. })
2950 ));
2951 }
2952
2953 for rule in [
2954 RuleConfigDef::FixedStoploss { price: 0.0 },
2955 RuleConfigDef::TrailingStop { distance: f64::NAN },
2956 RuleConfigDef::TakeProfit {
2957 price: f64::INFINITY,
2958 close_ratio: 1.0,
2959 },
2960 RuleConfigDef::TakeProfit {
2961 price: 2.0,
2962 close_ratio: 1.1,
2963 },
2964 RuleConfigDef::BreakevenWhen { trigger_price: 0.0 },
2965 RuleConfigDef::BreakevenWhenOffset {
2966 trigger_price_offset: -1.0,
2967 },
2968 RuleConfigDef::BreakevenAfterTargets { after_n: 0 },
2969 RuleConfigDef::TimeExit { max_seconds: 0 },
2970 ] {
2971 let mut profile = base_profile();
2972 profile.rules.push(rule);
2973 assert!(matches!(
2974 ProfileRegistry::empty().insert(profile, false),
2975 Err(ProfileError::InvalidConfiguration { .. })
2976 ));
2977 }
2978 }
2979
2980 #[test]
2981 fn allocate_target_units_validates_weights_and_empty_input() {
2982 assert_eq!(
2983 allocate_target_units(1.0, 0.1, &[], 1.0).unwrap(),
2984 Vec::<u64>::new()
2985 );
2986 assert!(matches!(
2987 allocate_target_units(1.0, 0.1, &[0.4, 0.4], 0.0),
2988 Err(ProfileApplicationError::TargetWeightRemainderMismatch { .. })
2989 ));
2990 assert!(matches!(
2991 allocate_target_units(1.0, 0.1, &[0.6, 0.6], 0.0),
2992 Err(ProfileApplicationError::TargetWeightSumExceeded { .. })
2993 ));
2994 assert!(matches!(
2995 allocate_target_units(1.0, 0.1, &[f64::NAN], 0.0),
2996 Err(ProfileApplicationError::InvalidTargetWeight { .. })
2997 ));
2998 assert!(matches!(
2999 allocate_target_units(1.0, 0.1, &[0.5], -0.5),
3000 Err(ProfileApplicationError::InvalidRemainder { .. })
3001 ));
3002 assert!(matches!(
3003 allocate_target_units(1.0, 0.1, &[0.5], 0.4),
3004 Err(ProfileApplicationError::TargetWeightRemainderMismatch { .. })
3005 ));
3006 }
3007}