1use crate::model::InstrumentMeta;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct AccountRisk {
10 pub equity: f64,
11 pub risk_pct_per_trade: f64,
13 pub max_leverage: f64,
14 pub max_position_notional: Option<f64>,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct PositionSizeResult {
20 pub size: f64,
22 pub risk_amount: f64,
25 pub capped_by_leverage: bool,
26 pub capped_by_notional: bool,
27}
28
29pub fn position_size(
35 account: &AccountRisk,
36 instrument: &InstrumentMeta,
37 entry: f64,
38 stop: f64,
39 tick_value: f64,
40) -> PositionSizeResult {
41 let risk_budget = account.equity * account.risk_pct_per_trade;
42 let price_risk = (entry - stop).abs();
43 if price_risk <= 0.0 || instrument.tick_size <= 0.0 || tick_value <= 0.0 {
44 return PositionSizeResult {
45 size: 0.0,
46 risk_amount: 0.0,
47 capped_by_leverage: false,
48 capped_by_notional: false,
49 };
50 }
51
52 let ticks_at_risk = price_risk / instrument.tick_size;
53 let risk_per_unit = ticks_at_risk * tick_value;
54 let mut size = risk_budget / risk_per_unit;
55
56 let leverage_cap = (account.equity * account.max_leverage) / entry.max(1e-9);
57 let mut capped_by_leverage = false;
58 if size > leverage_cap {
59 size = leverage_cap;
60 capped_by_leverage = true;
61 }
62
63 let mut capped_by_notional = false;
64 if let Some(max_notional) = account.max_position_notional {
65 let notional_cap = max_notional / entry.max(1e-9);
66 if size > notional_cap {
67 size = notional_cap;
68 capped_by_notional = true;
69 }
70 }
71
72 size = size.max(0.0);
73 let risk_amount = size * risk_per_unit;
74
75 PositionSizeResult {
76 size,
77 risk_amount,
78 capped_by_leverage,
79 capped_by_notional,
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq)]
86pub struct ScaleInStep {
87 pub trigger_price: f64,
88 pub fraction: f64,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq)]
94pub struct ScaleOutStep {
95 pub trigger_r_multiple: f64,
96 pub fraction: f64,
97}
98
99#[derive(Debug, Clone, PartialEq, Default)]
100pub struct ScalePlan {
101 pub entries: Vec<ScaleInStep>,
102 pub exits: Vec<ScaleOutStep>,
103}
104
105impl ScalePlan {
106 pub fn triggered_entries(&self, current_price: f64, is_long: bool) -> Vec<&ScaleInStep> {
110 self.entries
111 .iter()
112 .filter(|step| {
113 if is_long {
114 current_price >= step.trigger_price
115 } else {
116 current_price <= step.trigger_price
117 }
118 })
119 .collect()
120 }
121
122 pub fn triggered_exits(&self, current_r_multiple: f64) -> Vec<&ScaleOutStep> {
124 self.exits
125 .iter()
126 .filter(|step| current_r_multiple >= step.trigger_r_multiple)
127 .collect()
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Default)]
133pub struct StopManager {
134 pub breakeven_trigger_r: Option<f64>,
136 pub time_stop_bars: Option<u32>,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq)]
141pub enum StopDecision {
142 Hold,
143 MoveToBreakeven(f64),
144 TimeStopExit,
145}
146
147impl StopManager {
148 pub fn evaluate(
152 &self,
153 entry: f64,
154 current_price: f64,
155 risk_per_unit: f64,
156 bars_held: u32,
157 is_long: bool,
158 ) -> StopDecision {
159 if let Some(max_bars) = self.time_stop_bars {
160 if bars_held >= max_bars {
161 return StopDecision::TimeStopExit;
162 }
163 }
164
165 if let (Some(trigger_r), true) = (self.breakeven_trigger_r, risk_per_unit > 0.0) {
166 let favorable = if is_long {
167 current_price - entry
168 } else {
169 entry - current_price
170 };
171 let current_r = favorable / risk_per_unit;
172 if current_r >= trigger_r {
173 return StopDecision::MoveToBreakeven(entry);
174 }
175 }
176
177 StopDecision::Hold
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 fn account() -> AccountRisk {
186 AccountRisk {
187 equity: 100_000.0,
188 risk_pct_per_trade: 0.01, max_leverage: 100.0,
190 max_position_notional: None,
191 }
192 }
193
194 fn instrument() -> InstrumentMeta {
195 InstrumentMeta {
196 symbol: "TEST".to_string(),
197 tick_size: 0.25,
198 price_precision: 2,
199 timezone: "UTC".to_string(),
200 }
201 }
202
203 #[test]
204 fn test_position_size_from_account_risk() {
205 let result = position_size(&account(), &instrument(), 100.0, 98.0, 10.0);
208 assert!((result.size - 12.5).abs() < 1e-9);
209 assert!((result.risk_amount - 1000.0).abs() < 1e-6);
210 assert!(!result.capped_by_leverage);
211 }
212
213 #[test]
214 fn test_position_size_capped_by_leverage() {
215 let tight_account = AccountRisk {
216 max_leverage: 0.001,
217 ..account()
218 };
219 let result = position_size(&tight_account, &instrument(), 100.0, 98.0, 10.0);
220 assert!(result.capped_by_leverage);
221 assert!(result.size < 12.5);
222 }
223
224 #[test]
225 fn test_position_size_capped_by_notional() {
226 let capped_account = AccountRisk {
227 max_position_notional: Some(500.0),
228 ..account()
229 };
230 let result = position_size(&capped_account, &instrument(), 100.0, 98.0, 10.0);
231 assert!(result.capped_by_notional);
232 assert!((result.size - 5.0).abs() < 1e-9); }
234
235 #[test]
236 fn test_position_size_degenerate_inputs_return_zero() {
237 let result = position_size(&account(), &instrument(), 100.0, 100.0, 10.0); assert_eq!(result.size, 0.0);
239 }
240
241 #[test]
242 fn test_scale_plan_triggers() {
243 let plan = ScalePlan {
244 entries: vec![
245 ScaleInStep {
246 trigger_price: 101.0,
247 fraction: 0.5,
248 },
249 ScaleInStep {
250 trigger_price: 103.0,
251 fraction: 0.5,
252 },
253 ],
254 exits: vec![
255 ScaleOutStep {
256 trigger_r_multiple: 1.0,
257 fraction: 0.5,
258 },
259 ScaleOutStep {
260 trigger_r_multiple: 2.0,
261 fraction: 0.5,
262 },
263 ],
264 };
265
266 let triggered = plan.triggered_entries(102.0, true);
267 assert_eq!(triggered.len(), 1);
268 assert_eq!(triggered[0].trigger_price, 101.0);
269
270 let triggered_exits = plan.triggered_exits(1.5);
271 assert_eq!(triggered_exits.len(), 1);
272 }
273
274 #[test]
275 fn test_stop_manager_breakeven_trigger() {
276 let manager = StopManager {
277 breakeven_trigger_r: Some(1.0),
278 time_stop_bars: None,
279 };
280 let decision = manager.evaluate(100.0, 102.0, 2.0, 5, true); assert_eq!(decision, StopDecision::MoveToBreakeven(100.0));
282
283 let no_trigger = manager.evaluate(100.0, 100.5, 2.0, 5, true);
284 assert_eq!(no_trigger, StopDecision::Hold);
285 }
286
287 #[test]
288 fn test_stop_manager_time_stop_takes_priority() {
289 let manager = StopManager {
290 breakeven_trigger_r: Some(1.0),
291 time_stop_bars: Some(3),
292 };
293 let decision = manager.evaluate(100.0, 105.0, 2.0, 10, true);
296 assert_eq!(decision, StopDecision::TimeStopExit);
297 }
298
299 #[test]
300 fn test_stop_manager_short_side_direction() {
301 let manager = StopManager {
302 breakeven_trigger_r: Some(1.0),
303 time_stop_bars: None,
304 };
305 let decision = manager.evaluate(100.0, 98.0, 2.0, 5, false); assert_eq!(decision, StopDecision::MoveToBreakeven(100.0));
307 }
308}