Skip to main content

qs_core/
profile.rs

1//! Management profiles — decouple entry signals from trade management.
2//!
3//! A [`ManagementProfile`] resolves [`RawSignal::Entry`] fields before sizing.
4//! Resolved entries can be finalized into [`Action::Open`] calls after a concrete lot size is known.
5//! Profile definitions are loaded by an application-owned registry for comparison without recompilation.
6
7use std::collections::HashSet;
8
9use chrono::NaiveDateTime;
10use serde::{Deserialize, Serialize};
11
12use crate::TradeEngine;
13use crate::types::{
14    Action, GroupId, OrderType, PositionId, PositionStatus, RuleConfig, Side, TargetSpec, TradeId,
15};
16
17// ─── Errors ─────────────────────────────────────────────────────────────────
18
19/// Errors returned while validating a management profile definition.
20#[derive(Debug, thiserror::Error)]
21pub enum ProfileValidationError {
22    #[error(
23        "Profile '{profile}': selected target count ({targets}) does not match close_ratios length ({ratios})"
24    )]
25    TargetRatioMismatch {
26        profile: String,
27        targets: usize,
28        ratios: usize,
29    },
30
31    #[error("Profile '{profile}': close_ratios sum to {sum:.4}, which exceeds 1.0")]
32    RatioSumExceeded { profile: String, sum: f64 },
33
34    #[error(
35        "Profile '{profile}': close_ratios sum to {sum:.4}; they must sum to 1.0 when let_remainder_run is false"
36    )]
37    RatioSumIncomplete { profile: String, sum: f64 },
38
39    #[error("Profile '{profile}': close_ratios contains a non-finite or non-positive value")]
40    ZeroRatio { profile: String },
41
42    #[error("Profile '{profile}': target selection contains a 0 index (must be 1-indexed)")]
43    ZeroTargetIndex { profile: String },
44
45    #[error("Profile '{profile}': target index {index} is selected more than once")]
46    DuplicateTargetIndex { profile: String, index: usize },
47
48    #[error("Profile '{profile}': {reason}")]
49    InvalidConfiguration { profile: String, reason: String },
50}
51
52/// Strict validation failures returned by the canonical entry resolvers.
53#[derive(Debug, Clone, PartialEq, thiserror::Error)]
54pub enum ProfileApplicationError {
55    #[error("{field} must be finite and greater than zero, got {value}")]
56    InvalidNumericInput { field: String, value: f64 },
57
58    #[error("target indices are 1-based; index 0 is invalid")]
59    ZeroTargetIndex,
60
61    #[error("target index {index} is selected more than once")]
62    DuplicateTargetIndex { index: usize },
63
64    #[error("target price {price} is selected more than once")]
65    DuplicateTargetPrice { price: f64 },
66
67    #[error("target index {index} is missing; signal provides {available} target(s)")]
68    MissingTargetIndex { index: usize, available: usize },
69
70    #[error("selected target count ({targets}) does not match explicit weight count ({weights})")]
71    TargetWeightCountMismatch { targets: usize, weights: usize },
72
73    #[error("target weight {position} must be finite and greater than zero, got {weight}")]
74    InvalidTargetWeight { position: usize, weight: f64 },
75
76    #[error("target weights sum to {sum}, which exceeds 1.0")]
77    TargetWeightSumExceeded { sum: f64 },
78
79    #[error("target weights sum to {sum}; they must sum to 1.0 when no remainder runs")]
80    TargetWeightSumIncomplete { sum: f64 },
81
82    #[error(
83        "target {index} at {target} is invalid for {side} entry at {entry}: buy targets must be above entry and sell targets below entry"
84    )]
85    InvalidTargetGeometry {
86        index: usize,
87        side: Side,
88        entry: f64,
89        target: f64,
90    },
91
92    #[error(
93        "stop {stoploss} is invalid for {side} entry at {entry}: buy stops must be below entry and sell stops above entry"
94    )]
95    InvalidStopGeometry {
96        side: Side,
97        entry: f64,
98        stoploss: f64,
99    },
100
101    #[error("size {size} is not an integer multiple of lot_step {lot_step}")]
102    SizeNotMultipleOfLotStep { size: f64, lot_step: f64 },
103
104    #[error("size {size} and lot_step {lot_step} produce a lot count outside u64 range")]
105    LotUnitCountOverflow { size: f64, lot_step: f64 },
106
107    #[error("target allocation {position} rounds to zero lot units")]
108    ZeroUnitAllocation { position: usize },
109
110    #[error("allocation remainder must be finite and non-negative, got {remainder}")]
111    InvalidRemainder { remainder: f64 },
112
113    #[error(
114        "target weights sum to {sum}, but allocation remainder is {remainder}; together they must equal 1.0"
115    )]
116    TargetWeightRemainderMismatch { sum: f64, remainder: f64 },
117
118    #[error("{field} must be greater than zero, got {value}")]
119    InvalidCountInput { field: String, value: u64 },
120}
121
122// ─── PositionRef ────────────────────────────────────────────────────────────
123
124/// How a management signal references its target position(s).
125///
126/// Resolved at runtime by the backtest runner, which has access to
127/// engine state for lookup.
128///
129/// The minimal set is:
130/// - `ByTradeId`: the canonical parser path. Each entry carries an
131///   application-defined `trade_id`; management signals reference it.
132/// - `AllOnSymbol`: bulk close by symbol.
133/// - `AllInGroup`: bulk close by group.
134///
135/// `group` is a reporting tag (channel-level), while `trade_id` is the
136/// per-trade identity used for addressing.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(tag = "type")]
139pub enum PositionRef {
140    /// Target the position with the given application-defined trade id.
141    ByTradeId { trade_id: TradeId },
142    /// All open positions on a symbol.
143    AllOnSymbol { symbol: String },
144    /// All open positions in a group.
145    AllInGroup { group_id: GroupId },
146}
147
148// ─── RawSignal ──────────────────────────────────────────────────────────────
149
150fn deserialize_risk_multiplier<'de, D>(deserializer: D) -> Result<f64, D::Error>
151where
152    D: serde::Deserializer<'de>,
153{
154    let value = f64::deserialize(deserializer)?;
155    if value.is_finite() && value > 0.0 {
156        Ok(value)
157    } else {
158        Err(serde::de::Error::custom(format!(
159            "risk must be finite and greater than zero, got {value}"
160        )))
161    }
162}
163
164/// A raw signal from an external source — entry or management.
165///
166/// Covers both entry signals (which can be profile-transformed) and
167/// management signals (which pass through to the engine as-is after
168/// position resolution).
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(tag = "action", deny_unknown_fields)]
171pub enum RawSignal {
172    // ── Entry (profile-transformable) ───────────────────────────────
173    Entry {
174        ts: NaiveDateTime,
175        symbol: String,
176        side: Side,
177        order_type: OrderType,
178        price: Option<f64>,
179        #[serde(rename = "risk", deserialize_with = "deserialize_risk_multiplier")]
180        risk_multiplier: f64,
181        stoploss: Option<f64>,
182        #[serde(default)]
183        targets: Vec<f64>,
184        #[serde(default)]
185        group: Option<String>,
186        /// Application-defined trade identity. Required for `ByTradeId`
187        /// resolution. Older JSONL without this field is still accepted.
188        #[serde(default)]
189        trade_id: Option<TradeId>,
190    },
191
192    // ── Per-position management ─────────────────────────────────────
193    Close {
194        ts: NaiveDateTime,
195        position: PositionRef,
196    },
197    ClosePartial {
198        ts: NaiveDateTime,
199        position: PositionRef,
200        ratio: f64,
201    },
202    ModifyStoploss {
203        ts: NaiveDateTime,
204        position: PositionRef,
205        price: f64,
206    },
207    MoveStoplossToEntry {
208        ts: NaiveDateTime,
209        position: PositionRef,
210    },
211    AddTarget {
212        ts: NaiveDateTime,
213        position: PositionRef,
214        price: f64,
215        close_ratio: f64,
216    },
217    RemoveTarget {
218        ts: NaiveDateTime,
219        position: PositionRef,
220        price: f64,
221    },
222    ModifyTarget {
223        ts: NaiveDateTime,
224        position: PositionRef,
225        old_price: f64,
226        new_price: f64,
227    },
228    AddRule {
229        ts: NaiveDateTime,
230        position: PositionRef,
231        rule: RuleConfigDef,
232    },
233    RemoveRule {
234        ts: NaiveDateTime,
235        position: PositionRef,
236        rule_name: String,
237    },
238    ScaleIn {
239        ts: NaiveDateTime,
240        position: PositionRef,
241        price: Option<f64>,
242        size: f64,
243    },
244    CancelPending {
245        ts: NaiveDateTime,
246        position: PositionRef,
247    },
248
249    // ── Bulk actions ────────────────────────────────────────────────
250    CloseAllOf {
251        ts: NaiveDateTime,
252        symbol: String,
253    },
254    CloseAll {
255        ts: NaiveDateTime,
256    },
257    CancelAllPending {
258        ts: NaiveDateTime,
259    },
260    ModifyAllStoploss {
261        ts: NaiveDateTime,
262        symbol: String,
263        price: f64,
264    },
265    CloseAllInGroup {
266        ts: NaiveDateTime,
267        group_id: GroupId,
268    },
269    ModifyAllStoplossInGroup {
270        ts: NaiveDateTime,
271        group_id: GroupId,
272        price: f64,
273    },
274}
275
276impl RawSignal {
277    /// Extract the timestamp from any signal variant.
278    pub fn ts(&self) -> NaiveDateTime {
279        match self {
280            Self::Entry { ts, .. } => *ts,
281            Self::Close { ts, .. } => *ts,
282            Self::ClosePartial { ts, .. } => *ts,
283            Self::ModifyStoploss { ts, .. } => *ts,
284            Self::MoveStoplossToEntry { ts, .. } => *ts,
285            Self::AddTarget { ts, .. } => *ts,
286            Self::RemoveTarget { ts, .. } => *ts,
287            Self::ModifyTarget { ts, .. } => *ts,
288            Self::AddRule { ts, .. } => *ts,
289            Self::RemoveRule { ts, .. } => *ts,
290            Self::ScaleIn { ts, .. } => *ts,
291            Self::CancelPending { ts, .. } => *ts,
292            Self::CloseAllOf { ts, .. } => *ts,
293            Self::CloseAll { ts, .. } => *ts,
294            Self::CancelAllPending { ts, .. } => *ts,
295            Self::ModifyAllStoploss { ts, .. } => *ts,
296            Self::CloseAllInGroup { ts, .. } => *ts,
297            Self::ModifyAllStoplossInGroup { ts, .. } => *ts,
298        }
299    }
300
301    /// Returns `true` if this is an `Entry` variant.
302    pub fn is_entry(&self) -> bool {
303        matches!(self, Self::Entry { .. })
304    }
305}
306
307// ─── Position Resolution ────────────────────────────────────────────────────
308
309/// Resolves a `PositionRef` to concrete position ID(s) using engine state.
310pub trait PositionResolver {
311    /// Resolve a position reference to zero or more concrete position IDs.
312    fn resolve(&self, pr: &PositionRef) -> Vec<PositionId>;
313    /// Get entry info (average_entry, side) for a position.
314    fn position_entry_info(&self, id: &PositionId) -> Option<(f64, Side)>;
315}
316
317impl PositionResolver for TradeEngine {
318    fn resolve(&self, position: &PositionRef) -> Vec<PositionId> {
319        match position {
320            PositionRef::ByTradeId { trade_id } => {
321                self.manager.id_by_trade_id(trade_id).into_iter().collect()
322            }
323            PositionRef::AllOnSymbol { symbol } => self.manager.open_ids_by_symbol_sorted(symbol),
324            PositionRef::AllInGroup { group_id } => {
325                let mut ids = self.manager.open_ids_by_group(group_id);
326                ids.sort();
327                ids
328            }
329        }
330    }
331
332    fn position_entry_info(&self, id: &PositionId) -> Option<(f64, Side)> {
333        self.get_position(id).and_then(|position| {
334            if position.data.status == PositionStatus::Open {
335                Some((position.data.average_entry(), position.data.side))
336            } else {
337                None
338            }
339        })
340    }
341}
342
343/// Resolve a non-entry `RawSignal` into concrete `Action`(s).
344///
345/// Entry signals are not handled here — they go through the profile path.
346/// Returns an empty vec for `Entry` variants.
347pub fn resolve_signal(signal: &RawSignal, resolver: &impl PositionResolver) -> Vec<Action> {
348    match signal {
349        RawSignal::Entry { .. } => vec![],
350
351        RawSignal::Close { position, .. } => resolver
352            .resolve(position)
353            .into_iter()
354            .map(|id| Action::ClosePosition { position_id: id })
355            .collect(),
356
357        RawSignal::ClosePartial {
358            position, ratio, ..
359        } => resolver
360            .resolve(position)
361            .into_iter()
362            .map(|id| Action::ClosePartial {
363                position_id: id,
364                ratio: *ratio,
365            })
366            .collect(),
367
368        RawSignal::ModifyStoploss {
369            position, price, ..
370        } => resolver
371            .resolve(position)
372            .into_iter()
373            .map(|id| Action::ModifyStoploss {
374                position_id: id,
375                price: *price,
376            })
377            .collect(),
378
379        RawSignal::MoveStoplossToEntry { position, .. } => resolver
380            .resolve(position)
381            .into_iter()
382            .map(|id| Action::MoveStoplossToEntry { position_id: id })
383            .collect(),
384
385        RawSignal::AddTarget {
386            position,
387            price,
388            close_ratio,
389            ..
390        } => resolver
391            .resolve(position)
392            .into_iter()
393            .map(|id| Action::AddTarget {
394                position_id: id,
395                price: *price,
396                close_ratio: *close_ratio,
397            })
398            .collect(),
399
400        RawSignal::RemoveTarget {
401            position, price, ..
402        } => resolver
403            .resolve(position)
404            .into_iter()
405            .map(|id| Action::RemoveTarget {
406                position_id: id,
407                price: *price,
408            })
409            .collect(),
410
411        RawSignal::ModifyTarget {
412            position,
413            old_price,
414            new_price,
415            ..
416        } => resolver
417            .resolve(position)
418            .into_iter()
419            .map(|id| Action::ModifyTarget {
420                position_id: id,
421                old_price: *old_price,
422                new_price: *new_price,
423            })
424            .collect(),
425
426        RawSignal::AddRule { position, rule, .. } => {
427            resolver
428                .resolve(position)
429                .into_iter()
430                .filter_map(|id| {
431                    let info = resolver.position_entry_info(&id);
432                    let (entry_price, side) = match info {
433                        Some((ep, s)) => (Some(ep), s),
434                        None => (None, Side::Buy), // fallback side; resolve may return None
435                    };
436                    rule.resolve(entry_price, side)
437                        .map(|resolved_rule| Action::AddRule {
438                            position_id: id,
439                            rule: resolved_rule,
440                        })
441                })
442                .collect()
443        }
444
445        RawSignal::RemoveRule {
446            position,
447            rule_name,
448            ..
449        } => resolver
450            .resolve(position)
451            .into_iter()
452            .map(|id| Action::RemoveRule {
453                position_id: id,
454                rule_name: rule_name.clone(),
455            })
456            .collect(),
457
458        RawSignal::ScaleIn {
459            position,
460            price,
461            size,
462            ..
463        } => resolver
464            .resolve(position)
465            .into_iter()
466            .map(|id| Action::ScaleIn {
467                position_id: id,
468                price: *price,
469                size: *size,
470                trade_id: None,
471            })
472            .collect(),
473
474        RawSignal::CancelPending { position, .. } => resolver
475            .resolve(position)
476            .into_iter()
477            .map(|id| Action::CancelPending { position_id: id })
478            .collect(),
479
480        // ── Bulk actions — no resolution needed ─────────────────────
481        RawSignal::CloseAllOf { symbol, .. } => {
482            vec![Action::CloseAllOf {
483                symbol: symbol.clone(),
484            }]
485        }
486        RawSignal::CloseAll { .. } => {
487            vec![Action::CloseAll]
488        }
489        RawSignal::CancelAllPending { .. } => {
490            vec![Action::CancelAllPending]
491        }
492        RawSignal::ModifyAllStoploss { symbol, price, .. } => {
493            vec![Action::ModifyAllStoploss {
494                symbol: symbol.clone(),
495                price: *price,
496            }]
497        }
498        RawSignal::CloseAllInGroup { group_id, .. } => {
499            vec![Action::CloseAllInGroup {
500                group_id: group_id.clone(),
501            }]
502        }
503        RawSignal::ModifyAllStoplossInGroup {
504            group_id, price, ..
505        } => {
506            vec![Action::ModifyAllStoplossInGroup {
507                group_id: group_id.clone(),
508                price: *price,
509            }]
510        }
511    }
512}
513
514// ─── StoplossMode ───────────────────────────────────────────────────────────
515
516/// How the profile handles the stoploss from the raw signal.
517#[derive(Debug, Clone, Serialize, Deserialize)]
518#[serde(tag = "type")]
519pub enum StoplossMode {
520    /// Use the stoploss price from the signal as-is.
521    FromSignal,
522    /// No fixed stoploss (rely on trailing stop or time exit instead).
523    None,
524    /// Override with a fixed distance from entry price.
525    FixedDistance { distance: f64 },
526    /// Override with a specific absolute price.
527    FixedPrice { price: f64 },
528}
529
530// ─── TOML-friendly rule definition ──────────────────────────────────────────
531
532/// Profile-specific rule definition with `#[serde(tag = "type")]` for TOML.
533///
534/// Converts to the core `RuleConfig` enum. Includes an offset-based
535/// `BreakevenWhenOffset` variant that computes the absolute trigger price
536/// from the signal's entry price at apply time.
537#[derive(Debug, Clone, Serialize, Deserialize)]
538#[serde(tag = "type")]
539pub enum RuleConfigDef {
540    /// Fixed stoploss at an absolute price.
541    FixedStoploss { price: f64 },
542    /// Trailing stop with a fixed distance.
543    TrailingStop { distance: f64 },
544    /// Take profit at an absolute price with a close ratio.
545    TakeProfit { price: f64, close_ratio: f64 },
546    /// Breakeven trigger at an absolute price.
547    BreakevenWhen { trigger_price: f64 },
548    /// Breakeven trigger as an offset from the entry price (profile-specific).
549    BreakevenWhenOffset { trigger_price_offset: f64 },
550    /// Breakeven after N targets have been hit.
551    BreakevenAfterTargets { after_n: u32 },
552    /// Time-based exit after N seconds.
553    TimeExit { max_seconds: u64 },
554}
555
556impl RuleConfigDef {
557    /// Resolve this definition into a core `RuleConfig`.
558    ///
559    /// For offset-based variants, `entry_price` and `side` are needed
560    /// to compute the absolute trigger price. Returns `None` when the
561    /// offset variant is used but no entry price is available.
562    pub fn resolve(&self, entry_price: Option<f64>, side: Side) -> Option<RuleConfig> {
563        match self {
564            Self::FixedStoploss { price } => Some(RuleConfig::FixedStoploss { price: *price }),
565            Self::TrailingStop { distance } => Some(RuleConfig::TrailingStop {
566                distance: *distance,
567            }),
568            Self::TakeProfit { price, close_ratio } => Some(RuleConfig::TakeProfit {
569                price: *price,
570                close_ratio: *close_ratio,
571            }),
572            Self::BreakevenWhen { trigger_price } => Some(RuleConfig::BreakevenWhen {
573                trigger_price: *trigger_price,
574            }),
575            Self::BreakevenWhenOffset {
576                trigger_price_offset,
577            } => {
578                let entry = entry_price?;
579                let trigger = match side {
580                    Side::Buy => entry + trigger_price_offset,
581                    Side::Sell => entry - trigger_price_offset,
582                };
583                Some(RuleConfig::BreakevenWhen {
584                    trigger_price: trigger,
585                })
586            }
587            Self::BreakevenAfterTargets { after_n } => {
588                Some(RuleConfig::BreakevenAfterTargets { after_n: *after_n })
589            }
590            Self::TimeExit { max_seconds } => Some(RuleConfig::TimeExit {
591                max_seconds: *max_seconds,
592            }),
593        }
594    }
595}
596
597// ─── Strict target resolution ────────────────────────────────────────────────
598
599/// Which 1-based target indices participate in strict target resolution.
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601pub enum TargetSelection {
602    /// Use every target supplied by the entry signal, in signal order.
603    All,
604    /// Do not attach any targets.
605    None,
606    /// Use the listed 1-based signal target indices, in the listed order.
607    Selected(Vec<usize>),
608}
609
610/// Metadata describing how signal targets were selected and weighted.
611#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
612pub struct TargetResolution {
613    pub selection: TargetSelection,
614    /// Resolved 1-based indices in output order.
615    pub selected_indices: Vec<usize>,
616    /// Close weights corresponding one-to-one with `selected_indices`.
617    pub weights: Vec<f64>,
618    /// Fraction of the original position not assigned to a target.
619    pub remainder: f64,
620}
621
622/// A resolved entry that retains risk intent without assigning concrete lots.
623#[derive(Debug, Clone, Serialize, Deserialize)]
624pub struct ResolvedEntry {
625    pub risk_multiplier: f64,
626    pub symbol: String,
627    pub side: Side,
628    pub order_type: OrderType,
629    pub price: Option<f64>,
630    pub stoploss: Option<f64>,
631    pub targets: Vec<TargetSpec>,
632    pub rules: Vec<RuleConfig>,
633    pub group: Option<GroupId>,
634    pub trade_id: Option<TradeId>,
635    pub target_resolution: TargetResolution,
636}
637
638impl ResolvedEntry {
639    /// Finalize the resolved entry with a concrete lot size.
640    pub fn into_action(self, lot_size: f64) -> Action {
641        Action::Open {
642            symbol: self.symbol,
643            side: self.side,
644            order_type: self.order_type,
645            price: self.price,
646            size: lot_size,
647            stoploss: self.stoploss,
648            targets: self.targets,
649            rules: self.rules,
650            group: self.group,
651            trade_id: self.trade_id,
652        }
653    }
654}
655
656// ─── ManagementProfile ──────────────────────────────────────────────────────
657
658/// A named management profile that resolves raw entry signals before sizing.
659#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct ManagementProfile {
661    /// Profile name (e.g. "conservative", "aggressive", "runner").
662    pub name: String,
663
664    /// Explicit current target selection. When present, this takes precedence
665    /// over `use_targets` for [`Self::apply_entry_signal`]. When omitted,
666    /// compatibility decoding derives the prior behavior from `use_targets`: an empty vector means
667    /// [`TargetSelection::None`], otherwise it means [`TargetSelection::Selected`].
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub target_selection: Option<TargetSelection>,
670
671    /// Compatibility target selection (1-indexed), retained so existing serialized
672    /// profiles remain readable.
673    pub use_targets: Vec<usize>,
674
675    /// Close ratio for each selected target. In current application, an empty
676    /// vector assigns equal weights to all selected targets; otherwise its
677    /// length must match the effective target selection.
678    pub close_ratios: Vec<f64>,
679
680    /// How to handle the stoploss from the signal.
681    #[serde(default = "default_stoploss_mode")]
682    pub stoploss_mode: StoplossMode,
683
684    /// Additional rules to attach to every position opened with this profile.
685    #[serde(default)]
686    pub rules: Vec<RuleConfigDef>,
687
688    /// If set, override the signal's group tag with this value.
689    #[serde(default)]
690    pub group_override: Option<String>,
691
692    /// When true and ratios sum < 1.0, the remainder rides with just SL/rules.
693    #[serde(default)]
694    pub let_remainder_run: bool,
695}
696
697fn default_stoploss_mode() -> StoplossMode {
698    StoplossMode::FromSignal
699}
700
701impl ManagementProfile {
702    /// Return the target selection used by current application.
703    ///
704    /// The explicit `target_selection` field wins when present. Otherwise this
705    /// preserves existing profile behavior by deriving `None`/`Selected` from
706    /// `use_targets`.
707    pub fn effective_target_selection(&self) -> TargetSelection {
708        self.target_selection.clone().unwrap_or_else(|| {
709            if self.use_targets.is_empty() {
710                TargetSelection::None
711            } else {
712                TargetSelection::Selected(self.use_targets.clone())
713            }
714        })
715    }
716
717    /// Validate this profile's configuration.
718    pub fn validate(&self) -> Result<(), ProfileValidationError> {
719        validate_profile(self)
720    }
721
722    /// Transform a `RawSignal::Entry` while retaining target-resolution metadata.
723    ///
724    /// This canonical path rejects malformed numeric values, target selections,
725    /// geometry, and weights. An explicit `target_selection` takes precedence; when it is
726    /// omitted, an empty `use_targets` means [`TargetSelection::None`] and non-empty
727    /// `use_targets` means [`TargetSelection::Selected`]. When targets are selected and
728    /// `close_ratios` is empty, equal `1 / N` weights are synthesized.
729    pub fn apply_entry_signal(
730        &self,
731        signal: &RawSignal,
732    ) -> Result<Option<ResolvedEntry>, ProfileApplicationError> {
733        let (
734            symbol,
735            side,
736            order_type,
737            price,
738            risk_multiplier,
739            signal_stoploss,
740            signal_targets,
741            group,
742            trade_id,
743        ) = match signal {
744            RawSignal::Entry {
745                symbol,
746                side,
747                order_type,
748                price,
749                risk_multiplier,
750                stoploss,
751                targets,
752                group,
753                trade_id,
754                ..
755            } => (
756                symbol,
757                side,
758                order_type,
759                price,
760                risk_multiplier,
761                stoploss,
762                targets,
763                group,
764                trade_id,
765            ),
766            _ => return Ok(None),
767        };
768
769        validate_entry_numbers(*price, *risk_multiplier, *signal_stoploss, signal_targets)?;
770
771        let selection = self.effective_target_selection();
772        let (targets, target_resolution) = resolve_targets(
773            signal_targets,
774            *side,
775            *price,
776            selection,
777            &self.close_ratios,
778            self.let_remainder_run,
779        )?;
780        let stoploss = resolve_stoploss(&self.stoploss_mode, *signal_stoploss, *price, *side)?;
781        let rules = resolve_rules(&self.rules, *price, *side)?;
782
783        Ok(Some(ResolvedEntry {
784            risk_multiplier: *risk_multiplier,
785            symbol: symbol.clone(),
786            side: *side,
787            order_type: *order_type,
788            price: *price,
789            stoploss,
790            targets,
791            rules,
792            group: self.group_override.clone().or(group.clone()),
793            trade_id: trade_id.clone(),
794            target_resolution,
795        }))
796    }
797}
798
799const WEIGHT_TOLERANCE: f64 = 1e-12;
800const LOT_ALIGNMENT_TOLERANCE: f64 = 1e-9;
801
802fn require_positive_finite(
803    field: impl Into<String>,
804    value: f64,
805) -> Result<(), ProfileApplicationError> {
806    if value.is_finite() && value > 0.0 {
807        Ok(())
808    } else {
809        Err(ProfileApplicationError::InvalidNumericInput {
810            field: field.into(),
811            value,
812        })
813    }
814}
815
816fn validate_entry_numbers(
817    price: Option<f64>,
818    risk_multiplier: f64,
819    stoploss: Option<f64>,
820    targets: &[f64],
821) -> Result<(), ProfileApplicationError> {
822    require_positive_finite("risk_multiplier", risk_multiplier)?;
823    if let Some(price) = price {
824        require_positive_finite("price", price)?;
825    }
826    if let Some(stoploss) = stoploss {
827        require_positive_finite("stoploss", stoploss)?;
828    }
829    for (offset, &target) in targets.iter().enumerate() {
830        require_positive_finite(format!("target {}", offset + 1), target)?;
831    }
832    Ok(())
833}
834
835fn weights_sum_to_one(sum: f64) -> bool {
836    (sum - 1.0).abs() <= WEIGHT_TOLERANCE
837}
838
839fn validate_weights(
840    weights: &[f64],
841    let_remainder_run: bool,
842) -> Result<f64, ProfileApplicationError> {
843    for (offset, &weight) in weights.iter().enumerate() {
844        if !weight.is_finite() || weight <= 0.0 {
845            return Err(ProfileApplicationError::InvalidTargetWeight {
846                position: offset + 1,
847                weight,
848            });
849        }
850    }
851
852    let sum: f64 = weights.iter().sum();
853    if !sum.is_finite() || sum > 1.0 + WEIGHT_TOLERANCE {
854        return Err(ProfileApplicationError::TargetWeightSumExceeded { sum });
855    }
856    if !let_remainder_run && !weights_sum_to_one(sum) {
857        return Err(ProfileApplicationError::TargetWeightSumIncomplete { sum });
858    }
859
860    Ok(if weights_sum_to_one(sum) {
861        0.0
862    } else {
863        1.0 - sum
864    })
865}
866
867fn resolve_targets(
868    signal_targets: &[f64],
869    side: Side,
870    entry_price: Option<f64>,
871    selection: TargetSelection,
872    explicit_weights: &[f64],
873    let_remainder_run: bool,
874) -> Result<(Vec<TargetSpec>, TargetResolution), ProfileApplicationError> {
875    let selected_indices = match &selection {
876        TargetSelection::All => (1..=signal_targets.len()).collect(),
877        TargetSelection::None => Vec::new(),
878        TargetSelection::Selected(indices) => {
879            let mut seen = HashSet::with_capacity(indices.len());
880            for &index in indices {
881                if index == 0 {
882                    return Err(ProfileApplicationError::ZeroTargetIndex);
883                }
884                if !seen.insert(index) {
885                    return Err(ProfileApplicationError::DuplicateTargetIndex { index });
886                }
887                if index > signal_targets.len() {
888                    return Err(ProfileApplicationError::MissingTargetIndex {
889                        index,
890                        available: signal_targets.len(),
891                    });
892                }
893            }
894            indices.clone()
895        }
896    };
897
898    if selected_indices.is_empty() {
899        if !explicit_weights.is_empty() {
900            return Err(ProfileApplicationError::TargetWeightCountMismatch {
901                targets: 0,
902                weights: explicit_weights.len(),
903            });
904        }
905        return Ok((
906            Vec::new(),
907            TargetResolution {
908                selection,
909                selected_indices,
910                weights: Vec::new(),
911                remainder: 1.0,
912            },
913        ));
914    }
915
916    let weights = if explicit_weights.is_empty() {
917        vec![1.0 / selected_indices.len() as f64; selected_indices.len()]
918    } else {
919        if explicit_weights.len() != selected_indices.len() {
920            return Err(ProfileApplicationError::TargetWeightCountMismatch {
921                targets: selected_indices.len(),
922                weights: explicit_weights.len(),
923            });
924        }
925        explicit_weights.to_vec()
926    };
927    let remainder = validate_weights(&weights, let_remainder_run)?;
928
929    let mut targets = Vec::with_capacity(selected_indices.len());
930    let mut target_price_keys = HashSet::with_capacity(selected_indices.len());
931    for (&index, &weight) in selected_indices.iter().zip(&weights) {
932        let target = signal_targets[index - 1];
933        let target_key = (target * 1_000_000.0).round() as i64;
934        if !target_price_keys.insert(target_key) {
935            return Err(ProfileApplicationError::DuplicateTargetPrice { price: target });
936        }
937        if let Some(entry) = entry_price {
938            let valid_geometry = match side {
939                Side::Buy => target > entry,
940                Side::Sell => target < entry,
941            };
942            if !valid_geometry {
943                return Err(ProfileApplicationError::InvalidTargetGeometry {
944                    index,
945                    side,
946                    entry,
947                    target,
948                });
949            }
950        }
951        targets.push(TargetSpec {
952            price: target,
953            close_ratio: weight,
954        });
955    }
956
957    Ok((
958        targets,
959        TargetResolution {
960            selection,
961            selected_indices,
962            weights,
963            remainder,
964        },
965    ))
966}
967
968fn validate_stop_geometry(
969    side: Side,
970    entry: f64,
971    stoploss: f64,
972) -> Result<(), ProfileApplicationError> {
973    let valid = match side {
974        Side::Buy => stoploss < entry,
975        Side::Sell => stoploss > entry,
976    };
977    if valid {
978        Ok(())
979    } else {
980        Err(ProfileApplicationError::InvalidStopGeometry {
981            side,
982            entry,
983            stoploss,
984        })
985    }
986}
987
988fn validate_target_geometry(
989    index: usize,
990    side: Side,
991    entry: f64,
992    target: f64,
993) -> Result<(), ProfileApplicationError> {
994    let valid = match side {
995        Side::Buy => target > entry,
996        Side::Sell => target < entry,
997    };
998    if valid {
999        Ok(())
1000    } else {
1001        Err(ProfileApplicationError::InvalidTargetGeometry {
1002            index,
1003            side,
1004            entry,
1005            target,
1006        })
1007    }
1008}
1009
1010fn resolve_stoploss(
1011    mode: &StoplossMode,
1012    signal_stoploss: Option<f64>,
1013    entry_price: Option<f64>,
1014    side: Side,
1015) -> Result<Option<f64>, ProfileApplicationError> {
1016    let stoploss = match mode {
1017        StoplossMode::FromSignal => signal_stoploss,
1018        StoplossMode::None => None,
1019        StoplossMode::FixedDistance { distance } => {
1020            require_positive_finite("stoploss fixed distance", *distance)?;
1021            entry_price.map(|entry| match side {
1022                Side::Buy => entry - distance,
1023                Side::Sell => entry + distance,
1024            })
1025        }
1026        StoplossMode::FixedPrice { price } => {
1027            require_positive_finite("stoploss fixed price", *price)?;
1028            Some(*price)
1029        }
1030    };
1031    if let Some(stoploss) = stoploss {
1032        require_positive_finite("resolved stoploss", stoploss)?;
1033        if let Some(entry) = entry_price {
1034            validate_stop_geometry(side, entry, stoploss)?;
1035        }
1036    }
1037    Ok(stoploss)
1038}
1039
1040fn resolve_rules(
1041    definitions: &[RuleConfigDef],
1042    entry_price: Option<f64>,
1043    side: Side,
1044) -> Result<Vec<RuleConfig>, ProfileApplicationError> {
1045    let mut rules = Vec::with_capacity(definitions.len());
1046    for (offset, definition) in definitions.iter().enumerate() {
1047        let position = offset + 1;
1048        match definition {
1049            RuleConfigDef::FixedStoploss { price } => {
1050                require_positive_finite(format!("rule {position} fixed stoploss price"), *price)?;
1051                if let Some(entry) = entry_price {
1052                    validate_stop_geometry(side, entry, *price)?;
1053                }
1054            }
1055            RuleConfigDef::TrailingStop { distance } => {
1056                require_positive_finite(format!("rule {position} trailing distance"), *distance)?;
1057                if let Some(entry) = entry_price {
1058                    let initial_stop = match side {
1059                        Side::Buy => entry - distance,
1060                        Side::Sell => entry + distance,
1061                    };
1062                    require_positive_finite(
1063                        format!("rule {position} initial trailing stop"),
1064                        initial_stop,
1065                    )?;
1066                    validate_stop_geometry(side, entry, initial_stop)?;
1067                }
1068            }
1069            RuleConfigDef::TakeProfit { price, close_ratio } => {
1070                require_positive_finite(format!("rule {position} take-profit price"), *price)?;
1071                require_positive_finite(
1072                    format!("rule {position} take-profit close ratio"),
1073                    *close_ratio,
1074                )?;
1075                if *close_ratio > 1.0 {
1076                    return Err(ProfileApplicationError::InvalidTargetWeight {
1077                        position,
1078                        weight: *close_ratio,
1079                    });
1080                }
1081                if let Some(entry) = entry_price {
1082                    validate_target_geometry(position, side, entry, *price)?;
1083                }
1084            }
1085            RuleConfigDef::BreakevenWhen { trigger_price } => {
1086                require_positive_finite(
1087                    format!("rule {position} breakeven trigger price"),
1088                    *trigger_price,
1089                )?;
1090                if let Some(entry) = entry_price {
1091                    validate_target_geometry(position, side, entry, *trigger_price)?;
1092                }
1093            }
1094            RuleConfigDef::BreakevenWhenOffset {
1095                trigger_price_offset,
1096            } => {
1097                require_positive_finite(
1098                    format!("rule {position} breakeven trigger offset"),
1099                    *trigger_price_offset,
1100                )?;
1101            }
1102            RuleConfigDef::BreakevenAfterTargets { after_n } => {
1103                if *after_n == 0 {
1104                    return Err(ProfileApplicationError::InvalidCountInput {
1105                        field: format!("rule {position} breakeven target count"),
1106                        value: 0,
1107                    });
1108                }
1109            }
1110            RuleConfigDef::TimeExit { max_seconds } => {
1111                if *max_seconds == 0 {
1112                    return Err(ProfileApplicationError::InvalidCountInput {
1113                        field: format!("rule {position} maximum seconds"),
1114                        value: 0,
1115                    });
1116                }
1117            }
1118        }
1119
1120        if let Some(rule) = definition.resolve(entry_price, side) {
1121            if let RuleConfig::BreakevenWhen { trigger_price } = &rule {
1122                require_positive_finite(
1123                    format!("rule {position} resolved breakeven trigger"),
1124                    *trigger_price,
1125                )?;
1126                if let Some(entry) = entry_price {
1127                    validate_target_geometry(position, side, entry, *trigger_price)?;
1128                }
1129            }
1130            rules.push(rule);
1131        }
1132    }
1133    Ok(rules)
1134}
1135
1136/// Strictly resolve an entry without a management profile.
1137///
1138/// Every signal target is retained and receives an equal `1 / N` close weight.
1139/// Non-entry signals return `Ok(None)`.
1140pub fn resolve_unprofiled_entry(
1141    signal: &RawSignal,
1142) -> Result<Option<ResolvedEntry>, ProfileApplicationError> {
1143    let (
1144        symbol,
1145        side,
1146        order_type,
1147        price,
1148        risk_multiplier,
1149        stoploss,
1150        signal_targets,
1151        group,
1152        trade_id,
1153    ) = match signal {
1154        RawSignal::Entry {
1155            symbol,
1156            side,
1157            order_type,
1158            price,
1159            risk_multiplier,
1160            stoploss,
1161            targets,
1162            group,
1163            trade_id,
1164            ..
1165        } => (
1166            symbol,
1167            side,
1168            order_type,
1169            price,
1170            risk_multiplier,
1171            stoploss,
1172            targets,
1173            group,
1174            trade_id,
1175        ),
1176        _ => return Ok(None),
1177    };
1178
1179    validate_entry_numbers(*price, *risk_multiplier, *stoploss, signal_targets)?;
1180    let (targets, target_resolution) = resolve_targets(
1181        signal_targets,
1182        *side,
1183        *price,
1184        TargetSelection::All,
1185        &[],
1186        false,
1187    )?;
1188
1189    Ok(Some(ResolvedEntry {
1190        risk_multiplier: *risk_multiplier,
1191        symbol: symbol.clone(),
1192        side: *side,
1193        order_type: *order_type,
1194        price: *price,
1195        stoploss: *stoploss,
1196        targets,
1197        rules: Vec::new(),
1198        group: group.clone(),
1199        trade_id: trade_id.clone(),
1200        target_resolution,
1201    }))
1202}
1203
1204/// Allocate target close weights from authoritative integer lot steps.
1205/// Each non-final target rounds down, while a fully allocated final target receives all remaining steps.
1206/// A positive runner remainder is intentionally left unallocated, and `weights + remainder` must equal one.
1207pub fn allocate_target_steps(
1208    total_steps: u64,
1209    weights: &[f64],
1210    remainder: f64,
1211) -> Result<Vec<u64>, ProfileApplicationError> {
1212    if total_steps == 0 {
1213        return Err(ProfileApplicationError::InvalidCountInput {
1214            field: "total_steps".into(),
1215            value: total_steps,
1216        });
1217    }
1218    if !remainder.is_finite() || remainder < 0.0 {
1219        return Err(ProfileApplicationError::InvalidRemainder { remainder });
1220    }
1221    if weights.is_empty() {
1222        if weights_sum_to_one(remainder) {
1223            return Ok(Vec::new());
1224        }
1225        return Err(ProfileApplicationError::TargetWeightRemainderMismatch {
1226            sum: 0.0,
1227            remainder,
1228        });
1229    }
1230
1231    let computed_remainder = validate_weights(weights, true)?;
1232    let weight_sum = 1.0 - computed_remainder;
1233    if !weights_sum_to_one(weight_sum + remainder) {
1234        return Err(ProfileApplicationError::TargetWeightRemainderMismatch {
1235            sum: weight_sum,
1236            remainder,
1237        });
1238    }
1239    let assign_residue_to_final = weights_sum_to_one(weight_sum);
1240
1241    let mut allocations = Vec::with_capacity(weights.len());
1242    let mut allocated = 0_u64;
1243    for (offset, &weight) in weights.iter().enumerate() {
1244        let is_final = offset + 1 == weights.len();
1245        let steps = if is_final && assign_residue_to_final {
1246            total_steps.saturating_sub(allocated)
1247        } else {
1248            ((total_steps as f64) * weight).floor() as u64
1249        };
1250        if steps == 0 {
1251            return Err(ProfileApplicationError::ZeroUnitAllocation {
1252                position: offset + 1,
1253            });
1254        }
1255        allocated = allocated.saturating_add(steps);
1256        allocations.push(steps);
1257    }
1258
1259    Ok(allocations)
1260}
1261
1262/// Convert an aligned floating lot size to steps and delegate to [`allocate_target_steps`].
1263pub fn allocate_target_units(
1264    size: f64,
1265    lot_step: f64,
1266    weights: &[f64],
1267    remainder: f64,
1268) -> Result<Vec<u64>, ProfileApplicationError> {
1269    require_positive_finite("size", size)?;
1270    require_positive_finite("lot_step", lot_step)?;
1271
1272    let raw_units = size / lot_step;
1273    if !raw_units.is_finite() || raw_units >= u64::MAX as f64 {
1274        return Err(ProfileApplicationError::LotUnitCountOverflow { size, lot_step });
1275    }
1276    let rounded_units = raw_units.round();
1277    let alignment_tolerance = LOT_ALIGNMENT_TOLERANCE * raw_units.abs().max(1.0);
1278    if (raw_units - rounded_units).abs() > alignment_tolerance || rounded_units < 1.0 {
1279        return Err(ProfileApplicationError::SizeNotMultipleOfLotStep { size, lot_step });
1280    }
1281
1282    allocate_target_steps(rounded_units as u64, weights, remainder)
1283}
1284
1285/// Validate a management profile without performing configuration I/O.
1286pub fn validate_profile(p: &ManagementProfile) -> Result<(), ProfileValidationError> {
1287    let selection = p.effective_target_selection();
1288
1289    // Empty ratios are the strict sentinel for equal target weights.
1290    // Explicit ratios must correspond one-to-one when the selected target
1291    // count is profile-known. `All` is signal-dependent and is checked by
1292    // `apply_entry_signal` once the signal targets are available.
1293    let selected_count = match &selection {
1294        TargetSelection::All => None,
1295        TargetSelection::None => Some(0),
1296        TargetSelection::Selected(indices) => Some(indices.len()),
1297    };
1298    if let Some(targets) = selected_count
1299        && !p.close_ratios.is_empty()
1300        && targets != p.close_ratios.len()
1301    {
1302        return Err(ProfileValidationError::TargetRatioMismatch {
1303            profile: p.name.clone(),
1304            targets,
1305            ratios: p.close_ratios.len(),
1306        });
1307    }
1308
1309    // Keep both the legacy field and the effective strict selection safe even
1310    // when an explicit selection takes precedence.
1311    let mut seen = HashSet::new();
1312    for &index in &p.use_targets {
1313        if index == 0 {
1314            return Err(ProfileValidationError::ZeroTargetIndex {
1315                profile: p.name.clone(),
1316            });
1317        }
1318        if !seen.insert(index) {
1319            return Err(ProfileValidationError::DuplicateTargetIndex {
1320                profile: p.name.clone(),
1321                index,
1322            });
1323        }
1324    }
1325    if let TargetSelection::Selected(indices) = &selection {
1326        seen.clear();
1327        for &index in indices {
1328            if index == 0 {
1329                return Err(ProfileValidationError::ZeroTargetIndex {
1330                    profile: p.name.clone(),
1331                });
1332            }
1333            if !seen.insert(index) {
1334                return Err(ProfileValidationError::DuplicateTargetIndex {
1335                    profile: p.name.clone(),
1336                    index,
1337                });
1338            }
1339        }
1340    }
1341
1342    resolve_stoploss(&p.stoploss_mode, None, None, Side::Buy).map_err(|error| {
1343        ProfileValidationError::InvalidConfiguration {
1344            profile: p.name.clone(),
1345            reason: error.to_string(),
1346        }
1347    })?;
1348    resolve_rules(&p.rules, None, Side::Buy).map_err(|error| {
1349        ProfileValidationError::InvalidConfiguration {
1350            profile: p.name.clone(),
1351            reason: error.to_string(),
1352        }
1353    })?;
1354
1355    if p.close_ratios.is_empty() {
1356        return Ok(());
1357    }
1358
1359    match validate_weights(&p.close_ratios, p.let_remainder_run) {
1360        Ok(_) => Ok(()),
1361        Err(ProfileApplicationError::InvalidTargetWeight { .. }) => {
1362            Err(ProfileValidationError::ZeroRatio {
1363                profile: p.name.clone(),
1364            })
1365        }
1366        Err(ProfileApplicationError::TargetWeightSumExceeded { sum }) => {
1367            Err(ProfileValidationError::RatioSumExceeded {
1368                profile: p.name.clone(),
1369                sum,
1370            })
1371        }
1372        Err(ProfileApplicationError::TargetWeightSumIncomplete { sum }) => {
1373            Err(ProfileValidationError::RatioSumIncomplete {
1374                profile: p.name.clone(),
1375                sum,
1376            })
1377        }
1378        Err(error) => unreachable!("unexpected profile weight validation error: {error}"),
1379    }
1380}