Skip to main content

qs_backtest/
profile.rs

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