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