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 qs_instruments::{AdjustmentDirection, Decimal, DecimalGrid, GridRounding};
11use serde::{Deserialize, Serialize};
12
13use crate::TradeEngine;
14use crate::types::{
15    Action, GroupId, OrderType, PositionId, PositionStatus, RuleConfig, Side, TargetSpec, TradeId,
16};
17
18// ─── Errors ─────────────────────────────────────────────────────────────────
19
20/// Errors returned while validating a management profile definition.
21#[derive(Debug, thiserror::Error)]
22pub enum ProfileValidationError {
23    #[error(
24        "Profile '{profile}': selected target count ({targets}) does not match close_ratios length ({ratios})"
25    )]
26    TargetRatioMismatch {
27        profile: String,
28        targets: usize,
29        ratios: usize,
30    },
31
32    #[error("Profile '{profile}': close_ratios sum to {sum:.4}, which exceeds 1.0")]
33    RatioSumExceeded { profile: String, sum: f64 },
34
35    #[error(
36        "Profile '{profile}': close_ratios sum to {sum:.4}; they must sum to 1.0 when let_remainder_run is false"
37    )]
38    RatioSumIncomplete { profile: String, sum: f64 },
39
40    #[error("Profile '{profile}': close_ratios contains a non-finite or non-positive value")]
41    ZeroRatio { profile: String },
42
43    #[error("Profile '{profile}': target selection contains a 0 index (must be 1-indexed)")]
44    ZeroTargetIndex { profile: String },
45
46    #[error("Profile '{profile}': target index {index} is selected more than once")]
47    DuplicateTargetIndex { profile: String, index: usize },
48
49    #[error("Profile '{profile}': {reason}")]
50    InvalidConfiguration { profile: String, reason: String },
51}
52
53/// Strict validation failures returned by the canonical entry resolvers.
54#[derive(Debug, Clone, PartialEq, thiserror::Error)]
55pub enum ProfileApplicationError {
56    #[error("{field} must be finite and greater than zero, got {value}")]
57    InvalidNumericInput { field: String, value: f64 },
58
59    #[error("target indices are 1-based; index 0 is invalid")]
60    ZeroTargetIndex,
61
62    #[error("target index {index} is selected more than once")]
63    DuplicateTargetIndex { index: usize },
64
65    #[error("target price {price} is selected more than once")]
66    DuplicateTargetPrice { price: f64 },
67
68    #[error("target index {index} is missing; signal provides {available} target(s)")]
69    MissingTargetIndex { index: usize, available: usize },
70
71    #[error("selected target count ({targets}) does not match explicit weight count ({weights})")]
72    TargetWeightCountMismatch { targets: usize, weights: usize },
73
74    #[error("target weight {position} must be finite and greater than zero, got {weight}")]
75    InvalidTargetWeight { position: usize, weight: f64 },
76
77    #[error("target weights sum to {sum}, which exceeds 1.0")]
78    TargetWeightSumExceeded { sum: f64 },
79
80    #[error("target weights sum to {sum}; they must sum to 1.0 when no remainder runs")]
81    TargetWeightSumIncomplete { sum: f64 },
82
83    #[error(
84        "target {index} at {target} is invalid for {side} entry at {entry}: buy targets must be above entry and sell targets below entry"
85    )]
86    InvalidTargetGeometry {
87        index: usize,
88        side: Side,
89        entry: f64,
90        target: f64,
91    },
92
93    #[error(
94        "stop {stoploss} is invalid for {side} entry at {entry}: buy stops must be below entry and sell stops above entry"
95    )]
96    InvalidStopGeometry {
97        side: Side,
98        entry: f64,
99        stoploss: f64,
100    },
101
102    #[error("size {size} is not an integer multiple of lot_step {lot_step}")]
103    SizeNotMultipleOfLotStep { size: f64, lot_step: f64 },
104
105    #[error("size {size} and lot_step {lot_step} produce a lot count outside u64 range")]
106    LotUnitCountOverflow { size: f64, lot_step: f64 },
107
108    #[error("target allocation {position} rounds to zero lot units")]
109    ZeroUnitAllocation { position: usize },
110
111    #[error("allocation remainder must be finite and non-negative, got {remainder}")]
112    InvalidRemainder { remainder: f64 },
113
114    #[error(
115        "target weights sum to {sum}, but allocation remainder is {remainder}; together they must equal 1.0"
116    )]
117    TargetWeightRemainderMismatch { sum: f64, remainder: f64 },
118
119    #[error("{field} must be greater than zero, got {value}")]
120    InvalidCountInput { field: String, value: u64 },
121
122    #[error("entry resolution requires a price grid for {mode}")]
123    MissingPriceGrid { mode: &'static str },
124
125    #[error("entry resolution requires an entry price for {mode}")]
126    MissingEntryPrice { mode: &'static str },
127
128    #[error("entry resolution requires a signal stoploss for {mode}")]
129    MissingSignalStoploss { mode: &'static str },
130
131    #[error("price-grid resolution failed: {reason}")]
132    PriceGrid { reason: String },
133}
134
135// ─── PositionRef ────────────────────────────────────────────────────────────
136
137/// How a management signal references its target position(s).
138///
139/// Resolved at runtime by the backtest runner, which has access to
140/// engine state for lookup.
141///
142/// The minimal set is:
143/// - `ByTradeId`: the canonical parser path. Each entry carries an
144///   application-defined `trade_id`; management signals reference it.
145/// - `AllOnSymbol`: bulk close by symbol.
146/// - `AllInGroup`: bulk close by group.
147///
148/// `group` is a reporting tag (channel-level), while `trade_id` is the
149/// per-trade identity used for addressing.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(tag = "type")]
152pub enum PositionRef {
153    /// Target the position with the given application-defined trade id.
154    ByTradeId { trade_id: TradeId },
155    /// All open positions on a symbol.
156    AllOnSymbol { symbol: String },
157    /// All open positions in a group.
158    AllInGroup { group_id: GroupId },
159}
160
161// ─── RawSignal ──────────────────────────────────────────────────────────────
162
163fn deserialize_risk_multiplier<'de, D>(deserializer: D) -> Result<f64, D::Error>
164where
165    D: serde::Deserializer<'de>,
166{
167    let value = f64::deserialize(deserializer)?;
168    if value.is_finite() && value > 0.0 {
169        Ok(value)
170    } else {
171        Err(serde::de::Error::custom(format!(
172            "risk must be finite and greater than zero, got {value}"
173        )))
174    }
175}
176
177/// A raw signal from an external source — entry or management.
178///
179/// Covers both entry signals (which can be profile-transformed) and
180/// management signals (which pass through to the engine as-is after
181/// position resolution).
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(tag = "action", deny_unknown_fields)]
184pub enum RawSignal {
185    // ── Entry (profile-transformable) ───────────────────────────────
186    Entry {
187        ts: NaiveDateTime,
188        symbol: String,
189        side: Side,
190        order_type: OrderType,
191        price: Option<f64>,
192        #[serde(rename = "risk", deserialize_with = "deserialize_risk_multiplier")]
193        risk_multiplier: f64,
194        stoploss: Option<f64>,
195        #[serde(default)]
196        targets: Vec<f64>,
197        #[serde(default)]
198        group: Option<String>,
199        /// Application-defined trade identity. Required for `ByTradeId`
200        /// resolution. Older JSONL without this field is still accepted.
201        #[serde(default)]
202        trade_id: Option<TradeId>,
203        /// Optional semantic class used by replay-owned profile routing.
204        #[serde(default, skip_serializing_if = "Option::is_none")]
205        entry_class: Option<String>,
206    },
207
208    // ── Per-position management ─────────────────────────────────────
209    Close {
210        ts: NaiveDateTime,
211        position: PositionRef,
212    },
213    ClosePartial {
214        ts: NaiveDateTime,
215        position: PositionRef,
216        ratio: f64,
217    },
218    ModifyStoploss {
219        ts: NaiveDateTime,
220        position: PositionRef,
221        price: f64,
222    },
223    MoveStoplossToEntry {
224        ts: NaiveDateTime,
225        position: PositionRef,
226    },
227    AddTarget {
228        ts: NaiveDateTime,
229        position: PositionRef,
230        price: f64,
231        close_ratio: f64,
232    },
233    RemoveTarget {
234        ts: NaiveDateTime,
235        position: PositionRef,
236        price: f64,
237    },
238    ModifyTarget {
239        ts: NaiveDateTime,
240        position: PositionRef,
241        old_price: f64,
242        new_price: f64,
243    },
244    AddRule {
245        ts: NaiveDateTime,
246        position: PositionRef,
247        rule: RuleConfigDef,
248    },
249    RemoveRule {
250        ts: NaiveDateTime,
251        position: PositionRef,
252        rule_name: String,
253    },
254    ScaleIn {
255        ts: NaiveDateTime,
256        position: PositionRef,
257        price: Option<f64>,
258        size: f64,
259    },
260    CancelPending {
261        ts: NaiveDateTime,
262        position: PositionRef,
263    },
264
265    // ── Bulk actions ────────────────────────────────────────────────
266    CloseAllOf {
267        ts: NaiveDateTime,
268        symbol: String,
269    },
270    CloseAll {
271        ts: NaiveDateTime,
272    },
273    CancelAllPending {
274        ts: NaiveDateTime,
275    },
276    ModifyAllStoploss {
277        ts: NaiveDateTime,
278        symbol: String,
279        price: f64,
280    },
281    CloseAllInGroup {
282        ts: NaiveDateTime,
283        group_id: GroupId,
284    },
285    ModifyAllStoplossInGroup {
286        ts: NaiveDateTime,
287        group_id: GroupId,
288        price: f64,
289    },
290}
291
292impl RawSignal {
293    /// Extract the timestamp from any signal variant.
294    pub fn ts(&self) -> NaiveDateTime {
295        match self {
296            Self::Entry { ts, .. } => *ts,
297            Self::Close { ts, .. } => *ts,
298            Self::ClosePartial { ts, .. } => *ts,
299            Self::ModifyStoploss { ts, .. } => *ts,
300            Self::MoveStoplossToEntry { ts, .. } => *ts,
301            Self::AddTarget { ts, .. } => *ts,
302            Self::RemoveTarget { ts, .. } => *ts,
303            Self::ModifyTarget { ts, .. } => *ts,
304            Self::AddRule { ts, .. } => *ts,
305            Self::RemoveRule { ts, .. } => *ts,
306            Self::ScaleIn { ts, .. } => *ts,
307            Self::CancelPending { ts, .. } => *ts,
308            Self::CloseAllOf { ts, .. } => *ts,
309            Self::CloseAll { ts, .. } => *ts,
310            Self::CancelAllPending { ts, .. } => *ts,
311            Self::ModifyAllStoploss { ts, .. } => *ts,
312            Self::CloseAllInGroup { ts, .. } => *ts,
313            Self::ModifyAllStoplossInGroup { ts, .. } => *ts,
314        }
315    }
316
317    /// Returns `true` if this is an `Entry` variant.
318    pub fn is_entry(&self) -> bool {
319        matches!(self, Self::Entry { .. })
320    }
321}
322
323// ─── Position Resolution ────────────────────────────────────────────────────
324
325/// Resolves a `PositionRef` to concrete position ID(s) using engine state.
326pub trait PositionResolver {
327    /// Resolve a position reference to zero or more concrete position IDs.
328    fn resolve(&self, pr: &PositionRef) -> Vec<PositionId>;
329    /// Get entry info (average_entry, side) for a position.
330    fn position_entry_info(&self, id: &PositionId) -> Option<(f64, Side)>;
331}
332
333impl PositionResolver for TradeEngine {
334    fn resolve(&self, position: &PositionRef) -> Vec<PositionId> {
335        match position {
336            PositionRef::ByTradeId { trade_id } => {
337                self.manager.id_by_trade_id(trade_id).into_iter().collect()
338            }
339            PositionRef::AllOnSymbol { symbol } => self.manager.open_ids_by_symbol_sorted(symbol),
340            PositionRef::AllInGroup { group_id } => {
341                let mut ids = self.manager.open_ids_by_group(group_id);
342                ids.sort();
343                ids
344            }
345        }
346    }
347
348    fn position_entry_info(&self, id: &PositionId) -> Option<(f64, Side)> {
349        self.get_position(id).and_then(|position| {
350            if position.data.status == PositionStatus::Open {
351                Some((position.data.average_entry(), position.data.side))
352            } else {
353                None
354            }
355        })
356    }
357}
358
359/// Resolve a non-entry `RawSignal` into concrete `Action`(s).
360///
361/// Entry signals are not handled here — they go through the profile path.
362/// Returns an empty vec for `Entry` variants.
363pub fn resolve_signal(signal: &RawSignal, resolver: &impl PositionResolver) -> Vec<Action> {
364    match signal {
365        RawSignal::Entry { .. } => vec![],
366
367        RawSignal::Close { position, .. } => resolver
368            .resolve(position)
369            .into_iter()
370            .map(|id| Action::ClosePosition { position_id: id })
371            .collect(),
372
373        RawSignal::ClosePartial {
374            position, ratio, ..
375        } => resolver
376            .resolve(position)
377            .into_iter()
378            .map(|id| Action::ClosePartial {
379                position_id: id,
380                ratio: *ratio,
381            })
382            .collect(),
383
384        RawSignal::ModifyStoploss {
385            position, price, ..
386        } => resolver
387            .resolve(position)
388            .into_iter()
389            .map(|id| Action::ModifyStoploss {
390                position_id: id,
391                price: *price,
392            })
393            .collect(),
394
395        RawSignal::MoveStoplossToEntry { position, .. } => resolver
396            .resolve(position)
397            .into_iter()
398            .map(|id| Action::MoveStoplossToEntry { position_id: id })
399            .collect(),
400
401        RawSignal::AddTarget {
402            position,
403            price,
404            close_ratio,
405            ..
406        } => resolver
407            .resolve(position)
408            .into_iter()
409            .map(|id| Action::AddTarget {
410                position_id: id,
411                price: *price,
412                close_ratio: *close_ratio,
413            })
414            .collect(),
415
416        RawSignal::RemoveTarget {
417            position, price, ..
418        } => resolver
419            .resolve(position)
420            .into_iter()
421            .map(|id| Action::RemoveTarget {
422                position_id: id,
423                price: *price,
424            })
425            .collect(),
426
427        RawSignal::ModifyTarget {
428            position,
429            old_price,
430            new_price,
431            ..
432        } => resolver
433            .resolve(position)
434            .into_iter()
435            .map(|id| Action::ModifyTarget {
436                position_id: id,
437                old_price: *old_price,
438                new_price: *new_price,
439            })
440            .collect(),
441
442        RawSignal::AddRule { position, rule, .. } => {
443            resolver
444                .resolve(position)
445                .into_iter()
446                .filter_map(|id| {
447                    let info = resolver.position_entry_info(&id);
448                    let (entry_price, side) = match info {
449                        Some((ep, s)) => (Some(ep), s),
450                        None => (None, Side::Buy), // fallback side; resolve may return None
451                    };
452                    rule.resolve(entry_price, side)
453                        .map(|resolved_rule| Action::AddRule {
454                            position_id: id,
455                            rule: resolved_rule,
456                        })
457                })
458                .collect()
459        }
460
461        RawSignal::RemoveRule {
462            position,
463            rule_name,
464            ..
465        } => resolver
466            .resolve(position)
467            .into_iter()
468            .map(|id| Action::RemoveRule {
469                position_id: id,
470                rule_name: rule_name.clone(),
471            })
472            .collect(),
473
474        RawSignal::ScaleIn {
475            position,
476            price,
477            size,
478            ..
479        } => resolver
480            .resolve(position)
481            .into_iter()
482            .map(|id| Action::ScaleIn {
483                position_id: id,
484                price: *price,
485                size: *size,
486                trade_id: None,
487            })
488            .collect(),
489
490        RawSignal::CancelPending { position, .. } => resolver
491            .resolve(position)
492            .into_iter()
493            .map(|id| Action::CancelPending { position_id: id })
494            .collect(),
495
496        // ── Bulk actions — no resolution needed ─────────────────────
497        RawSignal::CloseAllOf { symbol, .. } => {
498            vec![Action::CloseAllOf {
499                symbol: symbol.clone(),
500            }]
501        }
502        RawSignal::CloseAll { .. } => {
503            vec![Action::CloseAll]
504        }
505        RawSignal::CancelAllPending { .. } => {
506            vec![Action::CancelAllPending]
507        }
508        RawSignal::ModifyAllStoploss { symbol, price, .. } => {
509            vec![Action::ModifyAllStoploss {
510                symbol: symbol.clone(),
511                price: *price,
512            }]
513        }
514        RawSignal::CloseAllInGroup { group_id, .. } => {
515            vec![Action::CloseAllInGroup {
516                group_id: group_id.clone(),
517            }]
518        }
519        RawSignal::ModifyAllStoplossInGroup {
520            group_id, price, ..
521        } => {
522            vec![Action::ModifyAllStoplossInGroup {
523                group_id: group_id.clone(),
524                price: *price,
525            }]
526        }
527    }
528}
529
530// ─── StoplossMode ───────────────────────────────────────────────────────────
531
532/// How the profile handles the stoploss from the raw signal.
533#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
534#[serde(tag = "type")]
535pub enum StoplossMode {
536    /// Use the stoploss price from the signal as-is.
537    FromSignal,
538    /// No fixed stoploss (rely on trailing stop or time exit instead).
539    None,
540    /// Override with a fixed distance from entry price.
541    FixedDistance { distance: f64 },
542    /// Override with a specific absolute price.
543    FixedPrice { price: f64 },
544    /// Scale the directional distance from the applied entry to the signal stop.
545    FromSignalDistance { multiplier: f64 },
546}
547
548/// Directional geometry policy for signal stoploss and targets resolved
549/// against the execution price.
550#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(rename_all = "lowercase")]
552pub enum EntryGeometryPolicy {
553    /// Reject entry resolution when a signal level sits on the wrong side
554    /// of the entry price.
555    #[default]
556    Strict,
557    /// Retain crossed signal levels at profile resolution. The engine may still
558    /// reject ordinary Open geometry before a position is created.
559    Permissive,
560}
561
562// ─── TOML-friendly rule definition ──────────────────────────────────────────
563
564/// Profile-specific rule definition with `#[serde(tag = "type")]` for TOML.
565///
566/// Converts to the core `RuleConfig` enum. Includes an offset-based
567/// `BreakevenWhenOffset` variant that computes the absolute trigger price
568/// from the signal's entry price at apply time.
569#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
570#[serde(tag = "type")]
571pub enum RuleConfigDef {
572    /// Fixed stoploss at an absolute price.
573    FixedStoploss { price: f64 },
574    /// Trailing stop with a fixed distance.
575    TrailingStop { distance: f64 },
576    /// Take profit at an absolute price with a close ratio.
577    TakeProfit { price: f64, close_ratio: f64 },
578    /// Breakeven trigger at an absolute price.
579    BreakevenWhen { trigger_price: f64 },
580    /// Breakeven trigger as an offset from the entry price (profile-specific).
581    BreakevenWhenOffset { trigger_price_offset: f64 },
582    /// Breakeven after N targets have been hit.
583    BreakevenAfterTargets { after_n: u32 },
584    /// Time-based exit after N seconds.
585    TimeExit { max_seconds: u64 },
586}
587
588impl RuleConfigDef {
589    /// Resolve this definition into a core `RuleConfig`.
590    ///
591    /// For offset-based variants, `entry_price` and `side` are needed
592    /// to compute the absolute trigger price. Returns `None` when the
593    /// offset variant is used but no entry price is available.
594    pub fn resolve(&self, entry_price: Option<f64>, side: Side) -> Option<RuleConfig> {
595        match self {
596            Self::FixedStoploss { price } => Some(RuleConfig::FixedStoploss { price: *price }),
597            Self::TrailingStop { distance } => Some(RuleConfig::TrailingStop {
598                distance: *distance,
599            }),
600            Self::TakeProfit { price, close_ratio } => Some(RuleConfig::TakeProfit {
601                price: *price,
602                close_ratio: *close_ratio,
603            }),
604            Self::BreakevenWhen { trigger_price } => Some(RuleConfig::BreakevenWhen {
605                trigger_price: *trigger_price,
606            }),
607            Self::BreakevenWhenOffset {
608                trigger_price_offset,
609            } => {
610                let entry = entry_price?;
611                let trigger = match side {
612                    Side::Buy => entry + trigger_price_offset,
613                    Side::Sell => entry - trigger_price_offset,
614                };
615                Some(RuleConfig::BreakevenWhen {
616                    trigger_price: trigger,
617                })
618            }
619            Self::BreakevenAfterTargets { after_n } => {
620                Some(RuleConfig::BreakevenAfterTargets { after_n: *after_n })
621            }
622            Self::TimeExit { max_seconds } => Some(RuleConfig::TimeExit {
623                max_seconds: *max_seconds,
624            }),
625        }
626    }
627}
628
629// ─── Strict target resolution ────────────────────────────────────────────────
630
631/// Source used to produce initial targets.
632#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
633#[serde(tag = "type", deny_unknown_fields)]
634pub enum TargetSource {
635    /// Select targets supplied by the entry signal.
636    #[default]
637    FromSignal,
638    /// Generate targets from multiples of the final protective-stop distance.
639    StopDistanceMultiples { multiples: Vec<f64> },
640}
641
642/// Source recorded for resolved target metadata.
643#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
644#[serde(rename_all = "snake_case")]
645pub enum TargetResolutionSource {
646    #[default]
647    FromSignal,
648    StopDistanceMultiples,
649}
650
651/// Metadata for one generated target.
652#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
653pub struct GeneratedTargetResolution {
654    pub ordinal: usize,
655    pub multiple: f64,
656    pub multiple_decimal: String,
657    pub requested_price: f64,
658    pub resolved_price: f64,
659}
660
661/// Origin of the price grid supplied by the replay adapter.
662#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
663#[serde(rename_all = "snake_case")]
664pub enum PriceGridSource {
665    InstrumentPriceGrid,
666    LegacyDigitsFallback,
667}
668
669/// Instrument-aware context for profile-generated price levels.
670#[derive(Debug, Clone, Copy)]
671pub struct EntryResolutionContext {
672    pub price_grid: DecimalGrid,
673    pub price_grid_source: PriceGridSource,
674}
675
676/// Auditable requested and resolved profile levels.
677#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
678pub struct EntryLevelResolution {
679    pub price_grid_source: Option<PriceGridSource>,
680    pub original_signal_stoploss: Option<f64>,
681    pub stop_distance_multiplier: Option<f64>,
682    pub stop_distance_multiplier_decimal: Option<String>,
683    pub source_stop_distance: Option<f64>,
684    pub final_stop_distance: Option<f64>,
685    pub requested_stoploss: Option<f64>,
686    pub resolved_stoploss: Option<f64>,
687    pub stop_adjustment: Option<AdjustmentDirection>,
688    #[serde(default)]
689    pub requested_targets: Vec<f64>,
690    #[serde(default)]
691    pub resolved_targets: Vec<f64>,
692    #[serde(default)]
693    pub target_adjustments: Vec<AdjustmentDirection>,
694}
695
696/// Which 1-based target indices participate in strict target resolution.
697#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
698pub enum TargetSelection {
699    /// Use every target supplied by the entry signal, in signal order.
700    All,
701    /// Do not attach any targets.
702    None,
703    /// Use the listed 1-based signal target indices, in the listed order.
704    Selected(Vec<usize>),
705}
706
707/// Metadata describing how signal targets were selected and weighted.
708#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
709pub struct TargetResolution {
710    #[serde(default)]
711    pub source: TargetResolutionSource,
712    pub selection: TargetSelection,
713    /// Resolved 1-based signal indices in output order.
714    pub selected_indices: Vec<usize>,
715    #[serde(default)]
716    pub generated: Vec<GeneratedTargetResolution>,
717    /// Close weights corresponding one-to-one with resolved targets.
718    pub weights: Vec<f64>,
719    /// Fraction of the original position not assigned to a target.
720    pub remainder: f64,
721}
722
723/// A resolved entry that retains risk intent without assigning concrete lots.
724#[derive(Debug, Clone, Serialize, Deserialize)]
725pub struct ResolvedEntry {
726    pub risk_multiplier: f64,
727    pub symbol: String,
728    pub side: Side,
729    pub order_type: OrderType,
730    pub price: Option<f64>,
731    pub stoploss: Option<f64>,
732    pub targets: Vec<TargetSpec>,
733    pub rules: Vec<RuleConfig>,
734    pub group: Option<GroupId>,
735    pub trade_id: Option<TradeId>,
736    pub target_resolution: TargetResolution,
737    #[serde(default)]
738    pub level_resolution: EntryLevelResolution,
739}
740
741impl ResolvedEntry {
742    /// Finalize the resolved entry with a concrete lot size.
743    pub fn into_action(self, lot_size: f64) -> Action {
744        Action::Open {
745            symbol: self.symbol,
746            side: self.side,
747            order_type: self.order_type,
748            price: self.price,
749            size: lot_size,
750            stoploss: self.stoploss,
751            targets: self.targets,
752            rules: self.rules,
753            group: self.group,
754            trade_id: self.trade_id,
755        }
756    }
757}
758
759// ─── ManagementProfile ──────────────────────────────────────────────────────
760
761/// A named management profile that resolves raw entry signals before sizing.
762#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
763pub struct ManagementProfile {
764    /// Profile name (e.g. "conservative", "aggressive", "runner").
765    pub name: String,
766
767    /// Explicit current target selection. When present, this takes precedence
768    /// over `use_targets` for [`Self::apply_entry_signal`]. When omitted,
769    /// compatibility decoding derives the prior behavior from `use_targets`: an empty vector means
770    /// [`TargetSelection::None`], otherwise it means [`TargetSelection::Selected`].
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub target_selection: Option<TargetSelection>,
773
774    /// Compatibility target selection (1-indexed), retained so existing serialized
775    /// profiles remain readable.
776    pub use_targets: Vec<usize>,
777
778    /// Close ratio for each selected target. In current application, an empty
779    /// vector assigns equal weights to all selected targets; otherwise its
780    /// length must match the effective target selection.
781    pub close_ratios: Vec<f64>,
782
783    /// Source used to select or generate initial targets.
784    #[serde(default, skip_serializing_if = "target_source_is_default")]
785    pub target_source: TargetSource,
786
787    /// How to handle the stoploss from the signal.
788    #[serde(default = "default_stoploss_mode")]
789    pub stoploss_mode: StoplossMode,
790
791    /// Additional rules to attach to every position opened with this profile.
792    #[serde(default)]
793    pub rules: Vec<RuleConfigDef>,
794
795    /// If set, override the signal's group tag with this value.
796    #[serde(default)]
797    pub group_override: Option<String>,
798
799    /// When true and ratios sum < 1.0, the remainder rides with just SL/rules.
800    #[serde(default)]
801    pub let_remainder_run: bool,
802
803    /// Directional geometry policy for the signal stoploss and targets when
804    /// they are validated against the execution price. Profile rule levels
805    /// and numeric validation are always strict.
806    #[serde(default)]
807    pub entry_geometry: EntryGeometryPolicy,
808}
809
810fn default_stoploss_mode() -> StoplossMode {
811    StoplossMode::FromSignal
812}
813
814fn target_source_is_default(source: &TargetSource) -> bool {
815    matches!(source, TargetSource::FromSignal)
816}
817
818impl ManagementProfile {
819    /// Return the target selection used by current application.
820    ///
821    /// The explicit `target_selection` field wins when present. Otherwise this
822    /// preserves existing profile behavior by deriving `None`/`Selected` from
823    /// `use_targets`.
824    pub fn effective_target_selection(&self) -> TargetSelection {
825        self.target_selection.clone().unwrap_or_else(|| {
826            if self.use_targets.is_empty() {
827                TargetSelection::None
828            } else {
829                TargetSelection::Selected(self.use_targets.clone())
830            }
831        })
832    }
833
834    /// Validate this profile's configuration.
835    pub fn validate(&self) -> Result<(), ProfileValidationError> {
836        validate_profile(self)
837    }
838
839    /// Transform an Entry using compatibility behavior for signal-backed levels.
840    pub fn apply_entry_signal(
841        &self,
842        signal: &RawSignal,
843    ) -> Result<Option<ResolvedEntry>, ProfileApplicationError> {
844        self.apply_entry_signal_internal(signal, None)
845    }
846
847    /// Transform an Entry with the instrument price grid required by generated levels.
848    pub fn apply_entry_signal_with_context(
849        &self,
850        signal: &RawSignal,
851        context: EntryResolutionContext,
852    ) -> Result<Option<ResolvedEntry>, ProfileApplicationError> {
853        self.apply_entry_signal_internal(signal, Some(context))
854    }
855
856    fn apply_entry_signal_internal(
857        &self,
858        signal: &RawSignal,
859        context: Option<EntryResolutionContext>,
860    ) -> Result<Option<ResolvedEntry>, ProfileApplicationError> {
861        let (
862            symbol,
863            side,
864            order_type,
865            price,
866            risk_multiplier,
867            signal_stoploss,
868            signal_targets,
869            group,
870            trade_id,
871        ) = match signal {
872            RawSignal::Entry {
873                symbol,
874                side,
875                order_type,
876                price,
877                risk_multiplier,
878                stoploss,
879                targets,
880                group,
881                trade_id,
882                ..
883            } => (
884                symbol,
885                side,
886                order_type,
887                price,
888                risk_multiplier,
889                stoploss,
890                targets,
891                group,
892                trade_id,
893            ),
894            _ => return Ok(None),
895        };
896
897        validate_entry_numbers(*price, *risk_multiplier, *signal_stoploss, signal_targets)?;
898
899        let (stoploss, mut level_resolution) = resolve_stoploss(
900            &self.stoploss_mode,
901            *signal_stoploss,
902            *price,
903            *side,
904            self.entry_geometry,
905            context,
906        )?;
907        let (targets, target_resolution, target_level_resolution) = resolve_target_source(
908            &self.target_source,
909            signal_targets,
910            stoploss,
911            *side,
912            *price,
913            self.effective_target_selection(),
914            &self.close_ratios,
915            self.let_remainder_run,
916            self.entry_geometry,
917            context,
918        )?;
919        level_resolution.requested_targets = target_level_resolution.requested_targets;
920        level_resolution.resolved_targets = target_level_resolution.resolved_targets;
921        level_resolution.target_adjustments = target_level_resolution.target_adjustments;
922        if level_resolution.price_grid_source.is_none() {
923            level_resolution.price_grid_source = target_level_resolution.price_grid_source;
924        }
925        if level_resolution.final_stop_distance.is_none() {
926            level_resolution.final_stop_distance = target_level_resolution.final_stop_distance;
927        }
928        let rules = resolve_rules(&self.rules, *price, *side)?;
929
930        Ok(Some(ResolvedEntry {
931            risk_multiplier: *risk_multiplier,
932            symbol: symbol.clone(),
933            side: *side,
934            order_type: *order_type,
935            price: *price,
936            stoploss,
937            targets,
938            rules,
939            group: self.group_override.clone().or(group.clone()),
940            trade_id: trade_id.clone(),
941            target_resolution,
942            level_resolution,
943        }))
944    }
945}
946
947const WEIGHT_TOLERANCE: f64 = 1e-12;
948const LOT_ALIGNMENT_TOLERANCE: f64 = 1e-9;
949
950fn require_positive_finite(
951    field: impl Into<String>,
952    value: f64,
953) -> Result<(), ProfileApplicationError> {
954    if value.is_finite() && value > 0.0 {
955        Ok(())
956    } else {
957        Err(ProfileApplicationError::InvalidNumericInput {
958            field: field.into(),
959            value,
960        })
961    }
962}
963
964fn validate_entry_numbers(
965    price: Option<f64>,
966    risk_multiplier: f64,
967    stoploss: Option<f64>,
968    targets: &[f64],
969) -> Result<(), ProfileApplicationError> {
970    require_positive_finite("risk_multiplier", risk_multiplier)?;
971    if let Some(price) = price {
972        require_positive_finite("price", price)?;
973    }
974    if let Some(stoploss) = stoploss {
975        require_positive_finite("stoploss", stoploss)?;
976    }
977    for (offset, &target) in targets.iter().enumerate() {
978        require_positive_finite(format!("target {}", offset + 1), target)?;
979    }
980    Ok(())
981}
982
983fn weights_sum_to_one(sum: f64) -> bool {
984    (sum - 1.0).abs() <= WEIGHT_TOLERANCE
985}
986
987fn validate_weights(
988    weights: &[f64],
989    let_remainder_run: bool,
990) -> Result<f64, ProfileApplicationError> {
991    for (offset, &weight) in weights.iter().enumerate() {
992        if !weight.is_finite() || weight <= 0.0 {
993            return Err(ProfileApplicationError::InvalidTargetWeight {
994                position: offset + 1,
995                weight,
996            });
997        }
998    }
999
1000    let sum: f64 = weights.iter().sum();
1001    if !sum.is_finite() || sum > 1.0 + WEIGHT_TOLERANCE {
1002        return Err(ProfileApplicationError::TargetWeightSumExceeded { sum });
1003    }
1004    if !let_remainder_run && !weights_sum_to_one(sum) {
1005        return Err(ProfileApplicationError::TargetWeightSumIncomplete { sum });
1006    }
1007
1008    Ok(if weights_sum_to_one(sum) {
1009        0.0
1010    } else {
1011        1.0 - sum
1012    })
1013}
1014
1015fn resolve_targets(
1016    signal_targets: &[f64],
1017    side: Side,
1018    entry_price: Option<f64>,
1019    selection: TargetSelection,
1020    explicit_weights: &[f64],
1021    let_remainder_run: bool,
1022    geometry_policy: EntryGeometryPolicy,
1023) -> Result<(Vec<TargetSpec>, TargetResolution), ProfileApplicationError> {
1024    let selected_indices = match &selection {
1025        TargetSelection::All => (1..=signal_targets.len()).collect(),
1026        TargetSelection::None => Vec::new(),
1027        TargetSelection::Selected(indices) => {
1028            let mut seen = HashSet::with_capacity(indices.len());
1029            for &index in indices {
1030                if index == 0 {
1031                    return Err(ProfileApplicationError::ZeroTargetIndex);
1032                }
1033                if !seen.insert(index) {
1034                    return Err(ProfileApplicationError::DuplicateTargetIndex { index });
1035                }
1036                if index > signal_targets.len() {
1037                    return Err(ProfileApplicationError::MissingTargetIndex {
1038                        index,
1039                        available: signal_targets.len(),
1040                    });
1041                }
1042            }
1043            indices.clone()
1044        }
1045    };
1046
1047    if selected_indices.is_empty() {
1048        if !explicit_weights.is_empty() {
1049            return Err(ProfileApplicationError::TargetWeightCountMismatch {
1050                targets: 0,
1051                weights: explicit_weights.len(),
1052            });
1053        }
1054        return Ok((
1055            Vec::new(),
1056            TargetResolution {
1057                source: TargetResolutionSource::FromSignal,
1058                selection,
1059                selected_indices,
1060                generated: Vec::new(),
1061                weights: Vec::new(),
1062                remainder: 1.0,
1063            },
1064        ));
1065    }
1066
1067    let weights = if explicit_weights.is_empty() {
1068        vec![1.0 / selected_indices.len() as f64; selected_indices.len()]
1069    } else {
1070        if explicit_weights.len() != selected_indices.len() {
1071            return Err(ProfileApplicationError::TargetWeightCountMismatch {
1072                targets: selected_indices.len(),
1073                weights: explicit_weights.len(),
1074            });
1075        }
1076        explicit_weights.to_vec()
1077    };
1078    let remainder = validate_weights(&weights, let_remainder_run)?;
1079
1080    let mut targets = Vec::with_capacity(selected_indices.len());
1081    let mut target_price_keys = HashSet::with_capacity(selected_indices.len());
1082    for (&index, &weight) in selected_indices.iter().zip(&weights) {
1083        let target = signal_targets[index - 1];
1084        let target_key = (target * 1_000_000.0).round() as i64;
1085        if !target_price_keys.insert(target_key) {
1086            return Err(ProfileApplicationError::DuplicateTargetPrice { price: target });
1087        }
1088        if let Some(entry) = entry_price {
1089            let valid_geometry = match side {
1090                Side::Buy => target > entry,
1091                Side::Sell => target < entry,
1092            };
1093            if !valid_geometry && geometry_policy == EntryGeometryPolicy::Strict {
1094                return Err(ProfileApplicationError::InvalidTargetGeometry {
1095                    index,
1096                    side,
1097                    entry,
1098                    target,
1099                });
1100            }
1101        }
1102        targets.push(TargetSpec {
1103            price: target,
1104            close_ratio: weight,
1105        });
1106    }
1107
1108    Ok((
1109        targets,
1110        TargetResolution {
1111            source: TargetResolutionSource::FromSignal,
1112            selection,
1113            selected_indices,
1114            generated: Vec::new(),
1115            weights,
1116            remainder,
1117        },
1118    ))
1119}
1120
1121#[allow(clippy::too_many_arguments)]
1122fn resolve_target_source(
1123    source: &TargetSource,
1124    signal_targets: &[f64],
1125    stoploss: Option<f64>,
1126    side: Side,
1127    entry_price: Option<f64>,
1128    selection: TargetSelection,
1129    explicit_weights: &[f64],
1130    let_remainder_run: bool,
1131    geometry_policy: EntryGeometryPolicy,
1132    context: Option<EntryResolutionContext>,
1133) -> Result<(Vec<TargetSpec>, TargetResolution, EntryLevelResolution), ProfileApplicationError> {
1134    match source {
1135        TargetSource::FromSignal => {
1136            let (targets, resolution) = resolve_targets(
1137                signal_targets,
1138                side,
1139                entry_price,
1140                selection,
1141                explicit_weights,
1142                let_remainder_run,
1143                geometry_policy,
1144            )?;
1145            let resolved_targets = targets
1146                .iter()
1147                .map(|target| target.price)
1148                .collect::<Vec<_>>();
1149            Ok((
1150                targets,
1151                resolution,
1152                EntryLevelResolution {
1153                    requested_targets: resolved_targets.clone(),
1154                    resolved_targets,
1155                    ..EntryLevelResolution::default()
1156                },
1157            ))
1158        }
1159        TargetSource::StopDistanceMultiples { multiples } => resolve_generated_targets(
1160            multiples,
1161            stoploss,
1162            side,
1163            entry_price,
1164            explicit_weights,
1165            let_remainder_run,
1166            context,
1167        ),
1168    }
1169}
1170
1171fn resolve_generated_targets(
1172    multiples: &[f64],
1173    stoploss: Option<f64>,
1174    side: Side,
1175    entry_price: Option<f64>,
1176    explicit_weights: &[f64],
1177    let_remainder_run: bool,
1178    context: Option<EntryResolutionContext>,
1179) -> Result<(Vec<TargetSpec>, TargetResolution, EntryLevelResolution), ProfileApplicationError> {
1180    if multiples.is_empty() {
1181        return Err(ProfileApplicationError::PriceGrid {
1182            reason: "generated target multiples cannot be empty".to_owned(),
1183        });
1184    }
1185    let entry = entry_price.ok_or(ProfileApplicationError::MissingEntryPrice {
1186        mode: "stop-distance targets",
1187    })?;
1188    let stop = stoploss.ok_or(ProfileApplicationError::MissingSignalStoploss {
1189        mode: "stop-distance targets",
1190    })?;
1191    validate_stop_geometry(side, entry, stop)?;
1192    let context = context.ok_or(ProfileApplicationError::MissingPriceGrid {
1193        mode: "stop-distance targets",
1194    })?;
1195
1196    let entry_decimal = decimal_from_f64(entry)?;
1197    let stop_decimal = decimal_from_f64(stop)?;
1198    context
1199        .price_grid
1200        .adjust(stop_decimal, GridRounding::Reject)
1201        .map_err(price_grid_error)?;
1202    let distance = directional_stop_distance(side, entry_decimal, stop_decimal)?;
1203
1204    let weights = if explicit_weights.is_empty() {
1205        vec![1.0 / multiples.len() as f64; multiples.len()]
1206    } else {
1207        if explicit_weights.len() != multiples.len() {
1208            return Err(ProfileApplicationError::TargetWeightCountMismatch {
1209                targets: multiples.len(),
1210                weights: explicit_weights.len(),
1211            });
1212        }
1213        explicit_weights.to_vec()
1214    };
1215    let remainder = validate_weights(&weights, let_remainder_run)?;
1216
1217    let mut targets = Vec::with_capacity(multiples.len());
1218    let mut generated = Vec::with_capacity(multiples.len());
1219    let mut requested_targets = Vec::with_capacity(multiples.len());
1220    let mut resolved_targets = Vec::with_capacity(multiples.len());
1221    let mut target_adjustments = Vec::with_capacity(multiples.len());
1222    let mut seen = HashSet::with_capacity(multiples.len());
1223    let mut seen_engine_prices = HashSet::with_capacity(multiples.len());
1224    for (offset, (&multiple, &weight)) in multiples.iter().zip(&weights).enumerate() {
1225        require_positive_finite(format!("target multiple {}", offset + 1), multiple)?;
1226        let multiple_decimal = decimal_from_f64(multiple)?;
1227        let offset_decimal = distance
1228            .checked_mul(multiple_decimal)
1229            .map_err(price_grid_error)?;
1230        let requested = match side {
1231            Side::Buy => entry_decimal.checked_add(offset_decimal),
1232            Side::Sell => entry_decimal.checked_sub(offset_decimal),
1233        }
1234        .map_err(price_grid_error)?;
1235        let rounding = match side {
1236            Side::Buy => GridRounding::Ceil,
1237            Side::Sell => GridRounding::Floor,
1238        };
1239        let adjustment = context
1240            .price_grid
1241            .adjust(requested, rounding)
1242            .map_err(price_grid_error)?;
1243        if !seen.insert(adjustment.adjusted) {
1244            return Err(ProfileApplicationError::DuplicateTargetPrice {
1245                price: decimal_to_f64(adjustment.adjusted),
1246            });
1247        }
1248        let requested_price = decimal_to_f64(requested);
1249        let resolved_price = decimal_to_f64(adjustment.adjusted);
1250        let engine_price_key = (resolved_price * 1_000_000.0).round() as i64;
1251        if !seen_engine_prices.insert(engine_price_key) {
1252            return Err(ProfileApplicationError::DuplicateTargetPrice {
1253                price: resolved_price,
1254            });
1255        }
1256        require_positive_finite(format!("generated target {}", offset + 1), resolved_price)?;
1257        validate_target_geometry(offset + 1, side, entry, resolved_price)?;
1258        targets.push(TargetSpec {
1259            price: resolved_price,
1260            close_ratio: weight,
1261        });
1262        generated.push(GeneratedTargetResolution {
1263            ordinal: offset + 1,
1264            multiple,
1265            multiple_decimal: multiple_decimal.to_string(),
1266            requested_price,
1267            resolved_price,
1268        });
1269        requested_targets.push(requested_price);
1270        resolved_targets.push(resolved_price);
1271        target_adjustments.push(adjustment.direction);
1272    }
1273
1274    Ok((
1275        targets,
1276        TargetResolution {
1277            source: TargetResolutionSource::StopDistanceMultiples,
1278            selection: TargetSelection::None,
1279            selected_indices: Vec::new(),
1280            generated,
1281            weights,
1282            remainder,
1283        },
1284        EntryLevelResolution {
1285            price_grid_source: Some(context.price_grid_source),
1286            final_stop_distance: Some(decimal_to_f64(distance)),
1287            requested_targets,
1288            resolved_targets,
1289            target_adjustments,
1290            ..EntryLevelResolution::default()
1291        },
1292    ))
1293}
1294
1295fn decimal_from_f64(value: f64) -> Result<Decimal, ProfileApplicationError> {
1296    Decimal::checked_from_f64(value).map_err(price_grid_error)
1297}
1298
1299fn decimal_to_f64(value: Decimal) -> f64 {
1300    value.coefficient() as f64 / 10_f64.powi(i32::from(value.scale()))
1301}
1302
1303fn price_grid_error(error: impl std::fmt::Display) -> ProfileApplicationError {
1304    ProfileApplicationError::PriceGrid {
1305        reason: error.to_string(),
1306    }
1307}
1308
1309fn directional_stop_distance(
1310    side: Side,
1311    entry: Decimal,
1312    stop: Decimal,
1313) -> Result<Decimal, ProfileApplicationError> {
1314    let distance = match side {
1315        Side::Buy => entry.checked_sub(stop),
1316        Side::Sell => stop.checked_sub(entry),
1317    }
1318    .map_err(price_grid_error)?;
1319    if distance.is_positive() {
1320        Ok(distance)
1321    } else {
1322        Err(ProfileApplicationError::PriceGrid {
1323            reason: "stop distance must be strictly protective".to_owned(),
1324        })
1325    }
1326}
1327
1328fn validate_stop_geometry(
1329    side: Side,
1330    entry: f64,
1331    stoploss: f64,
1332) -> Result<(), ProfileApplicationError> {
1333    let valid = match side {
1334        Side::Buy => stoploss < entry,
1335        Side::Sell => stoploss > entry,
1336    };
1337    if valid {
1338        Ok(())
1339    } else {
1340        Err(ProfileApplicationError::InvalidStopGeometry {
1341            side,
1342            entry,
1343            stoploss,
1344        })
1345    }
1346}
1347
1348fn validate_target_geometry(
1349    index: usize,
1350    side: Side,
1351    entry: f64,
1352    target: f64,
1353) -> Result<(), ProfileApplicationError> {
1354    let valid = match side {
1355        Side::Buy => target > entry,
1356        Side::Sell => target < entry,
1357    };
1358    if valid {
1359        Ok(())
1360    } else {
1361        Err(ProfileApplicationError::InvalidTargetGeometry {
1362            index,
1363            side,
1364            entry,
1365            target,
1366        })
1367    }
1368}
1369
1370fn resolve_stoploss(
1371    mode: &StoplossMode,
1372    signal_stoploss: Option<f64>,
1373    entry_price: Option<f64>,
1374    side: Side,
1375    geometry_policy: EntryGeometryPolicy,
1376    context: Option<EntryResolutionContext>,
1377) -> Result<(Option<f64>, EntryLevelResolution), ProfileApplicationError> {
1378    let mut resolution = EntryLevelResolution {
1379        original_signal_stoploss: signal_stoploss,
1380        ..EntryLevelResolution::default()
1381    };
1382    let (stoploss, signal_level) = match mode {
1383        StoplossMode::FromSignal => (signal_stoploss, true),
1384        StoplossMode::None => (None, false),
1385        StoplossMode::FixedDistance { distance } => {
1386            require_positive_finite("stoploss fixed distance", *distance)?;
1387            (
1388                entry_price.map(|entry| match side {
1389                    Side::Buy => entry - distance,
1390                    Side::Sell => entry + distance,
1391                }),
1392                false,
1393            )
1394        }
1395        StoplossMode::FixedPrice { price } => {
1396            require_positive_finite("stoploss fixed price", *price)?;
1397            (Some(*price), false)
1398        }
1399        StoplossMode::FromSignalDistance { multiplier } => {
1400            require_positive_finite("stoploss signal-distance multiplier", *multiplier)?;
1401            let entry = entry_price.ok_or(ProfileApplicationError::MissingEntryPrice {
1402                mode: "signal-distance stoploss",
1403            })?;
1404            let signal_stop =
1405                signal_stoploss.ok_or(ProfileApplicationError::MissingSignalStoploss {
1406                    mode: "signal-distance stoploss",
1407                })?;
1408            validate_stop_geometry(side, entry, signal_stop)?;
1409            let context = context.ok_or(ProfileApplicationError::MissingPriceGrid {
1410                mode: "signal-distance stoploss",
1411            })?;
1412            let entry_decimal = decimal_from_f64(entry)?;
1413            let stop_decimal = decimal_from_f64(signal_stop)?;
1414            let distance = directional_stop_distance(side, entry_decimal, stop_decimal)?;
1415            let multiplier_decimal = decimal_from_f64(*multiplier)?;
1416            let scaled = distance
1417                .checked_mul(multiplier_decimal)
1418                .map_err(price_grid_error)?;
1419            let requested = match side {
1420                Side::Buy => entry_decimal.checked_sub(scaled),
1421                Side::Sell => entry_decimal.checked_add(scaled),
1422            }
1423            .map_err(price_grid_error)?;
1424            let rounding = match side {
1425                Side::Buy => GridRounding::Floor,
1426                Side::Sell => GridRounding::Ceil,
1427            };
1428            let adjustment = context
1429                .price_grid
1430                .adjust(requested, rounding)
1431                .map_err(price_grid_error)?;
1432            let requested_price = decimal_to_f64(requested);
1433            let resolved_price = decimal_to_f64(adjustment.adjusted);
1434            resolution.price_grid_source = Some(context.price_grid_source);
1435            let final_distance =
1436                directional_stop_distance(side, entry_decimal, adjustment.adjusted)?;
1437            resolution.stop_distance_multiplier = Some(*multiplier);
1438            resolution.stop_distance_multiplier_decimal = Some(multiplier_decimal.to_string());
1439            resolution.source_stop_distance = Some(decimal_to_f64(distance));
1440            resolution.final_stop_distance = Some(decimal_to_f64(final_distance));
1441            resolution.requested_stoploss = Some(requested_price);
1442            resolution.resolved_stoploss = Some(resolved_price);
1443            resolution.stop_adjustment = Some(adjustment.direction);
1444            (Some(resolved_price), false)
1445        }
1446    };
1447    if let Some(stoploss) = stoploss {
1448        require_positive_finite("resolved stoploss", stoploss)?;
1449        if let Some(entry) = entry_price
1450            && (!signal_level || geometry_policy == EntryGeometryPolicy::Strict)
1451        {
1452            validate_stop_geometry(side, entry, stoploss)?;
1453        }
1454        resolution.resolved_stoploss.get_or_insert(stoploss);
1455        resolution.requested_stoploss.get_or_insert(stoploss);
1456    }
1457    Ok((stoploss, resolution))
1458}
1459
1460fn resolve_rules(
1461    definitions: &[RuleConfigDef],
1462    entry_price: Option<f64>,
1463    side: Side,
1464) -> Result<Vec<RuleConfig>, ProfileApplicationError> {
1465    let mut rules = Vec::with_capacity(definitions.len());
1466    for (offset, definition) in definitions.iter().enumerate() {
1467        let position = offset + 1;
1468        match definition {
1469            RuleConfigDef::FixedStoploss { price } => {
1470                require_positive_finite(format!("rule {position} fixed stoploss price"), *price)?;
1471                if let Some(entry) = entry_price {
1472                    validate_stop_geometry(side, entry, *price)?;
1473                }
1474            }
1475            RuleConfigDef::TrailingStop { distance } => {
1476                require_positive_finite(format!("rule {position} trailing distance"), *distance)?;
1477                if let Some(entry) = entry_price {
1478                    let initial_stop = match side {
1479                        Side::Buy => entry - distance,
1480                        Side::Sell => entry + distance,
1481                    };
1482                    require_positive_finite(
1483                        format!("rule {position} initial trailing stop"),
1484                        initial_stop,
1485                    )?;
1486                    validate_stop_geometry(side, entry, initial_stop)?;
1487                }
1488            }
1489            RuleConfigDef::TakeProfit { price, close_ratio } => {
1490                require_positive_finite(format!("rule {position} take-profit price"), *price)?;
1491                require_positive_finite(
1492                    format!("rule {position} take-profit close ratio"),
1493                    *close_ratio,
1494                )?;
1495                if *close_ratio > 1.0 {
1496                    return Err(ProfileApplicationError::InvalidTargetWeight {
1497                        position,
1498                        weight: *close_ratio,
1499                    });
1500                }
1501                if let Some(entry) = entry_price {
1502                    validate_target_geometry(position, side, entry, *price)?;
1503                }
1504            }
1505            RuleConfigDef::BreakevenWhen { trigger_price } => {
1506                require_positive_finite(
1507                    format!("rule {position} breakeven trigger price"),
1508                    *trigger_price,
1509                )?;
1510                if let Some(entry) = entry_price {
1511                    validate_target_geometry(position, side, entry, *trigger_price)?;
1512                }
1513            }
1514            RuleConfigDef::BreakevenWhenOffset {
1515                trigger_price_offset,
1516            } => {
1517                require_positive_finite(
1518                    format!("rule {position} breakeven trigger offset"),
1519                    *trigger_price_offset,
1520                )?;
1521            }
1522            RuleConfigDef::BreakevenAfterTargets { after_n } => {
1523                if *after_n == 0 {
1524                    return Err(ProfileApplicationError::InvalidCountInput {
1525                        field: format!("rule {position} breakeven target count"),
1526                        value: 0,
1527                    });
1528                }
1529            }
1530            RuleConfigDef::TimeExit { max_seconds } => {
1531                if *max_seconds == 0 {
1532                    return Err(ProfileApplicationError::InvalidCountInput {
1533                        field: format!("rule {position} maximum seconds"),
1534                        value: 0,
1535                    });
1536                }
1537            }
1538        }
1539
1540        if let Some(rule) = definition.resolve(entry_price, side) {
1541            if let RuleConfig::BreakevenWhen { trigger_price } = &rule {
1542                require_positive_finite(
1543                    format!("rule {position} resolved breakeven trigger"),
1544                    *trigger_price,
1545                )?;
1546                if let Some(entry) = entry_price {
1547                    validate_target_geometry(position, side, entry, *trigger_price)?;
1548                }
1549            }
1550            rules.push(rule);
1551        }
1552    }
1553    Ok(rules)
1554}
1555
1556/// Strictly resolve an entry without a management profile.
1557///
1558/// Every signal target is retained and receives an equal `1 / N` close weight.
1559/// Non-entry signals return `Ok(None)`.
1560pub fn resolve_unprofiled_entry(
1561    signal: &RawSignal,
1562) -> Result<Option<ResolvedEntry>, ProfileApplicationError> {
1563    let (
1564        symbol,
1565        side,
1566        order_type,
1567        price,
1568        risk_multiplier,
1569        stoploss,
1570        signal_targets,
1571        group,
1572        trade_id,
1573    ) = match signal {
1574        RawSignal::Entry {
1575            symbol,
1576            side,
1577            order_type,
1578            price,
1579            risk_multiplier,
1580            stoploss,
1581            targets,
1582            group,
1583            trade_id,
1584            ..
1585        } => (
1586            symbol,
1587            side,
1588            order_type,
1589            price,
1590            risk_multiplier,
1591            stoploss,
1592            targets,
1593            group,
1594            trade_id,
1595        ),
1596        _ => return Ok(None),
1597    };
1598
1599    validate_entry_numbers(*price, *risk_multiplier, *stoploss, signal_targets)?;
1600    let (targets, target_resolution) = resolve_targets(
1601        signal_targets,
1602        *side,
1603        *price,
1604        TargetSelection::All,
1605        &[],
1606        false,
1607        EntryGeometryPolicy::Strict,
1608    )?;
1609
1610    Ok(Some(ResolvedEntry {
1611        risk_multiplier: *risk_multiplier,
1612        symbol: symbol.clone(),
1613        side: *side,
1614        order_type: *order_type,
1615        price: *price,
1616        stoploss: *stoploss,
1617        targets,
1618        rules: Vec::new(),
1619        group: group.clone(),
1620        trade_id: trade_id.clone(),
1621        target_resolution,
1622        level_resolution: EntryLevelResolution {
1623            original_signal_stoploss: *stoploss,
1624            requested_stoploss: *stoploss,
1625            resolved_stoploss: *stoploss,
1626            requested_targets: signal_targets.clone(),
1627            resolved_targets: signal_targets.clone(),
1628            ..EntryLevelResolution::default()
1629        },
1630    }))
1631}
1632
1633/// Allocate target close weights from authoritative integer lot steps.
1634/// Each non-final target rounds down, while a fully allocated final target receives all remaining steps.
1635/// A positive runner remainder is intentionally left unallocated, and `weights + remainder` must equal one.
1636pub fn allocate_target_steps(
1637    total_steps: u64,
1638    weights: &[f64],
1639    remainder: f64,
1640) -> Result<Vec<u64>, ProfileApplicationError> {
1641    if total_steps == 0 {
1642        return Err(ProfileApplicationError::InvalidCountInput {
1643            field: "total_steps".into(),
1644            value: total_steps,
1645        });
1646    }
1647    if !remainder.is_finite() || remainder < 0.0 {
1648        return Err(ProfileApplicationError::InvalidRemainder { remainder });
1649    }
1650    if weights.is_empty() {
1651        if weights_sum_to_one(remainder) {
1652            return Ok(Vec::new());
1653        }
1654        return Err(ProfileApplicationError::TargetWeightRemainderMismatch {
1655            sum: 0.0,
1656            remainder,
1657        });
1658    }
1659
1660    let computed_remainder = validate_weights(weights, true)?;
1661    let weight_sum = 1.0 - computed_remainder;
1662    if !weights_sum_to_one(weight_sum + remainder) {
1663        return Err(ProfileApplicationError::TargetWeightRemainderMismatch {
1664            sum: weight_sum,
1665            remainder,
1666        });
1667    }
1668    let assign_residue_to_final = weights_sum_to_one(weight_sum);
1669
1670    let mut allocations = Vec::with_capacity(weights.len());
1671    let mut allocated = 0_u64;
1672    for (offset, &weight) in weights.iter().enumerate() {
1673        let is_final = offset + 1 == weights.len();
1674        let steps = if is_final && assign_residue_to_final {
1675            total_steps.saturating_sub(allocated)
1676        } else {
1677            ((total_steps as f64) * weight).floor() as u64
1678        };
1679        if steps == 0 {
1680            return Err(ProfileApplicationError::ZeroUnitAllocation {
1681                position: offset + 1,
1682            });
1683        }
1684        allocated = allocated.saturating_add(steps);
1685        allocations.push(steps);
1686    }
1687
1688    Ok(allocations)
1689}
1690
1691/// Convert an aligned floating lot size to steps and delegate to [`allocate_target_steps`].
1692pub fn allocate_target_units(
1693    size: f64,
1694    lot_step: f64,
1695    weights: &[f64],
1696    remainder: f64,
1697) -> Result<Vec<u64>, ProfileApplicationError> {
1698    require_positive_finite("size", size)?;
1699    require_positive_finite("lot_step", lot_step)?;
1700
1701    let raw_units = size / lot_step;
1702    if !raw_units.is_finite() || raw_units >= u64::MAX as f64 {
1703        return Err(ProfileApplicationError::LotUnitCountOverflow { size, lot_step });
1704    }
1705    let rounded_units = raw_units.round();
1706    let alignment_tolerance = LOT_ALIGNMENT_TOLERANCE * raw_units.abs().max(1.0);
1707    if (raw_units - rounded_units).abs() > alignment_tolerance || rounded_units < 1.0 {
1708        return Err(ProfileApplicationError::SizeNotMultipleOfLotStep { size, lot_step });
1709    }
1710
1711    allocate_target_steps(rounded_units as u64, weights, remainder)
1712}
1713
1714/// Validate a management profile without performing configuration I/O.
1715pub fn validate_profile(p: &ManagementProfile) -> Result<(), ProfileValidationError> {
1716    let invalid = |reason: String| ProfileValidationError::InvalidConfiguration {
1717        profile: p.name.clone(),
1718        reason,
1719    };
1720    let selection = p.effective_target_selection();
1721
1722    let selected_count = match &p.target_source {
1723        TargetSource::FromSignal => match &selection {
1724            TargetSelection::All => None,
1725            TargetSelection::None => Some(0),
1726            TargetSelection::Selected(indices) => Some(indices.len()),
1727        },
1728        TargetSource::StopDistanceMultiples { multiples } => {
1729            if p.target_selection.is_some() || !p.use_targets.is_empty() {
1730                return Err(invalid(
1731                    "generated targets cannot be combined with signal target selection".into(),
1732                ));
1733            }
1734            if multiples.is_empty() {
1735                return Err(invalid("generated target multiples cannot be empty".into()));
1736            }
1737            let mut previous = None;
1738            for (offset, &multiple) in multiples.iter().enumerate() {
1739                if !multiple.is_finite() || multiple <= 0.0 {
1740                    return Err(invalid(format!(
1741                        "target multiple {} must be finite and positive",
1742                        offset + 1
1743                    )));
1744                }
1745                if previous.is_some_and(|value| multiple <= value) {
1746                    return Err(invalid(
1747                        "generated target multiples must be strictly increasing".into(),
1748                    ));
1749                }
1750                previous = Some(multiple);
1751            }
1752            if matches!(p.stoploss_mode, StoplossMode::None) {
1753                return Err(invalid(
1754                    "generated targets require a protective stoploss mode".into(),
1755                ));
1756            }
1757            if p.rules
1758                .iter()
1759                .any(|rule| matches!(rule, RuleConfigDef::TakeProfit { .. }))
1760            {
1761                return Err(invalid(
1762                    "generated targets cannot be combined with take-profit rules".into(),
1763                ));
1764            }
1765            for rule in &p.rules {
1766                if let RuleConfigDef::BreakevenAfterTargets { after_n } = rule
1767                    && *after_n as usize > multiples.len()
1768                {
1769                    return Err(invalid(format!(
1770                        "breakeven target count {after_n} exceeds generated target count {}",
1771                        multiples.len()
1772                    )));
1773                }
1774            }
1775            Some(multiples.len())
1776        }
1777    };
1778
1779    if let Some(targets) = selected_count
1780        && !p.close_ratios.is_empty()
1781        && targets != p.close_ratios.len()
1782    {
1783        return Err(ProfileValidationError::TargetRatioMismatch {
1784            profile: p.name.clone(),
1785            targets,
1786            ratios: p.close_ratios.len(),
1787        });
1788    }
1789
1790    let mut seen = HashSet::new();
1791    for &index in &p.use_targets {
1792        if index == 0 {
1793            return Err(ProfileValidationError::ZeroTargetIndex {
1794                profile: p.name.clone(),
1795            });
1796        }
1797        if !seen.insert(index) {
1798            return Err(ProfileValidationError::DuplicateTargetIndex {
1799                profile: p.name.clone(),
1800                index,
1801            });
1802        }
1803    }
1804    if let TargetSelection::Selected(indices) = &selection {
1805        seen.clear();
1806        for &index in indices {
1807            if index == 0 {
1808                return Err(ProfileValidationError::ZeroTargetIndex {
1809                    profile: p.name.clone(),
1810                });
1811            }
1812            if !seen.insert(index) {
1813                return Err(ProfileValidationError::DuplicateTargetIndex {
1814                    profile: p.name.clone(),
1815                    index,
1816                });
1817            }
1818        }
1819    }
1820
1821    match &p.stoploss_mode {
1822        StoplossMode::FixedDistance { distance } => {
1823            require_positive_finite("stoploss fixed distance", *distance)
1824                .map_err(|error| invalid(error.to_string()))?;
1825        }
1826        StoplossMode::FixedPrice { price } => {
1827            require_positive_finite("stoploss fixed price", *price)
1828                .map_err(|error| invalid(error.to_string()))?;
1829        }
1830        StoplossMode::FromSignalDistance { multiplier } => {
1831            require_positive_finite("stoploss signal-distance multiplier", *multiplier)
1832                .map_err(|error| invalid(error.to_string()))?;
1833        }
1834        StoplossMode::FromSignal | StoplossMode::None => {}
1835    }
1836    resolve_rules(&p.rules, None, Side::Buy).map_err(|error| invalid(error.to_string()))?;
1837
1838    if p.close_ratios.is_empty() {
1839        return Ok(());
1840    }
1841
1842    match validate_weights(&p.close_ratios, p.let_remainder_run) {
1843        Ok(_) => Ok(()),
1844        Err(ProfileApplicationError::InvalidTargetWeight { .. }) => {
1845            Err(ProfileValidationError::ZeroRatio {
1846                profile: p.name.clone(),
1847            })
1848        }
1849        Err(ProfileApplicationError::TargetWeightSumExceeded { sum }) => {
1850            Err(ProfileValidationError::RatioSumExceeded {
1851                profile: p.name.clone(),
1852                sum,
1853            })
1854        }
1855        Err(ProfileApplicationError::TargetWeightSumIncomplete { sum }) => {
1856            Err(ProfileValidationError::RatioSumIncomplete {
1857                profile: p.name.clone(),
1858                sum,
1859            })
1860        }
1861        Err(error) => unreachable!("unexpected profile weight validation error: {error}"),
1862    }
1863}