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