kestrel-chartkit 0.11.3

High-performance Rust technical analysis library for indicator math, market regime classification, composite scoring, and SVG visualization.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Provider-neutral risk and position-sizing: account-risk-based sizing on top of
//! [`InstrumentMeta`] (tick size, not a broker-specific contract spec), leverage/position limits,
//! scale-in/out plans, and break-even/time-stop rules.

use crate::contract::{ContractSpec, FxConversionError, ValuationError};
use crate::model::InstrumentMeta;

/// Account-level risk configuration.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AccountRisk {
    pub equity: f64,
    /// Fraction of `equity` risked per trade (e.g. `0.01` = 1%).
    pub risk_pct_per_trade: f64,
    pub max_leverage: f64,
    /// Absolute cap on one position's notional value, regardless of leverage headroom.
    pub max_position_notional: Option<f64>,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PositionSizeResult {
    /// Position size in instrument units (e.g. shares/contracts), already tick-rounded.
    pub size: f64,
    /// The dollar risk the sized position represents (may be less than the requested risk amount
    /// if a leverage/notional cap bound the size first).
    pub risk_amount: f64,
    pub capped_by_leverage: bool,
    pub capped_by_notional: bool,
}

/// Sizes a position from account risk, entry/stop prices, and `tick_value` (P&L per tick per unit
/// — e.g. dollars per point per share, or per-contract tick value for futures), rounding the
/// result to `instrument`'s tick size via [`InstrumentMeta::round_to_tick`] is *not* applicable
/// here (that rounds prices, not size) — sizing itself is left unrounded; round to a lot size
/// externally if the instrument requires it.
pub fn position_size(
    account: &AccountRisk,
    instrument: &InstrumentMeta,
    entry: f64,
    stop: f64,
    tick_value: f64,
) -> PositionSizeResult {
    let risk_budget = account.equity * account.risk_pct_per_trade;
    let price_risk = (entry - stop).abs();
    if price_risk <= 0.0 || instrument.tick_size <= 0.0 || tick_value <= 0.0 {
        return PositionSizeResult {
            size: 0.0,
            risk_amount: 0.0,
            capped_by_leverage: false,
            capped_by_notional: false,
        };
    }

    let ticks_at_risk = price_risk / instrument.tick_size;
    let risk_per_unit = ticks_at_risk * tick_value;
    let mut size = risk_budget / risk_per_unit;

    let leverage_cap = (account.equity * account.max_leverage) / entry.max(1e-9);
    let mut capped_by_leverage = false;
    if size > leverage_cap {
        size = leverage_cap;
        capped_by_leverage = true;
    }

    let mut capped_by_notional = false;
    if let Some(max_notional) = account.max_position_notional {
        let notional_cap = max_notional / entry.max(1e-9);
        if size > notional_cap {
            size = notional_cap;
            capped_by_notional = true;
        }
    }

    size = size.max(0.0);
    let risk_amount = size * risk_per_unit;

    PositionSizeResult {
        size,
        risk_amount,
        capped_by_leverage,
        capped_by_notional,
    }
}

