1use crate::contract::{ContractSpec, FxConversionError, ValuationError};
6use crate::model::InstrumentMeta;
7
8#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct AccountRisk {
11 pub equity: f64,
12 pub risk_pct_per_trade: f64,
14 pub max_leverage: f64,
15 pub max_position_notional: Option<f64>,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct PositionSizeResult {
21 pub size: f64,
23 pub risk_amount: f64,
26 pub capped_by_leverage: bool,
27 pub capped_by_notional: bool,
28}
29
30pub fn position_size(
36 account: &AccountRisk,
37 instrument: &InstrumentMeta,
38 entry: f64,
39 stop: f64,
40 tick_value: f64,
41) -> PositionSizeResult {
42 let risk_budget = account.equity * account.risk_pct_per_trade;
43 let price_risk = (entry - stop).abs();
44 if price_risk <= 0.0 || instrument.tick_size <= 0.0 || tick_value <= 0.0 {
45 return PositionSizeResult {
46 size: 0.0,
47 risk_amount: 0.0,
48 capped_by_leverage: false,
49 capped_by_notional: false,
50 };
51 }
52
53 let ticks_at_risk = price_risk / instrument.tick_size;
54 let risk_per_unit = ticks_at_risk * tick_value;
55 let mut size = risk_budget / risk_per_unit;
56
57 let leverage_cap = (account.equity * account.max_leverage) / entry.max(1e-9);
58 let mut capped_by_leverage = false;
59 if size > leverage_cap {
60 size = leverage_cap;
61 capped_by_leverage = true;
62 }
63
64 let mut capped_by_notional = false;
65 if let Some(max_notional) = account.max_position_notional {
66 let notional_cap = max_notional / entry.max(1e-9);
67 if size > notional_cap {
68 size = notional_cap;
69 capped_by_notional = true;
70 }
71 }
72
73 size = size.max(0.0);
74 let risk_amount = size * risk_per_unit;
75
76 PositionSizeResult {
77 size,
78 risk_amount,
79 capped_by_leverage,
80 capped_by_notional,
81 }
82}
83
84pub fn position_size_contract(
95 account: &AccountRisk,
96 spec: &ContractSpec,
97 entry: f64,
98 stop: f64,
99 fx_to_account: Option<f64>,
100) -> Result<PositionSizeResult, ValuationError> {
101 spec.validate()?;
102 if !entry.is_finite() || entry <= 0.0 {
103 return Err(ValuationError::NonPositivePrice(entry));
104 }
105 if !stop.is_finite() || stop <= 0.0 {
106 return Err(ValuationError::NonPositivePrice(stop));
107 }
108 if !account.equity.is_finite() || account.equity <= 0.0 {
109 return Err(ValuationError::NonFiniteInput("account.equity"));
110 }
111
112 let fx = match fx_to_account {
113 Some(rate) if rate.is_finite() && rate > 0.0 => rate,
114 Some(invalid) => {
115 return Err(ValuationError::FxUnavailable(
116 FxConversionError::InvalidRate(invalid),
117 ))
118 }
119 None => {
120 return Err(ValuationError::FxUnavailable(
121 FxConversionError::MissingPair {
122 from: spec.price_currency.clone(),
123 to: spec.settlement_currency.clone(),
124 },
125 ))
126 }
127 };
128
129 let price_risk = (entry - stop).abs();
130 if price_risk <= 0.0 {
131 return Ok(PositionSizeResult {
132 size: 0.0,
133 risk_amount: 0.0,
134 capped_by_leverage: false,
135 capped_by_notional: false,
136 });
137 }
138
139 let risk_budget = account.equity * account.risk_pct_per_trade;
140 let risk_per_unit = price_risk * spec.multiplier * fx;
141 let unit_notional = entry * spec.multiplier * fx;
142
143 if risk_per_unit <= 0.0 || unit_notional <= 0.0 {
144 return Ok(PositionSizeResult {
145 size: 0.0,
146 risk_amount: 0.0,
147 capped_by_leverage: false,
148 capped_by_notional: false,
149 });
150 }
151
152 let raw_risk_size = risk_budget / risk_per_unit;
153 let mut size = raw_risk_size;
154
155 let mut capped_by_leverage = false;
156 if account.max_leverage.is_finite() && account.max_leverage > 0.0 {
157 let max_account_notional = account.equity * account.max_leverage;
158 let leverage_cap = max_account_notional / unit_notional;
159 if size > leverage_cap {
160 size = leverage_cap;
161 capped_by_leverage = true;
162 }
163 }
164
165 let mut capped_by_notional = false;
166 if let Some(max_notional) = account.max_position_notional {
167 if max_notional.is_finite() && max_notional > 0.0 {
168 let notional_cap = max_notional / unit_notional;
169 if size > notional_cap {
170 size = notional_cap;
171 capped_by_notional = true;
172 }
173 }
174 }
175
176 let rounded_size = spec.round_quantity_down(size);
178 let actual_risk = rounded_size * risk_per_unit;
179
180 Ok(PositionSizeResult {
181 size: rounded_size,
182 risk_amount: actual_risk,
183 capped_by_leverage,
184 capped_by_notional,
185 })
186}
187
188#[derive(Debug, Clone, Copy, PartialEq)]
191pub struct ScaleInStep {
192 pub trigger_price: f64,
193 pub fraction: f64,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq)]
199pub struct ScaleOutStep {
200 pub trigger_r_multiple: f64,
201 pub fraction: f64,
202}
203
204#[derive(Debug, Clone, PartialEq, Default)]
205pub struct ScalePlan {
206 pub entries: Vec<ScaleInStep>,
207 pub exits: Vec<ScaleOutStep>,
208}
209
210impl ScalePlan {
211 pub fn triggered_entries(&self, current_price: f64, is_long: bool) -> Vec<&ScaleInStep> {
215 self.entries
216 .iter()
217 .filter(|step| {
218 if is_long {
219 current_price >= step.trigger_price
220 } else {
221 current_price <= step.trigger_price
222 }
223 })
224 .collect()
225 }
226
227 pub fn triggered_exits(&self, current_r_multiple: f64) -> Vec<&ScaleOutStep> {
229 self.exits
230 .iter()
231 .filter(|step| current_r_multiple >= step.trigger_r_multiple)
232 .collect()
233 }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Default)]
238pub struct StopManager {
239 pub breakeven_trigger_r: Option<f64>,
241 pub time_stop_bars: Option<u32>,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq)]
246pub enum StopDecision {
247 Hold,
248 MoveToBreakeven(f64),
249 TimeStopExit,
250}
251
252impl StopManager {
253 pub fn evaluate(
257 &self,
258 entry: f64,
259 current_price: f64,
260 risk_per_unit: f64,
261 bars_held: u32,
262 is_long: bool,
263 ) -> StopDecision {
264 if let Some(max_bars) = self.time_stop_bars {
265 if bars_held >= max_bars {
266 return StopDecision::TimeStopExit;
267 }
268 }
269
270 if let (Some(trigger_r), true) = (self.breakeven_trigger_r, risk_per_unit > 0.0) {
271 let favorable = if is_long {
272 current_price - entry
273 } else {
274 entry - current_price
275 };
276 let current_r = favorable / risk_per_unit;
277 if current_r >= trigger_r {
278 return StopDecision::MoveToBreakeven(entry);
279 }
280 }
281
282 StopDecision::Hold
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 fn account() -> AccountRisk {
291 AccountRisk {
292 equity: 100_000.0,
293 risk_pct_per_trade: 0.01, max_leverage: 100.0,
295 max_position_notional: None,
296 }
297 }
298
299 fn instrument() -> InstrumentMeta {
300 InstrumentMeta {
301 symbol: "TEST".to_string(),
302 tick_size: 0.25,
303 price_precision: 2,
304 timezone: "UTC".to_string(),
305 }
306 }
307
308 #[test]
309 fn test_position_size_from_account_risk() {
310 let result = position_size(&account(), &instrument(), 100.0, 98.0, 10.0);
313 assert!((result.size - 12.5).abs() < 1e-9);
314 assert!((result.risk_amount - 1000.0).abs() < 1e-6);
315 assert!(!result.capped_by_leverage);
316 }
317
318 #[test]
319 fn test_position_size_capped_by_leverage() {
320 let tight_account = AccountRisk {
321 max_leverage: 0.001,
322 ..account()
323 };
324 let result = position_size(&tight_account, &instrument(), 100.0, 98.0, 10.0);
325 assert!(result.capped_by_leverage);
326 assert!(result.size < 12.5);
327 }
328
329 #[test]
330 fn test_position_size_capped_by_notional() {
331 let capped_account = AccountRisk {
332 max_position_notional: Some(500.0),
333 ..account()
334 };
335 let result = position_size(&capped_account, &instrument(), 100.0, 98.0, 10.0);
336 assert!(result.capped_by_notional);
337 assert!((result.size - 5.0).abs() < 1e-9); }
339
340 #[test]
341 fn test_position_size_degenerate_inputs_return_zero() {
342 let result = position_size(&account(), &instrument(), 100.0, 100.0, 10.0); assert_eq!(result.size, 0.0);
344 }
345
346 #[test]
347 fn test_scale_plan_triggers() {
348 let plan = ScalePlan {
349 entries: vec![
350 ScaleInStep {
351 trigger_price: 101.0,
352 fraction: 0.5,
353 },
354 ScaleInStep {
355 trigger_price: 103.0,
356 fraction: 0.5,
357 },
358 ],
359 exits: vec![
360 ScaleOutStep {
361 trigger_r_multiple: 1.0,
362 fraction: 0.5,
363 },
364 ScaleOutStep {
365 trigger_r_multiple: 2.0,
366 fraction: 0.5,
367 },
368 ],
369 };
370
371 let triggered = plan.triggered_entries(102.0, true);
372 assert_eq!(triggered.len(), 1);
373 assert_eq!(triggered[0].trigger_price, 101.0);
374
375 let triggered_exits = plan.triggered_exits(1.5);
376 assert_eq!(triggered_exits.len(), 1);
377 }
378
379 #[test]
380 fn test_stop_manager_breakeven_trigger() {
381 let manager = StopManager {
382 breakeven_trigger_r: Some(1.0),
383 time_stop_bars: None,
384 };
385 let decision = manager.evaluate(100.0, 102.0, 2.0, 5, true); assert_eq!(decision, StopDecision::MoveToBreakeven(100.0));
387
388 let no_trigger = manager.evaluate(100.0, 100.5, 2.0, 5, true);
389 assert_eq!(no_trigger, StopDecision::Hold);
390 }
391
392 #[test]
393 fn test_stop_manager_time_stop_takes_priority() {
394 let manager = StopManager {
395 breakeven_trigger_r: Some(1.0),
396 time_stop_bars: Some(3),
397 };
398 let decision = manager.evaluate(100.0, 105.0, 2.0, 10, true);
401 assert_eq!(decision, StopDecision::TimeStopExit);
402 }
403
404 #[test]
405 fn test_stop_manager_short_side_direction() {
406 let manager = StopManager {
407 breakeven_trigger_r: Some(1.0),
408 time_stop_bars: None,
409 };
410 let decision = manager.evaluate(100.0, 98.0, 2.0, 5, false); assert_eq!(decision, StopDecision::MoveToBreakeven(100.0));
412 }
413}