Skip to main content

qs_backtest/
profile.rs

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