Skip to main content

rustrade_risk/
sizing.rs

1//! Notional-based position sizing.
2//!
3//! Generalized from `kucoin/bot/sizing.rs`. Computes the integer number of
4//! contracts (or base-asset units) that corresponds to a desired margin
5//! commitment at the current price and leverage:
6//!
7//! ```text
8//! notional   = margin_usd × leverage
9//! contracts  = floor(notional / (price × contract_value))
10//! contracts  = min(contracts, max_contracts)
11//! ```
12//!
13//! The `contract_value` is exchange- and symbol-specific (0.001 BTC for
14//! XBTUSDTM, 0.01 ETH for ETHUSDTM, 1.0 SOL for SOLUSDTM). The framework
15//! gets it from the `ExchangeClient` adapter — see the v0 trait extension
16//! discussed in `kucoin-v2/DESIGN_NOTES.md`.
17
18use serde::{Deserialize, Serialize};
19
20/// Configuration for [`PositionSizer`].
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct SizingConfig {
23    /// Default margin to commit per trade in quote currency (e.g. USDT).
24    pub margin_per_trade: f64,
25    /// Leverage multiplier. Used to convert margin into notional.
26    pub leverage: u32,
27    /// Hard ceiling on contracts per trade — never exceeded regardless of
28    /// what the formula returns.
29    pub max_contracts: u32,
30}
31
32impl Default for SizingConfig {
33    fn default() -> Self {
34        Self {
35            margin_per_trade: 500.0,
36            leverage: 5,
37            max_contracts: 50,
38        }
39    }
40}
41
42/// Computes order sizes from margin + leverage + price + contract multiplier.
43///
44/// Returns `0` if any input is non-positive or the resulting size rounds
45/// down to zero. Callers should treat `0` as "skip this trade — too small".
46///
47/// # Example
48///
49/// ```
50/// use rustrade_risk::{PositionSizer, SizingConfig};
51///
52/// let sizer = PositionSizer::new(SizingConfig {
53///     margin_per_trade: 500.0,
54///     leverage: 5,
55///     max_contracts: 100,
56/// });
57///
58/// // 500 USDT * 5x leverage = 2500 USDT notional.
59/// // At price 50_000 (BTC) with contract_value 0.001 BTC:
60/// //   contracts = floor(2500 / (50000 * 0.001)) = floor(50.0) = 50
61/// assert_eq!(sizer.contracts(50_000.0, 0.001), 50);
62///
63/// // Capped at max_contracts when price is low enough to "afford" more.
64/// assert_eq!(sizer.contracts(10.0, 0.001), 100);
65///
66/// // Zero on degenerate inputs.
67/// assert_eq!(sizer.contracts(0.0, 0.001), 0);
68/// ```
69pub struct PositionSizer {
70    config: SizingConfig,
71}
72
73impl PositionSizer {
74    /// Construct a sizer with the given [`SizingConfig`].
75    pub fn new(config: SizingConfig) -> Self {
76        Self { config }
77    }
78
79    /// Compute contract count for a trade.
80    ///
81    /// `price` is the current mark or last-trade price in quote currency.
82    /// `contract_value` is the base-asset units per 1 contract (e.g. 0.001
83    /// for XBTUSDTM).
84    pub fn contracts(&self, price: f64, contract_value: f64) -> u32 {
85        if price <= 0.0
86            || contract_value <= 0.0
87            || self.config.margin_per_trade <= 0.0
88            || self.config.leverage == 0
89        {
90            return 0;
91        }
92
93        let notional = self.config.margin_per_trade * f64::from(self.config.leverage);
94        let raw = (notional / (price * contract_value)).floor() as u32;
95        raw.min(self.config.max_contracts)
96    }
97
98    /// Same as [`Self::contracts`] but takes an explicit override for the
99    /// per-trade margin (used by brains that want to scale up/down based
100    /// on confidence or by the framework when honouring `SizeHint::NotionalUsd`).
101    pub fn contracts_with_margin(&self, margin_usd: f64, price: f64, contract_value: f64) -> u32 {
102        if price <= 0.0 || contract_value <= 0.0 || margin_usd <= 0.0 || self.config.leverage == 0 {
103            return 0;
104        }
105        let notional = margin_usd * f64::from(self.config.leverage);
106        let raw = (notional / (price * contract_value)).floor() as u32;
107        raw.min(self.config.max_contracts)
108    }
109
110    /// Borrow the sizer's underlying config.
111    pub fn config(&self) -> &SizingConfig {
112        &self.config
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use proptest::prelude::*;
120
121    fn sizer(margin: f64, lev: u32, max: u32) -> PositionSizer {
122        PositionSizer::new(SizingConfig {
123            margin_per_trade: margin,
124            leverage: lev,
125            max_contracts: max,
126        })
127    }
128
129    #[test]
130    fn zero_price_returns_zero() {
131        let s = sizer(500.0, 5, 100);
132        assert_eq!(s.contracts(0.0, 0.001), 0);
133    }
134
135    #[test]
136    fn zero_leverage_returns_zero() {
137        let s = sizer(500.0, 0, 100);
138        assert_eq!(s.contracts(50_000.0, 0.001), 0);
139    }
140
141    #[test]
142    fn btc_known_value() {
143        // notional = 500 × 5 = 2500
144        // per-contract = 50000 × 0.001 = 50
145        // contracts = floor(2500 / 50) = 50
146        let s = sizer(500.0, 5, 100);
147        assert_eq!(s.contracts(50_000.0, 0.001), 50);
148    }
149
150    #[test]
151    fn cap_is_respected() {
152        // Massive margin × leverage at low price would otherwise blow past cap.
153        let s = sizer(500_000.0, 100, 10);
154        assert_eq!(s.contracts(1.0, 0.001), 10);
155    }
156
157    #[test]
158    fn rounds_to_zero_when_price_too_high() {
159        // notional = 1, per-contract = 1000 → floor(1/1000) = 0
160        let s = sizer(1.0, 1, 50);
161        assert_eq!(s.contracts(1_000_000.0, 0.001), 0);
162    }
163
164    // ── Property tests ─────────────────────────────────────────────────
165    //
166    // Bounds chosen to stay inside ranges that are realistic for crypto
167    // futures (prices up to ~1M, leverage up to 200x, margins up to ~1M)
168    // *and* to keep the unsaturated formula well inside `u32::MAX`.
169
170    proptest! {
171        /// Sizing must never exceed the configured cap.
172        #[test]
173        fn contracts_never_exceeds_cap(
174            margin in 0.0_f64..1_000_000.0,
175            leverage in 0_u32..200,
176            max in 0_u32..10_000,
177            price in 0.0_f64..1_000_000.0,
178            contract_value in 0.0_f64..100.0,
179        ) {
180            let s = sizer(margin, leverage, max);
181            prop_assert!(s.contracts(price, contract_value) <= max);
182        }
183
184        /// Any degenerate non-positive input forces a zero return.
185        #[test]
186        fn degenerate_inputs_return_zero(
187            margin in proptest::sample::select(vec![0.0, -1.0, -1_000.0]),
188            leverage in 0_u32..50,
189            price in 1.0_f64..100_000.0,
190            contract_value in 0.001_f64..1.0,
191        ) {
192            let s = sizer(margin, leverage, 1_000);
193            prop_assert_eq!(s.contracts(price, contract_value), 0);
194        }
195
196        #[test]
197        fn zero_or_negative_price_returns_zero(
198            price in proptest::sample::select(vec![0.0, -1.0, -50_000.0]),
199            margin in 1.0_f64..10_000.0,
200            leverage in 1_u32..50,
201            contract_value in 0.001_f64..1.0,
202        ) {
203            let s = sizer(margin, leverage, 1_000);
204            prop_assert_eq!(s.contracts(price, contract_value), 0);
205        }
206
207        #[test]
208        fn zero_or_negative_contract_value_returns_zero(
209            cv in proptest::sample::select(vec![0.0, -0.001, -1.0]),
210            margin in 1.0_f64..10_000.0,
211            leverage in 1_u32..50,
212            price in 1.0_f64..100_000.0,
213        ) {
214            let s = sizer(margin, leverage, 1_000);
215            prop_assert_eq!(s.contracts(price, cv), 0);
216        }
217
218        /// Doubling margin can only ever produce >= the original count
219        /// (subject to the cap). I.e. the sizer is monotone in margin.
220        #[test]
221        fn monotone_in_margin(
222            margin in 1.0_f64..10_000.0,
223            leverage in 1_u32..50,
224            price in 10.0_f64..50_000.0,
225            contract_value in 0.001_f64..1.0,
226        ) {
227            // High cap so the cap itself doesn't mask the property.
228            let s_low  = sizer(margin,        leverage, u32::MAX);
229            let s_high = sizer(margin * 2.0,  leverage, u32::MAX);
230            let c_low  = s_low.contracts(price, contract_value);
231            let c_high = s_high.contracts(price, contract_value);
232            prop_assert!(
233                c_high >= c_low,
234                "expected monotone in margin: low={c_low} high={c_high}"
235            );
236        }
237
238        /// Same property in leverage.
239        #[test]
240        fn monotone_in_leverage(
241            margin in 1.0_f64..10_000.0,
242            leverage in 1_u32..50,
243            price in 10.0_f64..50_000.0,
244            contract_value in 0.001_f64..1.0,
245        ) {
246            let s_low  = sizer(margin, leverage,     u32::MAX);
247            let s_high = sizer(margin, leverage * 2, u32::MAX);
248            let c_low  = s_low.contracts(price, contract_value);
249            let c_high = s_high.contracts(price, contract_value);
250            prop_assert!(
251                c_high >= c_low,
252                "expected monotone in leverage: low={c_low} high={c_high}"
253            );
254        }
255
256        /// The formula matches: contracts = floor(margin·leverage / (price·cv))
257        /// capped by max_contracts. Verify against an independently computed
258        /// reference for inputs that stay inside f64 → u32 safety.
259        #[test]
260        fn matches_reference_formula(
261            margin in 1.0_f64..100_000.0,
262            leverage in 1_u32..100,
263            max in 1_u32..1_000_000,
264            price in 1.0_f64..50_000.0,
265            contract_value in 0.001_f64..10.0,
266        ) {
267            let s = sizer(margin, leverage, max);
268            let got = s.contracts(price, contract_value);
269
270            let notional = margin * f64::from(leverage);
271            let per_contract = price * contract_value;
272            let raw = (notional / per_contract).floor();
273            let expected = if raw < 0.0 || !raw.is_finite() {
274                0
275            } else {
276                (raw as u32).min(max)
277            };
278            prop_assert_eq!(got, expected);
279        }
280    }
281}