1use 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}