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::contract::{ContractSpec, FxConversionError, ValuationError};
6use crate::model::InstrumentMeta;
7
8/// Account-level risk configuration.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct AccountRisk {
11    pub equity: f64,
12    /// Fraction of `equity` risked per trade (e.g. `0.01` = 1%).
13    pub risk_pct_per_trade: f64,
14    pub max_leverage: f64,
15    /// Absolute cap on one position's notional value, regardless of leverage headroom.
16    pub max_position_notional: Option<f64>,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct PositionSizeResult {
21    /// Position size in instrument units (e.g. shares/contracts), already tick-rounded.
22    pub size: f64,
23    /// The dollar risk the sized position represents (may be less than the requested risk amount
24    /// if a leverage/notional cap bound the size first).
25    pub risk_amount: f64,
26    pub capped_by_leverage: bool,
27    pub capped_by_notional: bool,
28}
29
30/// Sizes a position from account risk, entry/stop prices, and `tick_value` (P&L per tick per unit
31/// — e.g. dollars per point per share, or per-contract tick value for futures), rounding the
32/// result to `instrument`'s tick size via [`InstrumentMeta::round_to_tick`] is *not* applicable
33/// here (that rounds prices, not size) — sizing itself is left unrounded; round to a lot size
34/// externally if the instrument requires it.
35pub 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
84/// Sizes a position using account risk, entry and stop prices, an explicit [`ContractSpec`],
85/// and an optional FX rate converting from `spec.price_currency` to account currency.
86///
87/// Features:
88/// - Explicit unit risk: `risk_per_unit = |entry - stop| * spec.multiplier * fx_to_account`.
89/// - Explicit notional: `unit_notional = entry * spec.multiplier * fx_to_account`.
90/// - Strict quantity rounding: rounded downward (`floor`) to multiples of `spec.quantity_step`.
91/// - Minimum quantity enforcement: if sized quantity < `spec.min_quantity`, size is 0.0 (no trade).
92/// - Consistent leverage & notional caps: bounded by account currency notional.
93/// - Explicit FX validation: returns [`ValuationError::FxUnavailable`] if FX is needed but missing or invalid.
94pub 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    // Step-round strictly downward and enforce minimum quantity
177    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/// One scale-in step: `fraction` of the total planned size to add once price reaches
189/// `trigger_price`.
190#[derive(Debug, Clone, Copy, PartialEq)]
191pub struct ScaleInStep {
192    pub trigger_price: f64,
193    pub fraction: f64,
194}
195
196/// One scale-out step: `fraction` of the current position to exit once the trade reaches
197/// `trigger_r_multiple` (realized-favorable move, in multiples of the original risk).
198#[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    /// Scale-in steps whose `trigger_price` has been reached by `current_price`, in the
212    /// direction implied by comparing `current_price` to `entry_price` (`is_long` disambiguates
213    /// which side "reached" means).
214    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    /// Scale-out steps whose `trigger_r_multiple` has been reached by `current_r_multiple`.
228    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/// Break-even and time-stop rule configuration.
237#[derive(Debug, Clone, Copy, PartialEq, Default)]
238pub struct StopManager {
239    /// Move the stop to (at/near) entry once price reaches this R-multiple in favor.
240    pub breakeven_trigger_r: Option<f64>,
241    /// Force an exit after this many bars if the trade has not otherwise resolved.
242    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    /// Evaluates the current bar against the configured rules. `risk_per_unit` is the original
254    /// `|entry - stop|` distance (used as the R-multiple denominator); `bars_held` is elapsed
255    /// bars since entry.
256    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, // $1,000 risk budget
294            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        // entry=100, stop=98 -> 2.0 price risk = 8 ticks (0.25 each). tick_value=$10/tick.
311        // risk_per_unit = 8 * 10 = $80. size = 1000 / 80 = 12.5.
312        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); // 500 notional / 100 entry
338    }
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); // zero risk distance
343        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); // 1R reached (2.0 favorable / 2.0 risk)
386        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        // Even though breakeven would also trigger, time stop is evaluated first / takes
399        // priority once bars_held exceeds the limit.
400        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); // short: price fell 2.0 = 1R favorable
411        assert_eq!(decision, StopDecision::MoveToBreakeven(100.0));
412    }
413}