/// Sizes a position using account risk, entry and stop prices, an explicit [`ContractSpec`],
/// and an optional FX rate converting from `spec.price_currency` to account currency.
///
/// Features:
/// - Explicit unit risk: `risk_per_unit = |entry - stop| * spec.multiplier * fx_to_account`.
/// - Explicit notional: `unit_notional = entry * spec.multiplier * fx_to_account`.
/// - Strict quantity rounding: rounded downward (`floor`) to multiples of `spec.quantity_step`.
/// - Minimum quantity enforcement: if sized quantity < `spec.min_quantity`, size is 0.0 (no trade).
/// - Consistent leverage & notional caps: bounded by account currency notional.
/// - Explicit FX validation: returns [`ValuationError::FxUnavailable`] if FX is needed but missing or invalid.
pub fn position_size_contract(
    account: &AccountRisk,
    spec: &ContractSpec,
    entry: f64,
    stop: f64,
    fx_to_account: Option<f64>,
) -> Result<PositionSizeResult, ValuationError> {
    spec.validate()?;
    if !entry.is_finite() || entry <= 0.0 {
        return Err(ValuationError::NonPositivePrice(entry));
    }
    if !stop.is_finite() || stop <= 0.0 {
        return Err(ValuationError::NonPositivePrice(stop));
    }
    if !account.equity.is_finite() || account.equity <= 0.0 {
        return Err(ValuationError::NonFiniteInput("account.equity"));
    }

    let fx = match fx_to_account {
        Some(rate) if rate.is_finite() && rate > 0.0 => rate,
        Some(invalid) => {
            return Err(ValuationError::FxUnavailable(
                FxConversionError::InvalidRate(invalid),
            ))
        }
        None => {
            return Err(ValuationError::FxUnavailable(
                FxConversionError::MissingPair {
                    from: spec.price_currency.clone(),
                    to: spec.settlement_currency.clone(),
                },
            ))
        }
    };

    let price_risk = (entry - stop).abs();
    if price_risk <= 0.0 {
        return Ok(PositionSizeResult {
            size: 0.0,
            risk_amount: 0.0,
            capped_by_leverage: false,
            capped_by_notional: false,
        });
    }

    let risk_budget = account.equity * account.risk_pct_per_trade;
    let risk_per_unit = price_risk * spec.multiplier * fx;
    let unit_notional = entry * spec.multiplier * fx;

    if risk_per_unit <= 0.0 || unit_notional <= 0.0 {
        return Ok(PositionSizeResult {
            size: 0.0,
            risk_amount: 0.0,
            capped_by_leverage: false,
            capped_by_notional: false,
        });
    }

    let raw_risk_size = risk_budget / risk_per_unit;
    let mut size = raw_risk_size;

    let mut capped_by_leverage = false;
    if account.max_leverage.is_finite() && account.max_leverage > 0.0 {
        let max_account_notional = account.equity * account.max_leverage;
        let leverage_cap = max_account_notional / unit_notional;
        if size > leverage_cap {
            size = leverage_cap;
            capped_by_leverage = true;
        }
    }

    let mut capped_by_notional = false;
    if let Some(max_notional) = account.max_position_notional {
        if max_notional.is_finite() && max_notional > 0.0 {
            let notional_cap = max_notional / unit_notional;
            if size > notional_cap {
                size = notional_cap;
                capped_by_notional = true;
            }
        }
    }

    // Step-round strictly downward and enforce minimum quantity
    let rounded_size = spec.round_quantity_down(size);
    let actual_risk = rounded_size * risk_per_unit;

    Ok(PositionSizeResult {
        size: rounded_size,
        risk_amount: actual_risk,
        capped_by_leverage,
        capped_by_notional,
    })
}

/// One scale-in step: `fraction` of the total planned size to add once price reaches
/// `trigger_price`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ScaleInStep {
    pub trigger_price: f64,
    pub fraction: f64,
}

/// One scale-out step: `fraction` of the current position to exit once the trade reaches
/// `trigger_r_multiple` (realized-favorable move, in multiples of the original risk).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ScaleOutStep {
    pub trigger_r_multiple: f64,
    pub fraction: f64,
}

#[derive(Debug, Clone, PartialEq, Default)]
pub struct ScalePlan {
    pub entries: Vec<ScaleInStep>,
    pub exits: Vec<ScaleOutStep>,
}

impl ScalePlan {
    /// Scale-in steps whose `trigger_price` has been reached by `current_price`, in the
    /// direction implied by comparing `current_price` to `entry_price` (`is_long` disambiguates
    /// which side "reached" means).
    pub fn triggered_entries(&self, current_price: f64, is_long: bool) -> Vec<&ScaleInStep> {
        self.entries
            .iter()
            .filter(|step| {
                if is_long {
                    current_price >= step.trigger_price
                } else {
                    current_price <= step.trigger_price
                }
            })
            .collect()
    }

    /// Scale-out steps whose `trigger_r_multiple` has been reached by `current_r_multiple`.
    pub fn triggered_exits(&self, current_r_multiple: f64) -> Vec<&ScaleOutStep> {
        self.exits
            .iter()
            .filter(|step| current_r_multiple >= step.trigger_r_multiple)
            .collect()
    }
}

/// Break-even and time-stop rule configuration.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct StopManager {
    /// Move the stop to (at/near) entry once price reaches this R-multiple in favor.
    pub breakeven_trigger_r: Option<f64>,
    /// Force an exit after this many bars if the trade has not otherwise resolved.
    pub time_stop_bars: Option<u32>,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StopDecision {
    Hold,
    MoveToBreakeven(f64),
    TimeStopExit,
}

