Skip to main content

kestrel_chartkit/
risk.rs

1//! Provider-neutral risk and position-sizing: account-risk-based sizing on top of
2//! [`InstrumentMeta`] (tick size, not a broker-specific contract spec), leverage/position limits,
3//! scale-in/out plans, and break-even/time-stop rules.
4
5use crate::model::InstrumentMeta;
6
7/// Account-level risk configuration.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct AccountRisk {
10    pub equity: f64,
11    /// Fraction of `equity` risked per trade (e.g. `0.01` = 1%).
12    pub risk_pct_per_trade: f64,
13    pub max_leverage: f64,
14    /// Absolute cap on one position's notional value, regardless of leverage headroom.
15    pub max_position_notional: Option<f64>,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct PositionSizeResult {
20    /// Position size in instrument units (e.g. shares/contracts), already tick-rounded.
21    pub size: f64,
22    /// The dollar risk the sized position represents (may be less than the requested risk amount
23    /// if a leverage/notional cap bound the size first).
24    pub risk_amount: f64,
25    pub capped_by_leverage: bool,
26    pub capped_by_notional: bool,
27}
28
29/// Sizes a position from account risk, entry/stop prices, and `tick_value` (P&L per tick per unit
30/// — e.g. dollars per point per share, or per-contract tick value for futures), rounding the
31/// result to `instrument`'s tick size via [`InstrumentMeta::round_to_tick`] is *not* applicable
32/// here (that rounds prices, not size) — sizing itself is left unrounded; round to a lot size
33/// externally if the instrument requires it.
34pub 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/// One scale-in step: `fraction` of the total planned size to add once price reaches
84/// `trigger_price`.
85#[derive(Debug, Clone, Copy, PartialEq)]
86pub struct ScaleInStep {
87    pub trigger_price: f64,
88    pub fraction: f64,
89}
90
91/// One scale-out step: `fraction` of the current position to exit once the trade reaches
92/// `trigger_r_multiple` (realized-favorable move, in multiples of the original risk).
93#[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    /// Scale-in steps whose `trigger_price` has been reached by `current_price`, in the
107    /// direction implied by comparing `current_price` to `entry_price` (`is_long` disambiguates
108    /// which side "reached" means).
109    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    /// Scale-out steps whose `trigger_r_multiple` has been reached by `current_r_multiple`.
123    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/// Break-even and time-stop rule configuration.
132#[derive(Debug, Clone, Copy, PartialEq, Default)]
133pub struct StopManager {
134    /// Move the stop to (at/near) entry once price reaches this R-multiple in favor.
135    pub breakeven_trigger_r: Option<f64>,
136    /// Force an exit after this many bars if the trade has not otherwise resolved.
137    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    /// Evaluates the current bar against the configured rules. `risk_per_unit` is the original
149    /// `|entry - stop|` distance (used as the R-multiple denominator); `bars_held` is elapsed
150    /// bars since entry.
151    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, // $1,000 risk budget
189            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        // entry=100, stop=98 -> 2.0 price risk = 8 ticks (0.25 each). tick_value=$10/tick.
206        // risk_per_unit = 8 * 10 = $80. size = 1000 / 80 = 12.5.
207        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); // 500 notional / 100 entry
233    }
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); // zero risk distance
238        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); // 1R reached (2.0 favorable / 2.0 risk)
281        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        // Even though breakeven would also trigger, time stop is evaluated first / takes
294        // priority once bars_held exceeds the limit.
295        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); // short: price fell 2.0 = 1R favorable
306        assert_eq!(decision, StopDecision::MoveToBreakeven(100.0));
307    }
308}