1use 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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(tag = "type")]
152pub enum PositionRef {
153 ByTradeId { trade_id: TradeId },
155 AllOnSymbol { symbol: String },
157 AllInGroup { group_id: GroupId },
159}
160
161fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(tag = "action", deny_unknown_fields)]
184pub enum RawSignal {
185 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 #[serde(default)]
202 trade_id: Option<TradeId>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 entry_class: Option<String>,
206 },
207
208 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 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 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 pub fn is_entry(&self) -> bool {
319 matches!(self, Self::Entry { .. })
320 }
321}
322
323pub trait PositionResolver {
327 fn resolve(&self, pr: &PositionRef) -> Vec<PositionId>;
329 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
359pub 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), };
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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
534#[serde(tag = "type")]
535pub enum StoplossMode {
536 FromSignal,
538 None,
540 FixedDistance { distance: f64 },
542 FixedPrice { price: f64 },
544 FromSignalDistance { multiplier: f64 },
546}
547
548#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(rename_all = "lowercase")]
552pub enum EntryGeometryPolicy {
553 #[default]
556 Strict,
557 Permissive,
560}
561
562#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
570#[serde(tag = "type")]
571pub enum RuleConfigDef {
572 FixedStoploss { price: f64 },
574 TrailingStop { distance: f64 },
576 TakeProfit { price: f64, close_ratio: f64 },
578 BreakevenWhen { trigger_price: f64 },
580 BreakevenWhenOffset { trigger_price_offset: f64 },
582 BreakevenAfterTargets { after_n: u32 },
584 TimeExit { max_seconds: u64 },
586}
587
588impl RuleConfigDef {
589 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
633#[serde(tag = "type", deny_unknown_fields)]
634pub enum TargetSource {
635 #[default]
637 FromSignal,
638 StopDistanceMultiples { multiples: Vec<f64> },
640}
641
642#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
663#[serde(rename_all = "snake_case")]
664pub enum PriceGridSource {
665 InstrumentPriceGrid,
666 LegacyDigitsFallback,
667}
668
669#[derive(Debug, Clone, Copy)]
671pub struct EntryResolutionContext {
672 pub price_grid: DecimalGrid,
673 pub price_grid_source: PriceGridSource,
674}
675
676#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
698pub enum TargetSelection {
699 All,
701 None,
703 Selected(Vec<usize>),
705}
706
707#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
709pub struct TargetResolution {
710 #[serde(default)]
711 pub source: TargetResolutionSource,
712 pub selection: TargetSelection,
713 pub selected_indices: Vec<usize>,
715 #[serde(default)]
716 pub generated: Vec<GeneratedTargetResolution>,
717 pub weights: Vec<f64>,
719 pub remainder: f64,
721}
722
723#[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
763pub struct ManagementProfile {
764 pub name: String,
766
767 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub target_selection: Option<TargetSelection>,
773
774 pub use_targets: Vec<usize>,
777
778 pub close_ratios: Vec<f64>,
782
783 #[serde(default, skip_serializing_if = "target_source_is_default")]
785 pub target_source: TargetSource,
786
787 #[serde(default = "default_stoploss_mode")]
789 pub stoploss_mode: StoplossMode,
790
791 #[serde(default)]
793 pub rules: Vec<RuleConfigDef>,
794
795 #[serde(default)]
797 pub group_override: Option<String>,
798
799 #[serde(default)]
801 pub let_remainder_run: bool,
802
803 #[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 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 pub fn validate(&self) -> Result<(), ProfileValidationError> {
836 validate_profile(self)
837 }
838
839 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 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
1556pub 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
1633pub 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
1691pub 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
1714pub 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}