impl StopManager {
    /// Evaluates the current bar against the configured rules. `risk_per_unit` is the original
    /// `|entry - stop|` distance (used as the R-multiple denominator); `bars_held` is elapsed
    /// bars since entry.
    pub fn evaluate(
        &self,
        entry: f64,
        current_price: f64,
        risk_per_unit: f64,
        bars_held: u32,
        is_long: bool,
    ) -> StopDecision {
        if let Some(max_bars) = self.time_stop_bars {
            if bars_held >= max_bars {
                return StopDecision::TimeStopExit;
            }
        }

        if let (Some(trigger_r), true) = (self.breakeven_trigger_r, risk_per_unit > 0.0) {
            let favorable = if is_long {
                current_price - entry
            } else {
                entry - current_price
            };
            let current_r = favorable / risk_per_unit;
            if current_r >= trigger_r {
                return StopDecision::MoveToBreakeven(entry);
            }
        }

        StopDecision::Hold
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn account() -> AccountRisk {
        AccountRisk {
            equity: 100_000.0,
            risk_pct_per_trade: 0.01, // $1,000 risk budget
            max_leverage: 100.0,
            max_position_notional: None,
        }
    }

    fn instrument() -> InstrumentMeta {
        InstrumentMeta {
            symbol: "TEST".to_string(),
            tick_size: 0.25,
            price_precision: 2,
            timezone: "UTC".to_string(),
        }
    }

    #[test]
    fn test_position_size_from_account_risk() {
        // entry=100, stop=98 -> 2.0 price risk = 8 ticks (0.25 each). tick_value=$10/tick.
        // risk_per_unit = 8 * 10 = $80. size = 1000 / 80 = 12.5.
        let result = position_size(&account(), &instrument(), 100.0, 98.0, 10.0);
        assert!((result.size - 12.5).abs() < 1e-9);
        assert!((result.risk_amount - 1000.0).abs() < 1e-6);
        assert!(!result.capped_by_leverage);
    }

    #[test]
    fn test_position_size_capped_by_leverage() {
        let tight_account = AccountRisk {
            max_leverage: 0.001,
            ..account()
        };
        let result = position_size(&tight_account, &instrument(), 100.0, 98.0, 10.0);
        assert!(result.capped_by_leverage);
        assert!(result.size < 12.5);
    }

    #[test]
    fn test_position_size_capped_by_notional() {
        let capped_account = AccountRisk {
            max_position_notional: Some(500.0),
            ..account()
        };
        let result = position_size(&capped_account, &instrument(), 100.0, 98.0, 10.0);
        assert!(result.capped_by_notional);
        assert!((result.size - 5.0).abs() < 1e-9); // 500 notional / 100 entry
    }

    #[test]
    fn test_position_size_degenerate_inputs_return_zero() {
        let result = position_size(&account(), &instrument(), 100.0, 100.0, 10.0); // zero risk distance
        assert_eq!(result.size, 0.0);
    }

    #[test]
    fn test_scale_plan_triggers() {
        let plan = ScalePlan {
            entries: vec![
                ScaleInStep {
                    trigger_price: 101.0,
                    fraction: 0.5,
                },
                ScaleInStep {
                    trigger_price: 103.0,
                    fraction: 0.5,
                },
            ],
            exits: vec![
                ScaleOutStep {
                    trigger_r_multiple: 1.0,
                    fraction: 0.5,
                },
                ScaleOutStep {
                    trigger_r_multiple: 2.0,
                    fraction: 0.5,
                },
            ],
        };

        let triggered = plan.triggered_entries(102.0, true);
        assert_eq!(triggered.len(), 1);
        assert_eq!(triggered[0].trigger_price, 101.0);

        let triggered_exits = plan.triggered_exits(1.5);
        assert_eq!(triggered_exits.len(), 1);
    }

    #[test]
    fn test_stop_manager_breakeven_trigger() {
        let manager = StopManager {
            breakeven_trigger_r: Some(1.0),
            time_stop_bars: None,
        };
        let decision = manager.evaluate(100.0, 102.0, 2.0, 5, true); // 1R reached (2.0 favorable / 2.0 risk)
        assert_eq!(decision, StopDecision::MoveToBreakeven(100.0));

        let no_trigger = manager.evaluate(100.0, 100.5, 2.0, 5, true);
        assert_eq!(no_trigger, StopDecision::Hold);
    }

    #[test]
    fn test_stop_manager_time_stop_takes_priority() {
        let manager = StopManager {
            breakeven_trigger_r: Some(1.0),
            time_stop_bars: Some(3),
        };
        // Even though breakeven would also trigger, time stop is evaluated first / takes
        // priority once bars_held exceeds the limit.
        let decision = manager.evaluate(100.0, 105.0, 2.0, 10, true);
        assert_eq!(decision, StopDecision::TimeStopExit);
    }

    #[test]
    fn test_stop_manager_short_side_direction() {
        let manager = StopManager {
            breakeven_trigger_r: Some(1.0),
            time_stop_bars: None,
        };
        let decision = manager.evaluate(100.0, 98.0, 2.0, 5, false); // short: price fell 2.0 = 1R favorable
        assert_eq!(decision, StopDecision::MoveToBreakeven(100.0));
    }
}