use std::collections::HashSet;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use crate::TradeEngine;
use crate::types::{
Action, GroupId, OrderType, PositionId, PositionStatus, RuleConfig, Side, TargetSpec, TradeId,
};
#[derive(Debug, thiserror::Error)]
pub enum ProfileValidationError {
#[error(
"Profile '{profile}': selected target count ({targets}) does not match close_ratios length ({ratios})"
)]
TargetRatioMismatch {
profile: String,
targets: usize,
ratios: usize,
},
#[error("Profile '{profile}': close_ratios sum to {sum:.4}, which exceeds 1.0")]
RatioSumExceeded { profile: String, sum: f64 },
#[error(
"Profile '{profile}': close_ratios sum to {sum:.4}; they must sum to 1.0 when let_remainder_run is false"
)]
RatioSumIncomplete { profile: String, sum: f64 },
#[error("Profile '{profile}': close_ratios contains a non-finite or non-positive value")]
ZeroRatio { profile: String },
#[error("Profile '{profile}': target selection contains a 0 index (must be 1-indexed)")]
ZeroTargetIndex { profile: String },
#[error("Profile '{profile}': target index {index} is selected more than once")]
DuplicateTargetIndex { profile: String, index: usize },
#[error("Profile '{profile}': {reason}")]
InvalidConfiguration { profile: String, reason: String },
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum ProfileApplicationError {
#[error("{field} must be finite and greater than zero, got {value}")]
InvalidNumericInput { field: String, value: f64 },
#[error("target indices are 1-based; index 0 is invalid")]
ZeroTargetIndex,
#[error("target index {index} is selected more than once")]
DuplicateTargetIndex { index: usize },
#[error("target price {price} is selected more than once")]
DuplicateTargetPrice { price: f64 },
#[error("target index {index} is missing; signal provides {available} target(s)")]
MissingTargetIndex { index: usize, available: usize },
#[error("selected target count ({targets}) does not match explicit weight count ({weights})")]
TargetWeightCountMismatch { targets: usize, weights: usize },
#[error("target weight {position} must be finite and greater than zero, got {weight}")]
InvalidTargetWeight { position: usize, weight: f64 },
#[error("target weights sum to {sum}, which exceeds 1.0")]
TargetWeightSumExceeded { sum: f64 },
#[error("target weights sum to {sum}; they must sum to 1.0 when no remainder runs")]
TargetWeightSumIncomplete { sum: f64 },
#[error(
"target {index} at {target} is invalid for {side} entry at {entry}: buy targets must be above entry and sell targets below entry"
)]
InvalidTargetGeometry {
index: usize,
side: Side,
entry: f64,
target: f64,
},
#[error(
"stop {stoploss} is invalid for {side} entry at {entry}: buy stops must be below entry and sell stops above entry"
)]
InvalidStopGeometry {
side: Side,
entry: f64,
stoploss: f64,
},
#[error("size {size} is not an integer multiple of lot_step {lot_step}")]
SizeNotMultipleOfLotStep { size: f64, lot_step: f64 },
#[error("size {size} and lot_step {lot_step} produce a lot count outside u64 range")]
LotUnitCountOverflow { size: f64, lot_step: f64 },
#[error("target allocation {position} rounds to zero lot units")]
ZeroUnitAllocation { position: usize },
#[error("allocation remainder must be finite and non-negative, got {remainder}")]
InvalidRemainder { remainder: f64 },
#[error(
"target weights sum to {sum}, but allocation remainder is {remainder}; together they must equal 1.0"
)]
TargetWeightRemainderMismatch { sum: f64, remainder: f64 },
#[error("{field} must be greater than zero, got {value}")]
InvalidCountInput { field: String, value: u64 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum PositionRef {
ByTradeId { trade_id: TradeId },
AllOnSymbol { symbol: String },
AllInGroup { group_id: GroupId },
}
fn deserialize_risk_multiplier<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = f64::deserialize(deserializer)?;
if value.is_finite() && value > 0.0 {
Ok(value)
} else {
Err(serde::de::Error::custom(format!(
"risk must be finite and greater than zero, got {value}"
)))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", deny_unknown_fields)]
pub enum RawSignal {
Entry {
ts: NaiveDateTime,
symbol: String,
side: Side,
order_type: OrderType,
price: Option<f64>,
#[serde(rename = "risk", deserialize_with = "deserialize_risk_multiplier")]
risk_multiplier: f64,
stoploss: Option<f64>,
#[serde(default)]
targets: Vec<f64>,
#[serde(default)]
group: Option<String>,
#[serde(default)]
trade_id: Option<TradeId>,
},
Close {
ts: NaiveDateTime,
position: PositionRef,
},
ClosePartial {
ts: NaiveDateTime,
position: PositionRef,
ratio: f64,
},
ModifyStoploss {
ts: NaiveDateTime,
position: PositionRef,
price: f64,
},
MoveStoplossToEntry {
ts: NaiveDateTime,
position: PositionRef,
},
AddTarget {
ts: NaiveDateTime,
position: PositionRef,
price: f64,
close_ratio: f64,
},
RemoveTarget {
ts: NaiveDateTime,
position: PositionRef,
price: f64,
},
ModifyTarget {
ts: NaiveDateTime,
position: PositionRef,
old_price: f64,
new_price: f64,
},
AddRule {
ts: NaiveDateTime,
position: PositionRef,
rule: RuleConfigDef,
},
RemoveRule {
ts: NaiveDateTime,
position: PositionRef,
rule_name: String,
},
ScaleIn {
ts: NaiveDateTime,
position: PositionRef,
price: Option<f64>,
size: f64,
},
CancelPending {
ts: NaiveDateTime,
position: PositionRef,
},
CloseAllOf {
ts: NaiveDateTime,
symbol: String,
},
CloseAll {
ts: NaiveDateTime,
},
CancelAllPending {
ts: NaiveDateTime,
},
ModifyAllStoploss {
ts: NaiveDateTime,
symbol: String,
price: f64,
},
CloseAllInGroup {
ts: NaiveDateTime,
group_id: GroupId,
},
ModifyAllStoplossInGroup {
ts: NaiveDateTime,
group_id: GroupId,
price: f64,
},
}
impl RawSignal {
pub fn ts(&self) -> NaiveDateTime {
match self {
Self::Entry { ts, .. } => *ts,
Self::Close { ts, .. } => *ts,
Self::ClosePartial { ts, .. } => *ts,
Self::ModifyStoploss { ts, .. } => *ts,
Self::MoveStoplossToEntry { ts, .. } => *ts,
Self::AddTarget { ts, .. } => *ts,
Self::RemoveTarget { ts, .. } => *ts,
Self::ModifyTarget { ts, .. } => *ts,
Self::AddRule { ts, .. } => *ts,
Self::RemoveRule { ts, .. } => *ts,
Self::ScaleIn { ts, .. } => *ts,
Self::CancelPending { ts, .. } => *ts,
Self::CloseAllOf { ts, .. } => *ts,
Self::CloseAll { ts, .. } => *ts,
Self::CancelAllPending { ts, .. } => *ts,
Self::ModifyAllStoploss { ts, .. } => *ts,
Self::CloseAllInGroup { ts, .. } => *ts,
Self::ModifyAllStoplossInGroup { ts, .. } => *ts,
}
}
pub fn is_entry(&self) -> bool {
matches!(self, Self::Entry { .. })
}
}
pub trait PositionResolver {
fn resolve(&self, pr: &PositionRef) -> Vec<PositionId>;
fn position_entry_info(&self, id: &PositionId) -> Option<(f64, Side)>;
}
impl PositionResolver for TradeEngine {
fn resolve(&self, position: &PositionRef) -> Vec<PositionId> {
match position {
PositionRef::ByTradeId { trade_id } => {
self.manager.id_by_trade_id(trade_id).into_iter().collect()
}
PositionRef::AllOnSymbol { symbol } => self.manager.open_ids_by_symbol_sorted(symbol),
PositionRef::AllInGroup { group_id } => {
let mut ids = self.manager.open_ids_by_group(group_id);
ids.sort();
ids
}
}
}
fn position_entry_info(&self, id: &PositionId) -> Option<(f64, Side)> {
self.get_position(id).and_then(|position| {
if position.data.status == PositionStatus::Open {
Some((position.data.average_entry(), position.data.side))
} else {
None
}
})
}
}
pub fn resolve_signal(signal: &RawSignal, resolver: &impl PositionResolver) -> Vec<Action> {
match signal {
RawSignal::Entry { .. } => vec![],
RawSignal::Close { position, .. } => resolver
.resolve(position)
.into_iter()
.map(|id| Action::ClosePosition { position_id: id })
.collect(),
RawSignal::ClosePartial {
position, ratio, ..
} => resolver
.resolve(position)
.into_iter()
.map(|id| Action::ClosePartial {
position_id: id,
ratio: *ratio,
})
.collect(),
RawSignal::ModifyStoploss {
position, price, ..
} => resolver
.resolve(position)
.into_iter()
.map(|id| Action::ModifyStoploss {
position_id: id,
price: *price,
})
.collect(),
RawSignal::MoveStoplossToEntry { position, .. } => resolver
.resolve(position)
.into_iter()
.map(|id| Action::MoveStoplossToEntry { position_id: id })
.collect(),
RawSignal::AddTarget {
position,
price,
close_ratio,
..
} => resolver
.resolve(position)
.into_iter()
.map(|id| Action::AddTarget {
position_id: id,
price: *price,
close_ratio: *close_ratio,
})
.collect(),
RawSignal::RemoveTarget {
position, price, ..
} => resolver
.resolve(position)
.into_iter()
.map(|id| Action::RemoveTarget {
position_id: id,
price: *price,
})
.collect(),
RawSignal::ModifyTarget {
position,
old_price,
new_price,
..
} => resolver
.resolve(position)
.into_iter()
.map(|id| Action::ModifyTarget {
position_id: id,
old_price: *old_price,
new_price: *new_price,
})
.collect(),
RawSignal::AddRule { position, rule, .. } => {
resolver
.resolve(position)
.into_iter()
.filter_map(|id| {
let info = resolver.position_entry_info(&id);
let (entry_price, side) = match info {
Some((ep, s)) => (Some(ep), s),
None => (None, Side::Buy), };
rule.resolve(entry_price, side)
.map(|resolved_rule| Action::AddRule {
position_id: id,
rule: resolved_rule,
})
})
.collect()
}
RawSignal::RemoveRule {
position,
rule_name,
..
} => resolver
.resolve(position)
.into_iter()
.map(|id| Action::RemoveRule {
position_id: id,
rule_name: rule_name.clone(),
})
.collect(),
RawSignal::ScaleIn {
position,
price,
size,
..
} => resolver
.resolve(position)
.into_iter()
.map(|id| Action::ScaleIn {
position_id: id,
price: *price,
size: *size,
trade_id: None,
})
.collect(),
RawSignal::CancelPending { position, .. } => resolver
.resolve(position)
.into_iter()
.map(|id| Action::CancelPending { position_id: id })
.collect(),
RawSignal::CloseAllOf { symbol, .. } => {
vec![Action::CloseAllOf {
symbol: symbol.clone(),
}]
}
RawSignal::CloseAll { .. } => {
vec![Action::CloseAll]
}
RawSignal::CancelAllPending { .. } => {
vec![Action::CancelAllPending]
}
RawSignal::ModifyAllStoploss { symbol, price, .. } => {
vec![Action::ModifyAllStoploss {
symbol: symbol.clone(),
price: *price,
}]
}
RawSignal::CloseAllInGroup { group_id, .. } => {
vec![Action::CloseAllInGroup {
group_id: group_id.clone(),
}]
}
RawSignal::ModifyAllStoplossInGroup {
group_id, price, ..
} => {
vec![Action::ModifyAllStoplossInGroup {
group_id: group_id.clone(),
price: *price,
}]
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum StoplossMode {
FromSignal,
None,
FixedDistance { distance: f64 },
FixedPrice { price: f64 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum RuleConfigDef {
FixedStoploss { price: f64 },
TrailingStop { distance: f64 },
TakeProfit { price: f64, close_ratio: f64 },
BreakevenWhen { trigger_price: f64 },
BreakevenWhenOffset { trigger_price_offset: f64 },
BreakevenAfterTargets { after_n: u32 },
TimeExit { max_seconds: u64 },
}
impl RuleConfigDef {
pub fn resolve(&self, entry_price: Option<f64>, side: Side) -> Option<RuleConfig> {
match self {
Self::FixedStoploss { price } => Some(RuleConfig::FixedStoploss { price: *price }),
Self::TrailingStop { distance } => Some(RuleConfig::TrailingStop {
distance: *distance,
}),
Self::TakeProfit { price, close_ratio } => Some(RuleConfig::TakeProfit {
price: *price,
close_ratio: *close_ratio,
}),
Self::BreakevenWhen { trigger_price } => Some(RuleConfig::BreakevenWhen {
trigger_price: *trigger_price,
}),
Self::BreakevenWhenOffset {
trigger_price_offset,
} => {
let entry = entry_price?;
let trigger = match side {
Side::Buy => entry + trigger_price_offset,
Side::Sell => entry - trigger_price_offset,
};
Some(RuleConfig::BreakevenWhen {
trigger_price: trigger,
})
}
Self::BreakevenAfterTargets { after_n } => {
Some(RuleConfig::BreakevenAfterTargets { after_n: *after_n })
}
Self::TimeExit { max_seconds } => Some(RuleConfig::TimeExit {
max_seconds: *max_seconds,
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TargetSelection {
All,
None,
Selected(Vec<usize>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TargetResolution {
pub selection: TargetSelection,
pub selected_indices: Vec<usize>,
pub weights: Vec<f64>,
pub remainder: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedEntry {
pub risk_multiplier: f64,
pub symbol: String,
pub side: Side,
pub order_type: OrderType,
pub price: Option<f64>,
pub stoploss: Option<f64>,
pub targets: Vec<TargetSpec>,
pub rules: Vec<RuleConfig>,
pub group: Option<GroupId>,
pub trade_id: Option<TradeId>,
pub target_resolution: TargetResolution,
}
impl ResolvedEntry {
pub fn into_action(self, lot_size: f64) -> Action {
Action::Open {
symbol: self.symbol,
side: self.side,
order_type: self.order_type,
price: self.price,
size: lot_size,
stoploss: self.stoploss,
targets: self.targets,
rules: self.rules,
group: self.group,
trade_id: self.trade_id,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManagementProfile {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_selection: Option<TargetSelection>,
pub use_targets: Vec<usize>,
pub close_ratios: Vec<f64>,
#[serde(default = "default_stoploss_mode")]
pub stoploss_mode: StoplossMode,
#[serde(default)]
pub rules: Vec<RuleConfigDef>,
#[serde(default)]
pub group_override: Option<String>,
#[serde(default)]
pub let_remainder_run: bool,
}
fn default_stoploss_mode() -> StoplossMode {
StoplossMode::FromSignal
}
impl ManagementProfile {
pub fn effective_target_selection(&self) -> TargetSelection {
self.target_selection.clone().unwrap_or_else(|| {
if self.use_targets.is_empty() {
TargetSelection::None
} else {
TargetSelection::Selected(self.use_targets.clone())
}
})
}
pub fn validate(&self) -> Result<(), ProfileValidationError> {
validate_profile(self)
}
pub fn apply_entry_signal(
&self,
signal: &RawSignal,
) -> Result<Option<ResolvedEntry>, ProfileApplicationError> {
let (
symbol,
side,
order_type,
price,
risk_multiplier,
signal_stoploss,
signal_targets,
group,
trade_id,
) = match signal {
RawSignal::Entry {
symbol,
side,
order_type,
price,
risk_multiplier,
stoploss,
targets,
group,
trade_id,
..
} => (
symbol,
side,
order_type,
price,
risk_multiplier,
stoploss,
targets,
group,
trade_id,
),
_ => return Ok(None),
};
validate_entry_numbers(*price, *risk_multiplier, *signal_stoploss, signal_targets)?;
let selection = self.effective_target_selection();
let (targets, target_resolution) = resolve_targets(
signal_targets,
*side,
*price,
selection,
&self.close_ratios,
self.let_remainder_run,
)?;
let stoploss = resolve_stoploss(&self.stoploss_mode, *signal_stoploss, *price, *side)?;
let rules = resolve_rules(&self.rules, *price, *side)?;
Ok(Some(ResolvedEntry {
risk_multiplier: *risk_multiplier,
symbol: symbol.clone(),
side: *side,
order_type: *order_type,
price: *price,
stoploss,
targets,
rules,
group: self.group_override.clone().or(group.clone()),
trade_id: trade_id.clone(),
target_resolution,
}))
}
}
const WEIGHT_TOLERANCE: f64 = 1e-12;
const LOT_ALIGNMENT_TOLERANCE: f64 = 1e-9;
fn require_positive_finite(
field: impl Into<String>,
value: f64,
) -> Result<(), ProfileApplicationError> {
if value.is_finite() && value > 0.0 {
Ok(())
} else {
Err(ProfileApplicationError::InvalidNumericInput {
field: field.into(),
value,
})
}
}
fn validate_entry_numbers(
price: Option<f64>,
risk_multiplier: f64,
stoploss: Option<f64>,
targets: &[f64],
) -> Result<(), ProfileApplicationError> {
require_positive_finite("risk_multiplier", risk_multiplier)?;
if let Some(price) = price {
require_positive_finite("price", price)?;
}
if let Some(stoploss) = stoploss {
require_positive_finite("stoploss", stoploss)?;
}
for (offset, &target) in targets.iter().enumerate() {
require_positive_finite(format!("target {}", offset + 1), target)?;
}
Ok(())
}
fn weights_sum_to_one(sum: f64) -> bool {
(sum - 1.0).abs() <= WEIGHT_TOLERANCE
}
fn validate_weights(
weights: &[f64],
let_remainder_run: bool,
) -> Result<f64, ProfileApplicationError> {
for (offset, &weight) in weights.iter().enumerate() {
if !weight.is_finite() || weight <= 0.0 {
return Err(ProfileApplicationError::InvalidTargetWeight {
position: offset + 1,
weight,
});
}
}
let sum: f64 = weights.iter().sum();
if !sum.is_finite() || sum > 1.0 + WEIGHT_TOLERANCE {
return Err(ProfileApplicationError::TargetWeightSumExceeded { sum });
}
if !let_remainder_run && !weights_sum_to_one(sum) {
return Err(ProfileApplicationError::TargetWeightSumIncomplete { sum });
}
Ok(if weights_sum_to_one(sum) {
0.0
} else {
1.0 - sum
})
}
fn resolve_targets(
signal_targets: &[f64],
side: Side,
entry_price: Option<f64>,
selection: TargetSelection,
explicit_weights: &[f64],
let_remainder_run: bool,
) -> Result<(Vec<TargetSpec>, TargetResolution), ProfileApplicationError> {
let selected_indices = match &selection {
TargetSelection::All => (1..=signal_targets.len()).collect(),
TargetSelection::None => Vec::new(),
TargetSelection::Selected(indices) => {
let mut seen = HashSet::with_capacity(indices.len());
for &index in indices {
if index == 0 {
return Err(ProfileApplicationError::ZeroTargetIndex);
}
if !seen.insert(index) {
return Err(ProfileApplicationError::DuplicateTargetIndex { index });
}
if index > signal_targets.len() {
return Err(ProfileApplicationError::MissingTargetIndex {
index,
available: signal_targets.len(),
});
}
}
indices.clone()
}
};
if selected_indices.is_empty() {
if !explicit_weights.is_empty() {
return Err(ProfileApplicationError::TargetWeightCountMismatch {
targets: 0,
weights: explicit_weights.len(),
});
}
return Ok((
Vec::new(),
TargetResolution {
selection,
selected_indices,
weights: Vec::new(),
remainder: 1.0,
},
));
}
let weights = if explicit_weights.is_empty() {
vec![1.0 / selected_indices.len() as f64; selected_indices.len()]
} else {
if explicit_weights.len() != selected_indices.len() {
return Err(ProfileApplicationError::TargetWeightCountMismatch {
targets: selected_indices.len(),
weights: explicit_weights.len(),
});
}
explicit_weights.to_vec()
};
let remainder = validate_weights(&weights, let_remainder_run)?;
let mut targets = Vec::with_capacity(selected_indices.len());
let mut target_price_keys = HashSet::with_capacity(selected_indices.len());
for (&index, &weight) in selected_indices.iter().zip(&weights) {
let target = signal_targets[index - 1];
let target_key = (target * 1_000_000.0).round() as i64;
if !target_price_keys.insert(target_key) {
return Err(ProfileApplicationError::DuplicateTargetPrice { price: target });
}
if let Some(entry) = entry_price {
let valid_geometry = match side {
Side::Buy => target > entry,
Side::Sell => target < entry,
};
if !valid_geometry {
return Err(ProfileApplicationError::InvalidTargetGeometry {
index,
side,
entry,
target,
});
}
}
targets.push(TargetSpec {
price: target,
close_ratio: weight,
});
}
Ok((
targets,
TargetResolution {
selection,
selected_indices,
weights,
remainder,
},
))
}
fn validate_stop_geometry(
side: Side,
entry: f64,
stoploss: f64,
) -> Result<(), ProfileApplicationError> {
let valid = match side {
Side::Buy => stoploss < entry,
Side::Sell => stoploss > entry,
};
if valid {
Ok(())
} else {
Err(ProfileApplicationError::InvalidStopGeometry {
side,
entry,
stoploss,
})
}
}
fn validate_target_geometry(
index: usize,
side: Side,
entry: f64,
target: f64,
) -> Result<(), ProfileApplicationError> {
let valid = match side {
Side::Buy => target > entry,
Side::Sell => target < entry,
};
if valid {
Ok(())
} else {
Err(ProfileApplicationError::InvalidTargetGeometry {
index,
side,
entry,
target,
})
}
}
fn resolve_stoploss(
mode: &StoplossMode,
signal_stoploss: Option<f64>,
entry_price: Option<f64>,
side: Side,
) -> Result<Option<f64>, ProfileApplicationError> {
let stoploss = match mode {
StoplossMode::FromSignal => signal_stoploss,
StoplossMode::None => None,
StoplossMode::FixedDistance { distance } => {
require_positive_finite("stoploss fixed distance", *distance)?;
entry_price.map(|entry| match side {
Side::Buy => entry - distance,
Side::Sell => entry + distance,
})
}
StoplossMode::FixedPrice { price } => {
require_positive_finite("stoploss fixed price", *price)?;
Some(*price)
}
};
if let Some(stoploss) = stoploss {
require_positive_finite("resolved stoploss", stoploss)?;
if let Some(entry) = entry_price {
validate_stop_geometry(side, entry, stoploss)?;
}
}
Ok(stoploss)
}
fn resolve_rules(
definitions: &[RuleConfigDef],
entry_price: Option<f64>,
side: Side,
) -> Result<Vec<RuleConfig>, ProfileApplicationError> {
let mut rules = Vec::with_capacity(definitions.len());
for (offset, definition) in definitions.iter().enumerate() {
let position = offset + 1;
match definition {
RuleConfigDef::FixedStoploss { price } => {
require_positive_finite(format!("rule {position} fixed stoploss price"), *price)?;
if let Some(entry) = entry_price {
validate_stop_geometry(side, entry, *price)?;
}
}
RuleConfigDef::TrailingStop { distance } => {
require_positive_finite(format!("rule {position} trailing distance"), *distance)?;
if let Some(entry) = entry_price {
let initial_stop = match side {
Side::Buy => entry - distance,
Side::Sell => entry + distance,
};
require_positive_finite(
format!("rule {position} initial trailing stop"),
initial_stop,
)?;
validate_stop_geometry(side, entry, initial_stop)?;
}
}
RuleConfigDef::TakeProfit { price, close_ratio } => {
require_positive_finite(format!("rule {position} take-profit price"), *price)?;
require_positive_finite(
format!("rule {position} take-profit close ratio"),
*close_ratio,
)?;
if *close_ratio > 1.0 {
return Err(ProfileApplicationError::InvalidTargetWeight {
position,
weight: *close_ratio,
});
}
if let Some(entry) = entry_price {
validate_target_geometry(position, side, entry, *price)?;
}
}
RuleConfigDef::BreakevenWhen { trigger_price } => {
require_positive_finite(
format!("rule {position} breakeven trigger price"),
*trigger_price,
)?;
if let Some(entry) = entry_price {
validate_target_geometry(position, side, entry, *trigger_price)?;
}
}
RuleConfigDef::BreakevenWhenOffset {
trigger_price_offset,
} => {
require_positive_finite(
format!("rule {position} breakeven trigger offset"),
*trigger_price_offset,
)?;
}
RuleConfigDef::BreakevenAfterTargets { after_n } => {
if *after_n == 0 {
return Err(ProfileApplicationError::InvalidCountInput {
field: format!("rule {position} breakeven target count"),
value: 0,
});
}
}
RuleConfigDef::TimeExit { max_seconds } => {
if *max_seconds == 0 {
return Err(ProfileApplicationError::InvalidCountInput {
field: format!("rule {position} maximum seconds"),
value: 0,
});
}
}
}
if let Some(rule) = definition.resolve(entry_price, side) {
if let RuleConfig::BreakevenWhen { trigger_price } = &rule {
require_positive_finite(
format!("rule {position} resolved breakeven trigger"),
*trigger_price,
)?;
if let Some(entry) = entry_price {
validate_target_geometry(position, side, entry, *trigger_price)?;
}
}
rules.push(rule);
}
}
Ok(rules)
}
pub fn resolve_unprofiled_entry(
signal: &RawSignal,
) -> Result<Option<ResolvedEntry>, ProfileApplicationError> {
let (
symbol,
side,
order_type,
price,
risk_multiplier,
stoploss,
signal_targets,
group,
trade_id,
) = match signal {
RawSignal::Entry {
symbol,
side,
order_type,
price,
risk_multiplier,
stoploss,
targets,
group,
trade_id,
..
} => (
symbol,
side,
order_type,
price,
risk_multiplier,
stoploss,
targets,
group,
trade_id,
),
_ => return Ok(None),
};
validate_entry_numbers(*price, *risk_multiplier, *stoploss, signal_targets)?;
let (targets, target_resolution) = resolve_targets(
signal_targets,
*side,
*price,
TargetSelection::All,
&[],
false,
)?;
Ok(Some(ResolvedEntry {
risk_multiplier: *risk_multiplier,
symbol: symbol.clone(),
side: *side,
order_type: *order_type,
price: *price,
stoploss: *stoploss,
targets,
rules: Vec::new(),
group: group.clone(),
trade_id: trade_id.clone(),
target_resolution,
}))
}
pub fn allocate_target_steps(
total_steps: u64,
weights: &[f64],
remainder: f64,
) -> Result<Vec<u64>, ProfileApplicationError> {
if total_steps == 0 {
return Err(ProfileApplicationError::InvalidCountInput {
field: "total_steps".into(),
value: total_steps,
});
}
if !remainder.is_finite() || remainder < 0.0 {
return Err(ProfileApplicationError::InvalidRemainder { remainder });
}
if weights.is_empty() {
if weights_sum_to_one(remainder) {
return Ok(Vec::new());
}
return Err(ProfileApplicationError::TargetWeightRemainderMismatch {
sum: 0.0,
remainder,
});
}
let computed_remainder = validate_weights(weights, true)?;
let weight_sum = 1.0 - computed_remainder;
if !weights_sum_to_one(weight_sum + remainder) {
return Err(ProfileApplicationError::TargetWeightRemainderMismatch {
sum: weight_sum,
remainder,
});
}
let assign_residue_to_final = weights_sum_to_one(weight_sum);
let mut allocations = Vec::with_capacity(weights.len());
let mut allocated = 0_u64;
for (offset, &weight) in weights.iter().enumerate() {
let is_final = offset + 1 == weights.len();
let steps = if is_final && assign_residue_to_final {
total_steps.saturating_sub(allocated)
} else {
((total_steps as f64) * weight).floor() as u64
};
if steps == 0 {
return Err(ProfileApplicationError::ZeroUnitAllocation {
position: offset + 1,
});
}
allocated = allocated.saturating_add(steps);
allocations.push(steps);
}
Ok(allocations)
}
pub fn allocate_target_units(
size: f64,
lot_step: f64,
weights: &[f64],
remainder: f64,
) -> Result<Vec<u64>, ProfileApplicationError> {
require_positive_finite("size", size)?;
require_positive_finite("lot_step", lot_step)?;
let raw_units = size / lot_step;
if !raw_units.is_finite() || raw_units >= u64::MAX as f64 {
return Err(ProfileApplicationError::LotUnitCountOverflow { size, lot_step });
}
let rounded_units = raw_units.round();
let alignment_tolerance = LOT_ALIGNMENT_TOLERANCE * raw_units.abs().max(1.0);
if (raw_units - rounded_units).abs() > alignment_tolerance || rounded_units < 1.0 {
return Err(ProfileApplicationError::SizeNotMultipleOfLotStep { size, lot_step });
}
allocate_target_steps(rounded_units as u64, weights, remainder)
}
pub fn validate_profile(p: &ManagementProfile) -> Result<(), ProfileValidationError> {
let selection = p.effective_target_selection();
let selected_count = match &selection {
TargetSelection::All => None,
TargetSelection::None => Some(0),
TargetSelection::Selected(indices) => Some(indices.len()),
};
if let Some(targets) = selected_count
&& !p.close_ratios.is_empty()
&& targets != p.close_ratios.len()
{
return Err(ProfileValidationError::TargetRatioMismatch {
profile: p.name.clone(),
targets,
ratios: p.close_ratios.len(),
});
}
let mut seen = HashSet::new();
for &index in &p.use_targets {
if index == 0 {
return Err(ProfileValidationError::ZeroTargetIndex {
profile: p.name.clone(),
});
}
if !seen.insert(index) {
return Err(ProfileValidationError::DuplicateTargetIndex {
profile: p.name.clone(),
index,
});
}
}
if let TargetSelection::Selected(indices) = &selection {
seen.clear();
for &index in indices {
if index == 0 {
return Err(ProfileValidationError::ZeroTargetIndex {
profile: p.name.clone(),
});
}
if !seen.insert(index) {
return Err(ProfileValidationError::DuplicateTargetIndex {
profile: p.name.clone(),
index,
});
}
}
}
resolve_stoploss(&p.stoploss_mode, None, None, Side::Buy).map_err(|error| {
ProfileValidationError::InvalidConfiguration {
profile: p.name.clone(),
reason: error.to_string(),
}
})?;
resolve_rules(&p.rules, None, Side::Buy).map_err(|error| {
ProfileValidationError::InvalidConfiguration {
profile: p.name.clone(),
reason: error.to_string(),
}
})?;
if p.close_ratios.is_empty() {
return Ok(());
}
match validate_weights(&p.close_ratios, p.let_remainder_run) {
Ok(_) => Ok(()),
Err(ProfileApplicationError::InvalidTargetWeight { .. }) => {
Err(ProfileValidationError::ZeroRatio {
profile: p.name.clone(),
})
}
Err(ProfileApplicationError::TargetWeightSumExceeded { sum }) => {
Err(ProfileValidationError::RatioSumExceeded {
profile: p.name.clone(),
sum,
})
}
Err(ProfileApplicationError::TargetWeightSumIncomplete { sum }) => {
Err(ProfileValidationError::RatioSumIncomplete {
profile: p.name.clone(),
sum,
})
}
Err(error) => unreachable!("unexpected profile weight validation error: {error}"),
}
}