Skip to main content

limitless/models/
order.rs

1//! Order models matching the Limitless Exchange API `POST /orders` schema
2//! and the EIP-712 typed order used for on-chain signature verification.
3
4use serde::{Deserialize, Serialize};
5
6// ── Order side ──
7
8/// Buy or sell side for an order.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "UPPERCASE")]
11pub enum OrderSide {
12    Buy,
13    Sell,
14}
15
16impl OrderSide {
17    /// Convert to the `uint8` value used in EIP-712 and the API.
18    /// `0` = BUY, `1` = SELL.
19    pub fn to_u8(self) -> u8 {
20        match self {
21            OrderSide::Buy => 0,
22            OrderSide::Sell => 1,
23        }
24    }
25
26    /// Create from the uint8 value.
27    pub fn from_u8(v: u8) -> Option<Self> {
28        match v {
29            0 => Some(OrderSide::Buy),
30            1 => Some(OrderSide::Sell),
31            _ => None,
32        }
33    }
34}
35
36// ── Order type ──
37
38/// Execution strategy for an order.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "UPPERCASE")]
41pub enum OrderType {
42    /// Good-Till-Cancelled: rests on the orderbook until filled or cancelled.
43    Gtc,
44    /// Fill-Or-Kill: executes immediately at market or is cancelled entirely.
45    Fok,
46}
47
48// ── API-facing order (what you send to POST /orders) ──
49
50/// The signed order payload within a create-order request.
51///
52/// Matches the EIP-712 `Order` struct. Fields that represent on-chain
53/// `uint256` values (token_id, maker_amount, taker_amount) are serialized
54/// as decimal strings to match the reference API format and avoid JSON
55/// precision loss above 2^53.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct OrderData {
58    /// Unique order identifier (monotonic counter).
59    pub salt: i64,
60    /// Checksummed address of the order creator.
61    pub maker: String,
62    /// Same as maker for EOA wallets.
63    pub signer: String,
64    /// `0x000...000` for open orders (any taker can fill).
65    pub taker: String,
66    /// Position ID — YES or NO token from market data (decimal string).
67    #[serde(rename = "tokenId")]
68    pub token_id: String,
69    /// Amount the maker offers, scaled by 1e6.
70    #[serde(rename = "makerAmount")]
71    pub maker_amount: i64,
72    /// Amount the maker wants in return, scaled by 1e6.
73    #[serde(rename = "takerAmount")]
74    pub taker_amount: i64,
75    /// Expiration timestamp as decimal string. `"0"` = no expiration.
76    pub expiration: String,
77    /// Order nonce.
78    pub nonce: i32,
79    /// Fee rate in basis points.
80    #[serde(rename = "feeRateBps")]
81    pub fee_rate_bps: i32,
82    /// `0` = BUY, `1` = SELL.
83    pub side: u8,
84    /// The EIP-712 signature (0x-prefixed hex, 65 bytes for EOA).
85    pub signature: String,
86    /// `0` = EOA signature.
87    #[serde(rename = "signatureType")]
88    pub signature_type: u8,
89}
90
91/// The full request body for `POST /orders`.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct CreateOrderRequest {
94    /// The signed order data.
95    pub order: OrderData,
96    /// Your internal profile ID (from `GET /profiles/{address}`).
97    #[serde(rename = "ownerId")]
98    pub owner_id: u64,
99    /// `GTC` or `FOK`.
100    #[serde(rename = "orderType")]
101    pub order_type: OrderType,
102    /// Market slug identifier.
103    #[serde(rename = "marketSlug")]
104    pub market_slug: String,
105    /// Optional idempotency key (max 128 chars).
106    #[serde(skip_serializing_if = "Option::is_none", rename = "clientOrderId")]
107    pub client_order_id: Option<String>,
108    /// Optional profile ID to place order on behalf of (partner flow).
109    #[serde(skip_serializing_if = "Option::is_none", rename = "onBehalfOf")]
110    pub on_behalf_of: Option<u64>,
111}
112
113// ── Amount calculation constants ──
114
115/// USDC and shares are scaled by 1e6 on-chain.
116pub const SCALE: u128 = 1_000_000;
117
118/// Maximum basis points for fee rate (e.g., 250 = 2.5%).
119pub const MAX_BPS: i32 = 10_000;
120
121/// Default price tick (minimum price increment) for CLOB markets.
122pub const DEFAULT_PRICE_TICK: f64 = 0.001;
123
124/// Default fee rate in basis points.
125pub const DEFAULT_FEE_RATE_BPS: i32 = 300;
126
127// ── Precise scaling helper ──
128
129/// Convert a floating-point dollar amount to a 6-decimal fixed-point `u128`.
130///
131/// Uses string formatting to avoid floating-point precision loss,
132/// then truncates to exactly 6 decimal places (matching the reference
133/// SDK's `parse_dec_to_int` / `scale_to_6_decimals` behaviour).
134fn scale_to_6_decimals(amount: f64) -> u128 {
135    if amount <= 0.0 {
136        return 0;
137    }
138    // Format with enough precision to capture the value accurately,
139    // then truncate to 6 decimal places.
140    let formatted = format!("{amount:.12}");
141    let negative = formatted.starts_with('-');
142    let cleaned = if negative {
143        formatted.trim_start_matches('-')
144    } else {
145        formatted.as_str()
146    };
147    let parts: Vec<&str> = cleaned.split('.').collect();
148    let int_part: u128 = parts[0].parse().unwrap_or(0);
149    let frac_str = if parts.len() > 1 { parts[1] } else { "" };
150    // Truncate fractional part to 6 digits
151    let frac_6 = if frac_str.len() > 6 {
152        &frac_str[..6]
153    } else {
154        frac_str
155    };
156    // Pad with trailing zeros to ensure exactly 6 digits
157    let mut frac_padded = String::with_capacity(6);
158    frac_padded.push_str(frac_6);
159    while frac_padded.len() < 6 {
160        frac_padded.push('0');
161    }
162    let frac_val: u128 = frac_padded.parse().unwrap_or(0);
163    let result = int_part * SCALE + frac_val;
164    if negative {
165        0
166    } else {
167        result
168    }
169}
170
171/// Ceiling division for unsigned integers.
172///
173/// `ceil(a / b)` computed without floating-point.
174/// Panics if `b == 0`.
175fn div_ceil_u128(a: u128, b: u128) -> u128 {
176    assert!(b > 0, "division by zero");
177    (a + b - 1) / b
178}
179
180// ── Order amount calculations ──
181
182/// Calculate `maker_amount` and `taker_amount` for a **GTC limit order**.
183///
184/// Uses precise 6-decimal fixed-point arithmetic matching the reference SDK.
185/// BUY orders use ceiling division for collateral (the maker pays the
186/// rounded-up amount); SELL orders use truncating division.
187///
188/// * `side` — BUY or SELL
189/// * `price` — Price between 0 and 1 (e.g., 0.55)
190/// * `size` — Number of contracts (e.g., 10.0)
191///
192/// Returns `(maker_amount, taker_amount)` as raw `i64` values.
193///
194/// # Panics
195///
196/// Panics if the scaled result exceeds `i64::MAX`.
197///
198/// ```
199/// use limitless::models::order::*;
200///
201/// // BUY 10 shares at $0.55
202/// let (maker, taker) = gtc_amounts(OrderSide::Buy, 0.55, 10.0);
203/// assert_eq!(maker, 5_500_000);  // 0.55 * 10 * 1e6
204/// assert_eq!(taker, 10_000_000); // 10 * 1e6
205///
206/// // SELL 10 shares at $0.55
207/// let (maker, taker) = gtc_amounts(OrderSide::Sell, 0.55, 10.0);
208/// assert_eq!(maker, 10_000_000); // 10 * 1e6
209/// assert_eq!(taker, 5_500_000);  // 0.55 * 10 * 1e6
210/// ```
211pub fn gtc_amounts(side: OrderSide, price: f64, size: f64) -> (i64, i64) {
212    let shares_scaled = scale_to_6_decimals(size);
213    let price_scaled = scale_to_6_decimals(price);
214
215    // collateral = (shares * price_int) / scale
216    // = (size * 1e6 * price * 1e6) / 1e6
217    // = size * price * 1e6
218    let numerator = shares_scaled * price_scaled;
219    let collateral = match side {
220        OrderSide::Buy => div_ceil_u128(numerator, SCALE),
221        OrderSide::Sell => numerator / SCALE,
222    };
223
224    let (maker_amount, taker_amount) = match side {
225        OrderSide::Buy => (collateral, shares_scaled),
226        OrderSide::Sell => (shares_scaled, collateral),
227    };
228
229    let maker = i64::try_from(maker_amount).expect("maker_amount exceeds i64 range");
230    let taker = i64::try_from(taker_amount).expect("taker_amount exceeds i64 range");
231
232    (maker, taker)
233}
234
235/// Calculate `maker_amount` for a **FOK market order**.
236///
237/// FOK orders always set `taker_amount = 1`.
238///
239/// * BUY: `maker_amount` = raw USDC to spend scaled by 1e6
240/// * SELL: `maker_amount` = raw shares to sell scaled by 1e6
241pub fn fok_amount(_side: OrderSide, amount: f64) -> i64 {
242    let scaled = scale_to_6_decimals(amount);
243    i64::try_from(scaled).expect("FOK amount exceeds i64 range")
244}
245
246// ── Order validation ──
247
248/// Validate a GTC limit order's fields client-side.
249///
250/// Checks price range, size positivity, decimal-place limits,
251/// and price-tick alignment.
252pub fn validate_gtc_order(price: f64, size: f64, price_tick: Option<f64>) -> Result<(), String> {
253    let tick = price_tick.unwrap_or(DEFAULT_PRICE_TICK);
254
255    if !(0.0..=1.0).contains(&price) || price == 0.0 {
256        return Err(format!(
257            "price must be between 0 and 1 (exclusive of 0), got: {price}"
258        ));
259    }
260    if size <= 0.0 {
261        return Err(format!("size must be positive, got: {size}"));
262    }
263
264    // Check price decimal places against tick
265    let tick_str = float_to_decimal_string(tick);
266    let price_str = float_to_decimal_string(price);
267    let max_decimals = decimal_places(&tick_str);
268    if decimal_places(&price_str) > max_decimals {
269        return Err(format!(
270            "price {price} has too many decimal places — tick {tick} allows at most {max_decimals}"
271        ));
272    }
273
274    // Check price is a multiple of tick
275    let tick_scaled = scale_to_6_decimals(tick);
276    let price_scaled = scale_to_6_decimals(price);
277    if tick_scaled > 0 && (price_scaled % tick_scaled) != 0 {
278        return Err(format!(
279            "price {price} is not tick-aligned — must be a multiple of {tick}"
280        ));
281    }
282
283    // Check size has at most 6 decimal places
284    let size_str = float_to_decimal_string(size);
285    if decimal_places(&size_str) > 6 {
286        return Err(format!(
287            "size {size} has too many decimal places — maximum is 6"
288        ));
289    }
290
291    Ok(())
292}
293
294/// Validate a FOK market order's fields client-side.
295pub fn validate_fok_order(amount: f64) -> Result<(), String> {
296    if amount <= 0.0 {
297        return Err(format!("FOK amount must be positive, got: {amount}"));
298    }
299    let amount_str = float_to_decimal_string(amount);
300    if decimal_places(&amount_str) > 6 {
301        return Err(format!(
302            "FOK amount {amount} has too many decimal places — maximum is 6"
303        ));
304    }
305    Ok(())
306}
307
308/// Validate the high-level `OrderData` fields before signing.
309pub fn validate_order_data(order: &OrderData) -> Result<(), String> {
310    if order.token_id.is_empty() || order.token_id == "0" {
311        return Err("token_id is required and must be non-zero".to_string());
312    }
313    if order.maker_amount <= 0 {
314        return Err("maker_amount must be positive".to_string());
315    }
316    if order.taker_amount <= 0 {
317        return Err("taker_amount must be positive".to_string());
318    }
319    if order.salt <= 0 {
320        return Err(format!("salt must be positive, got: {}", order.salt));
321    }
322    if order.nonce < 0 {
323        return Err(format!("nonce must be non-negative, got: {}", order.nonce));
324    }
325    if order.fee_rate_bps < 0 || order.fee_rate_bps > MAX_BPS {
326        return Err(format!(
327            "fee_rate_bps must be in [0, {MAX_BPS}], got: {}",
328            order.fee_rate_bps
329        ));
330    }
331    if order.side > 1 {
332        return Err(format!(
333            "side must be 0 (BUY) or 1 (SELL), got: {}",
334            order.side
335        ));
336    }
337    if order.signature_type > 2 {
338        return Err(format!(
339            "signature_type must be 0-2, got: {}",
340            order.signature_type
341        ));
342    }
343    Ok(())
344}
345
346// ── Helpers ──
347
348/// Format an f64 to a decimal string with up to 12 decimal places,
349/// trimming trailing zeros (matching the reference `float_to_decimal_string`).
350fn float_to_decimal_string(value: f64) -> String {
351    let mut formatted = format!("{value:.12}");
352    // Trim trailing zeros after decimal point
353    while formatted.contains('.') && formatted.ends_with('0') {
354        formatted.pop();
355    }
356    if formatted.ends_with('.') {
357        formatted.pop();
358    }
359    if formatted == "-0" {
360        "0".to_string()
361    } else {
362        formatted
363    }
364}
365
366/// Count decimal places in a formatted decimal string.
367fn decimal_places(value: &str) -> usize {
368    value.split('.').nth(1).map(str::len).unwrap_or(0)
369}
370
371// ── Tests ──
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn gtc_buy_scales_correctly() {
379        let (maker, taker) = gtc_amounts(OrderSide::Buy, 0.55, 10.0);
380        assert_eq!(maker, 5_500_000);
381        assert_eq!(taker, 10_000_000);
382    }
383
384    #[test]
385    fn gtc_sell_scales_correctly() {
386        let (maker, taker) = gtc_amounts(OrderSide::Sell, 0.55, 10.0);
387        assert_eq!(maker, 10_000_000);
388        assert_eq!(taker, 5_500_000);
389    }
390
391    #[test]
392    fn gtc_buy_uses_ceil_division() {
393        // price=0.333333, size=1.0 → maker = ceil(1e6 * 333333 / 1e6) = ceil(333333) = 333333
394        let (maker, _taker) = gtc_amounts(OrderSide::Buy, 0.333333, 1.0);
395        assert_eq!(maker, 333_333);
396    }
397
398    #[test]
399    fn gtc_amounts_are_symmetric() {
400        // BUY and SELL should swap maker/taker
401        let (buy_maker, buy_taker) = gtc_amounts(OrderSide::Buy, 0.42, 5.0);
402        let (sell_maker, sell_taker) = gtc_amounts(OrderSide::Sell, 0.42, 5.0);
403        assert_eq!(buy_maker, sell_taker);
404        assert_eq!(buy_taker, sell_maker);
405    }
406
407    #[test]
408    fn fok_amount_scales_correctly() {
409        let scaled = fok_amount(OrderSide::Buy, 10.5);
410        assert_eq!(scaled, 10_500_000);
411    }
412
413    #[test]
414    fn scale_to_6_decimals_truncates() {
415        // 0.001001 * 1e6 = 1001
416        assert_eq!(scale_to_6_decimals(0.001001), 1001);
417        // 0.0010015 truncates to 0.001001 → 1001
418        assert_eq!(scale_to_6_decimals(0.0010015), 1001);
419    }
420
421    #[test]
422    fn scale_to_6_decimals_handles_large_integer() {
423        assert_eq!(scale_to_6_decimals(123.456789), 123_456_789);
424    }
425
426    #[test]
427    fn validate_gtc_rejects_zero_price() {
428        assert!(validate_gtc_order(0.0, 1.0, None).is_err());
429    }
430
431    #[test]
432    fn validate_gtc_rejects_price_above_one() {
433        assert!(validate_gtc_order(1.5, 1.0, None).is_err());
434    }
435
436    #[test]
437    fn validate_gtc_rejects_negative_size() {
438        assert!(validate_gtc_order(0.5, -1.0, None).is_err());
439    }
440
441    #[test]
442    fn validate_gtc_accepts_valid_order() {
443        assert!(validate_gtc_order(0.55, 10.0, None).is_ok());
444    }
445
446    #[test]
447    fn validate_fok_rejects_zero_amount() {
448        assert!(validate_fok_order(0.0).is_err());
449    }
450
451    #[test]
452    fn validate_fok_accepts_valid_amount() {
453        assert!(validate_fok_order(100.0).is_ok());
454    }
455}