1use crate::model::order::OrderSide;
16use pretty_simple_display::{DebugPretty, DisplaySimple};
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
23#[serde(rename_all = "lowercase")]
24pub enum ComboState {
25 Rfq,
27 #[default]
29 Active,
30 Inactive,
32}
33
34impl ComboState {
35 #[must_use]
37 pub fn as_str(&self) -> &'static str {
38 match self {
39 Self::Rfq => "rfq",
40 Self::Active => "active",
41 Self::Inactive => "inactive",
42 }
43 }
44
45 #[must_use]
47 pub fn is_tradeable(&self) -> bool {
48 matches!(self, Self::Active)
49 }
50}
51
52impl std::fmt::Display for ComboState {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 write!(f, "{}", self.as_str())
55 }
56}
57
58#[derive(DebugPretty, DisplaySimple, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ComboLeg {
65 pub instrument_name: String,
67 pub amount: i32,
69}
70
71impl ComboLeg {
72 #[must_use]
74 pub fn new(instrument_name: String, amount: i32) -> Self {
75 Self {
76 instrument_name,
77 amount,
78 }
79 }
80
81 #[must_use]
83 pub fn is_same_direction(&self) -> bool {
84 self.amount > 0
85 }
86
87 #[must_use]
89 pub fn is_opposite_direction(&self) -> bool {
90 self.amount < 0
91 }
92
93 #[must_use]
95 pub fn abs_amount(&self) -> i32 {
96 self.amount.abs()
97 }
98}
99
100#[derive(DebugPretty, DisplaySimple, Clone, PartialEq, Serialize, Deserialize)]
105pub struct ComboTradeLeg {
106 pub instrument_name: String,
108 pub amount: String,
110 pub direction: OrderSide,
112}
113
114impl ComboTradeLeg {
115 #[must_use]
117 pub fn new(instrument_name: String, amount: String, direction: OrderSide) -> Self {
118 Self {
119 instrument_name,
120 amount,
121 direction,
122 }
123 }
124
125 #[must_use]
127 pub fn from_amount(instrument_name: String, amount: i32, direction: OrderSide) -> Self {
128 Self {
129 instrument_name,
130 amount: amount.to_string(),
131 direction,
132 }
133 }
134}
135
136#[derive(DebugPretty, DisplaySimple, Clone, PartialEq, Serialize, Deserialize)]
141pub struct CreateComboRequest {
142 pub trades: Vec<ComboTradeLeg>,
144}
145
146impl CreateComboRequest {
147 #[must_use]
149 pub fn new(trades: Vec<ComboTradeLeg>) -> Self {
150 Self { trades }
151 }
152
153 #[must_use]
155 pub fn leg_count(&self) -> usize {
156 self.trades.len()
157 }
158
159 #[must_use]
161 pub fn is_valid(&self) -> bool {
162 self.trades.len() >= 2
163 }
164}
165
166#[derive(DebugPretty, DisplaySimple, Clone, PartialEq, Serialize, Deserialize)]
171pub struct ComboDetails {
172 pub id: String,
174 pub instrument_id: i64,
176 pub state: ComboState,
178 pub state_timestamp: i64,
180 pub creation_timestamp: i64,
182 pub legs: Vec<ComboLeg>,
184}
185
186impl ComboDetails {
187 #[must_use]
189 pub fn leg_count(&self) -> usize {
190 self.legs.len()
191 }
192
193 #[must_use]
195 pub fn is_tradeable(&self) -> bool {
196 self.state.is_tradeable()
197 }
198
199 #[must_use]
201 pub fn instruments(&self) -> Vec<&str> {
202 self.legs
203 .iter()
204 .map(|l| l.instrument_name.as_str())
205 .collect()
206 }
207
208 #[must_use]
210 pub fn is_futures_spread(&self) -> bool {
211 self.id.contains("-FS-")
212 }
213
214 #[must_use]
216 pub fn is_call_spread(&self) -> bool {
217 self.id.contains("-CS-")
218 }
219
220 #[must_use]
222 pub fn is_put_spread(&self) -> bool {
223 self.id.contains("-PS-")
224 }
225
226 #[must_use]
228 pub fn is_reversal(&self) -> bool {
229 self.id.contains("-REV-")
230 }
231}
232
233#[derive(DebugPretty, DisplaySimple, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct ComboIds {
238 pub ids: Vec<String>,
240}
241
242impl ComboIds {
243 #[must_use]
245 pub fn new(ids: Vec<String>) -> Self {
246 Self { ids }
247 }
248
249 #[must_use]
251 pub fn len(&self) -> usize {
252 self.ids.len()
253 }
254
255 #[must_use]
257 pub fn is_empty(&self) -> bool {
258 self.ids.is_empty()
259 }
260
261 #[must_use]
263 pub fn contains(&self, combo_id: &str) -> bool {
264 self.ids.iter().any(|id| id == combo_id)
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn test_combo_state_default() {
274 let state = ComboState::default();
275 assert_eq!(state, ComboState::Active);
276 }
277
278 #[test]
279 fn test_combo_state_as_str() {
280 assert_eq!(ComboState::Rfq.as_str(), "rfq");
281 assert_eq!(ComboState::Active.as_str(), "active");
282 assert_eq!(ComboState::Inactive.as_str(), "inactive");
283 }
284
285 #[test]
286 fn test_combo_state_is_tradeable() {
287 assert!(!ComboState::Rfq.is_tradeable());
288 assert!(ComboState::Active.is_tradeable());
289 assert!(!ComboState::Inactive.is_tradeable());
290 }
291
292 #[test]
293 fn test_combo_state_display() {
294 assert_eq!(format!("{}", ComboState::Rfq), "rfq");
295 assert_eq!(format!("{}", ComboState::Active), "active");
296 assert_eq!(format!("{}", ComboState::Inactive), "inactive");
297 }
298
299 #[test]
300 fn test_combo_state_serialization() {
301 let state = ComboState::Active;
302 let json = serde_json::to_string(&state).unwrap();
303 assert_eq!(json, "\"active\"");
304
305 let deserialized: ComboState = serde_json::from_str(&json).unwrap();
306 assert_eq!(deserialized, ComboState::Active);
307 }
308
309 #[test]
310 fn test_combo_leg_new() {
311 let leg = ComboLeg::new("BTC-PERPETUAL".to_string(), -1);
312 assert_eq!(leg.instrument_name, "BTC-PERPETUAL");
313 assert_eq!(leg.amount, -1);
314 }
315
316 #[test]
317 fn test_combo_leg_direction() {
318 let positive_leg = ComboLeg::new("BTC-29APR22".to_string(), 1);
319 assert!(positive_leg.is_same_direction());
320 assert!(!positive_leg.is_opposite_direction());
321 assert_eq!(positive_leg.abs_amount(), 1);
322
323 let negative_leg = ComboLeg::new("BTC-PERPETUAL".to_string(), -1);
324 assert!(!negative_leg.is_same_direction());
325 assert!(negative_leg.is_opposite_direction());
326 assert_eq!(negative_leg.abs_amount(), 1);
327 }
328
329 #[test]
330 fn test_combo_leg_serialization() {
331 let leg = ComboLeg::new("BTC-PERPETUAL".to_string(), -1);
332 let json = serde_json::to_string(&leg).unwrap();
333 let deserialized: ComboLeg = serde_json::from_str(&json).unwrap();
334 assert_eq!(leg, deserialized);
335 }
336
337 #[test]
338 fn test_combo_trade_leg_new() {
339 let leg = ComboTradeLeg::new(
340 "BTC-29APR22-37500-C".to_string(),
341 "1".to_string(),
342 OrderSide::Buy,
343 );
344 assert_eq!(leg.instrument_name, "BTC-29APR22-37500-C");
345 assert_eq!(leg.amount, "1");
346 assert_eq!(leg.direction, OrderSide::Buy);
347 }
348
349 #[test]
350 fn test_combo_trade_leg_from_amount() {
351 let leg = ComboTradeLeg::from_amount("BTC-29APR22-37500-C".to_string(), 1, OrderSide::Buy);
352 assert_eq!(leg.amount, "1");
353 }
354
355 #[test]
356 fn test_combo_trade_leg_serialization() {
357 let leg = ComboTradeLeg::new(
358 "BTC-29APR22-37500-C".to_string(),
359 "1".to_string(),
360 OrderSide::Buy,
361 );
362 let json = serde_json::to_string(&leg).unwrap();
363 let deserialized: ComboTradeLeg = serde_json::from_str(&json).unwrap();
364 assert_eq!(leg, deserialized);
365 }
366
367 #[test]
368 fn test_create_combo_request_new() {
369 let trades = vec![
370 ComboTradeLeg::new(
371 "BTC-29APR22-37500-C".to_string(),
372 "1".to_string(),
373 OrderSide::Buy,
374 ),
375 ComboTradeLeg::new(
376 "BTC-29APR22-37500-P".to_string(),
377 "1".to_string(),
378 OrderSide::Sell,
379 ),
380 ];
381 let request = CreateComboRequest::new(trades);
382 assert_eq!(request.leg_count(), 2);
383 assert!(request.is_valid());
384 }
385
386 #[test]
387 fn test_create_combo_request_invalid() {
388 let request = CreateComboRequest::new(vec![ComboTradeLeg::new(
389 "BTC-29APR22-37500-C".to_string(),
390 "1".to_string(),
391 OrderSide::Buy,
392 )]);
393 assert!(!request.is_valid());
394 }
395
396 fn create_test_combo_details() -> ComboDetails {
397 ComboDetails {
398 id: "BTC-FS-29APR22_PERP".to_string(),
399 instrument_id: 27,
400 state: ComboState::Active,
401 state_timestamp: 1650620605150,
402 creation_timestamp: 1650620575000,
403 legs: vec![
404 ComboLeg::new("BTC-PERPETUAL".to_string(), -1),
405 ComboLeg::new("BTC-29APR22".to_string(), 1),
406 ],
407 }
408 }
409
410 #[test]
411 fn test_combo_details_leg_count() {
412 let combo = create_test_combo_details();
413 assert_eq!(combo.leg_count(), 2);
414 }
415
416 #[test]
417 fn test_combo_details_is_tradeable() {
418 let active_combo = create_test_combo_details();
419 assert!(active_combo.is_tradeable());
420
421 let mut inactive_combo = create_test_combo_details();
422 inactive_combo.state = ComboState::Inactive;
423 assert!(!inactive_combo.is_tradeable());
424 }
425
426 #[test]
427 fn test_combo_details_instruments() {
428 let combo = create_test_combo_details();
429 let instruments = combo.instruments();
430 assert_eq!(instruments.len(), 2);
431 assert!(instruments.contains(&"BTC-PERPETUAL"));
432 assert!(instruments.contains(&"BTC-29APR22"));
433 }
434
435 #[test]
436 fn test_combo_details_type_detection() {
437 let futures_spread = create_test_combo_details();
438 assert!(futures_spread.is_futures_spread());
439 assert!(!futures_spread.is_call_spread());
440 assert!(!futures_spread.is_put_spread());
441 assert!(!futures_spread.is_reversal());
442
443 let mut call_spread = create_test_combo_details();
444 call_spread.id = "BTC-CS-29APR22-39300_39600".to_string();
445 assert!(call_spread.is_call_spread());
446
447 let mut reversal = create_test_combo_details();
448 reversal.id = "BTC-REV-29APR22-37500".to_string();
449 assert!(reversal.is_reversal());
450 }
451
452 #[test]
453 fn test_combo_details_serialization() {
454 let combo = create_test_combo_details();
455 let json = serde_json::to_string(&combo).unwrap();
456 let deserialized: ComboDetails = serde_json::from_str(&json).unwrap();
457 assert_eq!(combo.id, deserialized.id);
458 assert_eq!(combo.state, deserialized.state);
459 assert_eq!(combo.legs.len(), deserialized.legs.len());
460 }
461
462 #[test]
463 fn test_combo_ids_new() {
464 let ids = ComboIds::new(vec![
465 "BTC-CS-29APR22-39300_39600".to_string(),
466 "BTC-FS-29APR22_PERP".to_string(),
467 ]);
468 assert_eq!(ids.len(), 2);
469 assert!(!ids.is_empty());
470 }
471
472 #[test]
473 fn test_combo_ids_contains() {
474 let ids = ComboIds::new(vec![
475 "BTC-CS-29APR22-39300_39600".to_string(),
476 "BTC-FS-29APR22_PERP".to_string(),
477 ]);
478 assert!(ids.contains("BTC-FS-29APR22_PERP"));
479 assert!(!ids.contains("ETH-FS-29APR22_PERP"));
480 }
481
482 #[test]
483 fn test_combo_ids_empty() {
484 let ids = ComboIds::new(vec![]);
485 assert!(ids.is_empty());
486 assert_eq!(ids.len(), 0);
487 }
488
489 #[test]
490 fn test_combo_ids_serialization() {
491 let ids = ComboIds::new(vec!["BTC-FS-29APR22_PERP".to_string()]);
492 let json = serde_json::to_string(&ids).unwrap();
493 let deserialized: ComboIds = serde_json::from_str(&json).unwrap();
494 assert_eq!(ids, deserialized);
495 }
496}