1use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct SizingConfig {
23 pub margin_per_trade: f64,
25 pub leverage: u32,
27 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
42pub struct PositionSizer {
70 config: SizingConfig,
71}
72
73impl PositionSizer {
74 pub fn new(config: SizingConfig) -> Self {
76 Self { config }
77 }
78
79 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 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 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 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 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 let s = sizer(1.0, 1, 50);
161 assert_eq!(s.contracts(1_000_000.0, 0.001), 0);
162 }
163
164 proptest! {
171 #[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 #[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 #[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 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 #[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 #[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}