Skip to main content

hypercall_engine/
admission.rs

1//! Pure order admission checks.
2
3use crate::instrument::ParsedInstrument;
4use crate::order_index::EngineOrderIndex;
5use crate::position::EnginePosition;
6use hypercall_types::{to_human_readable_decimal, OrderInfo, Side, WalletAddress};
7use rust_decimal::Decimal;
8use rust_decimal_macros::dec;
9use std::collections::{HashMap, HashSet};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum OrderAdmissionDecision {
13    Accepted,
14    Rejected,
15}
16
17#[derive(Debug, Clone)]
18pub struct OrderAdmissionInput<'a> {
19    pub wallet: &'a WalletAddress,
20    pub order: &'a OrderInfo,
21}
22
23pub struct OrderAdmissionState<'a> {
24    pub order_index: &'a EngineOrderIndex,
25    pub engine_positions: &'a HashMap<(WalletAddress, String), EnginePosition>,
26    pub engine_cash: &'a HashMap<WalletAddress, Decimal>,
27    pub expired_instruments: &'a HashSet<String>,
28    pub open_symbols: &'a HashSet<String>,
29    pub preliquidating_wallets: &'a HashSet<WalletAddress>,
30}
31
32#[derive(Debug, Clone, Copy)]
33pub struct TradingLimits {
34    pub max_open_orders: i32,
35    pub max_open_positions: i32,
36}
37
38pub fn validate_order_shape(order_info: &OrderInfo) -> Result<(), String> {
39    if order_info.price <= dec!(0) {
40        return Err("Price must be positive".to_string());
41    }
42    if order_info.size <= dec!(0) {
43        return Err("Size must be greater than zero".to_string());
44    }
45    if order_info.is_perp {
46        validate_perp_order(order_info)?;
47    } else {
48        ParsedInstrument::parse(&order_info.symbol)
49            .map_err(|e| format!("Invalid symbol: {}", e))?;
50    }
51    Ok(())
52}
53
54pub fn validate_perp_order(order_info: &OrderInfo) -> Result<(), String> {
55    if order_info
56        .underlying
57        .as_deref()
58        .filter(|u| !u.is_empty())
59        .is_none()
60    {
61        return Err("Perp order missing underlying symbol".to_string());
62    }
63    Ok(())
64}
65
66pub fn validate_instrument_open(
67    order_info: &OrderInfo,
68    expired_instruments: &HashSet<String>,
69    open_symbols: &HashSet<String>,
70) -> Result<(), String> {
71    if !order_info.is_perp && expired_instruments.contains(&order_info.symbol) {
72        return Err("Instrument has expired".to_string());
73    }
74    if !open_symbols.contains(&order_info.symbol) {
75        return Err(format!("Invalid symbol: {}", order_info.symbol));
76    }
77    Ok(())
78}
79
80pub fn validate_account_has_funds(
81    wallet: &WalletAddress,
82    engine_cash: &HashMap<WalletAddress, Decimal>,
83) -> Result<(), String> {
84    let cash = engine_cash
85        .get(wallet)
86        .copied()
87        .ok_or_else(|| format!("Missing cash balance for wallet {}", wallet))?;
88    if cash <= dec!(0) {
89        return Err("Account has no funds. Please deposit before trading.".to_string());
90    }
91    Ok(())
92}
93
94pub fn validate_order_limits(
95    wallet: &WalletAddress,
96    order_info: &OrderInfo,
97    order_index: &EngineOrderIndex,
98    engine_positions: &HashMap<(WalletAddress, String), EnginePosition>,
99    limits: TradingLimits,
100) -> Result<(), String> {
101    let open_order_count = order_index.open_order_count(wallet);
102    if limits.max_open_orders >= 0 && open_order_count >= limits.max_open_orders as usize {
103        return Err(format!(
104            "max_open_orders_exceeded: current={}, limit={}",
105            open_order_count, limits.max_open_orders
106        ));
107    }
108
109    let position_count = engine_positions.keys().filter(|(w, _)| w == wallet).count();
110    let has_position = engine_positions.contains_key(&(*wallet, order_info.symbol.clone()));
111    if limits.max_open_positions >= 0
112        && !has_position
113        && position_count >= limits.max_open_positions as usize
114    {
115        return Err(format!(
116            "max_positions_exceeded: wallet {} has {} positions, limit is {}",
117            wallet, position_count, limits.max_open_positions
118        ));
119    }
120
121    Ok(())
122}
123
124pub fn validate_preliquidation_order_allowed(
125    preliquidating: bool,
126    engine_positions: &HashMap<(WalletAddress, String), EnginePosition>,
127    wallet: &WalletAddress,
128    order_info: &OrderInfo,
129) -> Result<(), String> {
130    if !preliquidating {
131        return Ok(());
132    }
133
134    if is_reduce_only_order(engine_positions, wallet, order_info) {
135        Ok(())
136    } else {
137        Err(format!(
138            "Order blocked: account {} is in pre-liquidation state. Only reduce-only orders are allowed (close position or reduce size while keeping same direction).",
139            wallet
140        ))
141    }
142}
143
144pub fn is_reduce_only_order(
145    engine_positions: &HashMap<(WalletAddress, String), EnginePosition>,
146    wallet: &WalletAddress,
147    order_info: &OrderInfo,
148) -> bool {
149    let current_position = engine_positions
150        .get(&(*wallet, order_info.symbol.clone()))
151        .map(|p| p.quantity)
152        .unwrap_or(dec!(0));
153
154    let order_size = to_human_readable_decimal(&order_info.symbol, order_info.size);
155    let signed_order_size = if matches!(order_info.side, Side::Buy) {
156        order_size
157    } else {
158        -order_size
159    };
160    let new_position = current_position + signed_order_size;
161
162    is_position_reduce_only(current_position, new_position)
163}
164
165pub fn is_position_reduce_only(current: Decimal, new: Decimal) -> bool {
166    if new == dec!(0) {
167        return true;
168    }
169    if current == dec!(0) {
170        return false;
171    }
172    let same_sign = (current > dec!(0) && new > dec!(0)) || (current < dec!(0) && new < dec!(0));
173    same_sign && new.abs() < current.abs()
174}
175
176pub fn validate_tier_sell_restriction(
177    tier: &str,
178    wallet: &WalletAddress,
179    order_info: &OrderInfo,
180    filled_long_position: Decimal,
181    order_index: &EngineOrderIndex,
182) -> Result<(), String> {
183    if order_info.is_perp || tier == "tier2" || tier == "market_maker" {
184        return Ok(());
185    }
186    if matches!(order_info.side, Side::Buy) {
187        return Ok(());
188    }
189
190    ParsedInstrument::parse(&order_info.symbol)
191        .map_err(|e| format!("Failed to parse symbol: {}", e))?;
192    let total_open_sell_quantity =
193        order_index.get_open_sells_for_contract(wallet, &order_info.symbol);
194    let order_size_human = to_human_readable_decimal(&order_info.symbol, order_info.size);
195    let total_sell_with_new = total_open_sell_quantity + order_size_human;
196
197    if filled_long_position < total_sell_with_new {
198        Err(format!(
199            "Tier1 users cannot go short. Filled long position: {}, total sell orders (including new): {} (symbol: {})",
200            filled_long_position, total_sell_with_new, order_info.symbol
201        ))
202    } else {
203        Ok(())
204    }
205}
206
207pub fn classify_rejection_reason(reason: &str) -> &'static str {
208    let reason_lower = reason.to_lowercase();
209    if reason_lower.contains("margin") {
210        "margin"
211    } else if reason_lower.contains("expired") {
212        "expired"
213    } else if reason_lower.contains("no funds") {
214        "no_funds"
215    } else if reason_lower.contains("tier") {
216        "tier"
217    } else if reason_lower.contains("mmp") {
218        "mmp"
219    } else {
220        "other"
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::OrderSummary;
228    use rust_decimal_macros::dec;
229
230    fn wallet(byte: u8) -> WalletAddress {
231        WalletAddress::from(alloy::primitives::Address::repeat_byte(byte))
232    }
233
234    fn order(symbol: &str, side: Side) -> OrderInfo {
235        OrderInfo {
236            symbol: symbol.to_string(),
237            price: dec!(100),
238            size: hypercall_types::CONTRACT_UNIT_MULTIPLIER_DECIMAL,
239            side,
240            tif: hypercall_types::TimeInForce::GTC,
241            client_id: None,
242            order_id: None,
243            is_perp: false,
244            underlying: None,
245            reduce_only: None,
246            nonce: None,
247            signature: None,
248            mmp_enabled: false,
249            builder_code_address: None,
250        }
251    }
252
253    #[test]
254    fn validates_shape() {
255        assert!(validate_order_shape(&order("ETH-20260131-4000-C", Side::Buy)).is_ok());
256        let mut bad = order("ETH-PERP", Side::Buy);
257        bad.is_perp = false;
258        assert!(validate_order_shape(&bad).is_err());
259    }
260
261    #[test]
262    fn blocks_preliquidation_risk_increasing_order() {
263        let w = wallet(1);
264        let positions = HashMap::new();
265        let result = validate_preliquidation_order_allowed(
266            true,
267            &positions,
268            &w,
269            &order("ETH-20260131-4000-C", Side::Buy),
270        );
271        assert!(result.is_err());
272    }
273
274    #[test]
275    fn allows_preliquidation_reduce_only_order() {
276        let w = wallet(1);
277        let mut positions = HashMap::new();
278        positions.insert(
279            (w, "ETH-20260131-4000-C".to_string()),
280            EnginePosition {
281                quantity: dec!(-2),
282                entry_price: dec!(100),
283            },
284        );
285        let result = validate_preliquidation_order_allowed(
286            true,
287            &positions,
288            &w,
289            &order("ETH-20260131-4000-C", Side::Buy),
290        );
291        assert!(result.is_ok());
292    }
293
294    #[test]
295    fn validates_open_order_limit() {
296        let w = wallet(1);
297        let mut index = EngineOrderIndex::new();
298        index.add_order(
299            &w,
300            OrderSummary {
301                order_id: 1,
302                symbol: "ETH-20260131-4000-C".to_string(),
303                side: Side::Buy,
304                price: dec!(100),
305                original_size: hypercall_types::CONTRACT_UNIT_MULTIPLIER_DECIMAL,
306                remaining_size: hypercall_types::CONTRACT_UNIT_MULTIPLIER_DECIMAL,
307                is_perp: false,
308                mmp_enabled: false,
309                client_id: None,
310                created_at: 0,
311            },
312        );
313        let result = validate_order_limits(
314            &w,
315            &order("ETH-20260131-4000-C", Side::Buy),
316            &index,
317            &HashMap::new(),
318            TradingLimits {
319                max_open_orders: 1,
320                max_open_positions: -1,
321            },
322        );
323        assert!(result.unwrap_err().contains("max_open_orders_exceeded"));
324    }
325
326    #[test]
327    fn missing_cash_balance_fails_explicitly() {
328        let w = wallet(1);
329        let result = validate_account_has_funds(&w, &HashMap::new());
330        assert!(result.unwrap_err().contains("Missing cash balance"));
331    }
332
333    #[test]
334    fn missing_perp_orderbook_is_rejected() {
335        let mut perp = order("ETH-PERP", Side::Buy);
336        perp.is_perp = true;
337        perp.underlying = Some("ETH".to_string());
338
339        let result = validate_instrument_open(&perp, &HashSet::new(), &HashSet::new());
340        assert_eq!(result.unwrap_err(), "Invalid symbol: ETH-PERP");
341    }
342
343    #[test]
344    fn is_position_reduce_only_closing_to_zero() {
345        assert!(is_position_reduce_only(dec!(5), dec!(0)));
346        assert!(is_position_reduce_only(dec!(-5), dec!(0)));
347    }
348
349    #[test]
350    fn is_position_reduce_only_partial_close() {
351        assert!(is_position_reduce_only(dec!(10), dec!(3)));
352        assert!(is_position_reduce_only(dec!(-10), dec!(-3)));
353    }
354
355    #[test]
356    fn is_position_reduce_only_rejects_increase() {
357        assert!(!is_position_reduce_only(dec!(5), dec!(8)));
358        assert!(!is_position_reduce_only(dec!(-5), dec!(-8)));
359    }
360
361    #[test]
362    fn is_position_reduce_only_rejects_sign_flip() {
363        assert!(!is_position_reduce_only(dec!(5), dec!(-1)));
364        assert!(!is_position_reduce_only(dec!(-5), dec!(1)));
365    }
366
367    #[test]
368    fn is_position_reduce_only_from_zero_is_not_reduce() {
369        assert!(!is_position_reduce_only(dec!(0), dec!(5)));
370        assert!(!is_position_reduce_only(dec!(0), dec!(-5)));
371    }
372
373    #[test]
374    fn validate_perp_order_requires_underlying() {
375        let mut perp = order("ETH-PERP", Side::Buy);
376        perp.is_perp = true;
377        perp.underlying = None;
378        assert!(validate_perp_order(&perp).is_err());
379
380        perp.underlying = Some("ETH".to_string());
381        assert!(validate_perp_order(&perp).is_ok());
382    }
383
384    #[test]
385    fn zero_balance_blocks_trading() {
386        let w = wallet(1);
387        let mut cash = HashMap::new();
388        cash.insert(w, dec!(0));
389        let result = validate_account_has_funds(&w, &cash);
390        assert!(result.is_err(), "zero balance should block trading");
391    }
392
393    #[test]
394    fn positive_balance_allows_trading() {
395        let w = wallet(1);
396        let mut cash = HashMap::new();
397        cash.insert(w, dec!(1));
398        let result = validate_account_has_funds(&w, &cash);
399        assert!(result.is_ok());
400    }
401
402    #[test]
403    fn tier_sell_restriction_allows_buy() {
404        let w = wallet(1);
405        let index = EngineOrderIndex::new();
406        let result = validate_tier_sell_restriction(
407            "tier1",
408            &w,
409            &order("ETH-20260131-4000-C", Side::Buy),
410            dec!(0),
411            &index,
412        );
413        assert!(result.is_ok());
414    }
415
416    #[test]
417    fn tier_sell_restriction_allows_tier2() {
418        let w = wallet(1);
419        let index = EngineOrderIndex::new();
420        let result = validate_tier_sell_restriction(
421            "tier2",
422            &w,
423            &order("ETH-20260131-4000-C", Side::Sell),
424            dec!(0),
425            &index,
426        );
427        assert!(result.is_ok());
428    }
429
430    #[test]
431    fn tier_sell_restriction_blocks_naked_short() {
432        let w = wallet(1);
433        let index = EngineOrderIndex::new();
434        let result = validate_tier_sell_restriction(
435            "tier1",
436            &w,
437            &order("ETH-20260131-4000-C", Side::Sell),
438            dec!(0),
439            &index,
440        );
441        assert!(result.is_err());
442    }
443
444    #[test]
445    fn tier_sell_restriction_allows_covered_sell() {
446        let w = wallet(1);
447        let index = EngineOrderIndex::new();
448        let mut sell = order("ETH-20260131-4000-C", Side::Sell);
449        sell.size = dec!(500_000);
450        let result = validate_tier_sell_restriction("tier1", &w, &sell, dec!(1_000_000), &index);
451        assert!(result.is_ok());
452    }
453
454    #[test]
455    fn classify_rejection_reason_categories() {
456        assert_eq!(classify_rejection_reason("Insufficient margin"), "margin");
457        assert_eq!(
458            classify_rejection_reason("Instrument has expired"),
459            "expired"
460        );
461        assert_eq!(
462            classify_rejection_reason("Account has no funds"),
463            "no_funds"
464        );
465        assert_eq!(classify_rejection_reason("Tier1 restriction"), "tier");
466        assert_eq!(classify_rejection_reason("MMP triggered"), "mmp");
467        assert_eq!(classify_rejection_reason("Unknown error"), "other");
468    }
469
470    #[test]
471    fn validate_order_limits_position_count() {
472        let w = wallet(1);
473        let index = EngineOrderIndex::new();
474        let mut positions = HashMap::new();
475        positions.insert(
476            (w, "ETH-20260131-4000-C".to_string()),
477            EnginePosition {
478                quantity: dec!(1),
479                entry_price: dec!(100),
480            },
481        );
482
483        let result = validate_order_limits(
484            &w,
485            &order("SOL-20260131-200-C", Side::Buy),
486            &index,
487            &positions,
488            TradingLimits {
489                max_open_orders: -1,
490                max_open_positions: 1,
491            },
492        );
493        assert!(result.unwrap_err().contains("max_positions_exceeded"));
494    }
495}