1use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18#[derive(Default)]
19pub enum ComboState {
20 #[default]
22 Rfq,
23 Active,
25 Inactive,
27}
28
29impl std::fmt::Display for ComboState {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 match self {
32 ComboState::Rfq => write!(f, "rfq"),
33 ComboState::Active => write!(f, "active"),
34 ComboState::Inactive => write!(f, "inactive"),
35 }
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct ComboLeg {
45 pub instrument_name: String,
47 pub amount: i64,
50}
51
52impl ComboLeg {
53 #[must_use]
60 pub fn new(instrument_name: impl Into<String>, amount: i64) -> Self {
61 Self {
62 instrument_name: instrument_name.into(),
63 amount,
64 }
65 }
66
67 #[must_use]
69 pub fn is_opposite_direction(&self) -> bool {
70 self.amount < 0
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct Combo {
80 pub id: String,
82 pub instrument_id: u64,
84 pub state: ComboState,
86 pub state_timestamp: u64,
88 pub creation_timestamp: u64,
90 pub legs: Vec<ComboLeg>,
92}
93
94impl Combo {
95 #[must_use]
97 pub fn is_active(&self) -> bool {
98 self.state == ComboState::Active
99 }
100
101 #[must_use]
103 pub fn is_rfq(&self) -> bool {
104 self.state == ComboState::Rfq
105 }
106
107 #[must_use]
109 pub fn leg_count(&self) -> usize {
110 self.legs.len()
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct ComboTrade {
120 pub instrument_name: String,
122 #[serde(skip_serializing_if = "Option::is_none")]
126 pub amount: Option<f64>,
127 pub direction: String,
129}
130
131impl ComboTrade {
132 #[must_use]
140 pub fn new(
141 instrument_name: impl Into<String>,
142 direction: impl Into<String>,
143 amount: Option<f64>,
144 ) -> Self {
145 Self {
146 instrument_name: instrument_name.into(),
147 direction: direction.into(),
148 amount,
149 }
150 }
151
152 #[must_use]
154 pub fn buy(instrument_name: impl Into<String>, amount: Option<f64>) -> Self {
155 Self::new(instrument_name, "buy", amount)
156 }
157
158 #[must_use]
160 pub fn sell(instrument_name: impl Into<String>, amount: Option<f64>) -> Self {
161 Self::new(instrument_name, "sell", amount)
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169pub struct LegInput {
170 pub instrument_name: String,
172 pub amount: f64,
176 pub direction: String,
178}
179
180impl LegInput {
181 #[must_use]
189 pub fn new(
190 instrument_name: impl Into<String>,
191 amount: f64,
192 direction: impl Into<String>,
193 ) -> Self {
194 Self {
195 instrument_name: instrument_name.into(),
196 amount,
197 direction: direction.into(),
198 }
199 }
200
201 #[must_use]
203 pub fn buy(instrument_name: impl Into<String>, amount: f64) -> Self {
204 Self::new(instrument_name, amount, "buy")
205 }
206
207 #[must_use]
209 pub fn sell(instrument_name: impl Into<String>, amount: f64) -> Self {
210 Self::new(instrument_name, amount, "sell")
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218pub struct LegPrice {
219 pub instrument_name: String,
221 pub direction: String,
223 pub price: f64,
225 pub ratio: i64,
227}
228
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233pub struct LegPricesResponse {
234 pub amount: f64,
236 pub legs: Vec<LegPrice>,
238}
239
240impl LegPricesResponse {
241 #[must_use]
243 pub fn leg_count(&self) -> usize {
244 self.legs.len()
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn test_combo_state_serialization() {
254 assert_eq!(
255 serde_json::to_string(&ComboState::Active).unwrap(),
256 "\"active\""
257 );
258 assert_eq!(serde_json::to_string(&ComboState::Rfq).unwrap(), "\"rfq\"");
259 assert_eq!(
260 serde_json::to_string(&ComboState::Inactive).unwrap(),
261 "\"inactive\""
262 );
263 }
264
265 #[test]
266 fn test_combo_state_deserialization() {
267 assert_eq!(
268 serde_json::from_str::<ComboState>("\"active\"").unwrap(),
269 ComboState::Active
270 );
271 assert_eq!(
272 serde_json::from_str::<ComboState>("\"rfq\"").unwrap(),
273 ComboState::Rfq
274 );
275 assert_eq!(
276 serde_json::from_str::<ComboState>("\"inactive\"").unwrap(),
277 ComboState::Inactive
278 );
279 }
280
281 #[test]
282 fn test_combo_leg_new() {
283 let leg = ComboLeg::new("BTC-PERPETUAL", -1);
284 assert_eq!(leg.instrument_name, "BTC-PERPETUAL");
285 assert_eq!(leg.amount, -1);
286 assert!(leg.is_opposite_direction());
287 }
288
289 #[test]
290 fn test_combo_leg_positive_amount() {
291 let leg = ComboLeg::new("BTC-29APR22", 1);
292 assert!(!leg.is_opposite_direction());
293 }
294
295 #[test]
296 fn test_combo_trade_buy() {
297 let trade = ComboTrade::buy("BTC-29APR22-37500-C", Some(1.0));
298 assert_eq!(trade.instrument_name, "BTC-29APR22-37500-C");
299 assert_eq!(trade.direction, "buy");
300 assert_eq!(trade.amount, Some(1.0));
301 }
302
303 #[test]
304 fn test_combo_trade_sell() {
305 let trade = ComboTrade::sell("BTC-29APR22-37500-P", None);
306 assert_eq!(trade.direction, "sell");
307 assert!(trade.amount.is_none());
308 }
309
310 #[test]
311 fn test_leg_input_new() {
312 let leg = LegInput::new("BTC-1NOV24-67000-C", 2.0, "buy");
313 assert_eq!(leg.instrument_name, "BTC-1NOV24-67000-C");
314 assert_eq!(leg.amount, 2.0);
315 assert_eq!(leg.direction, "buy");
316 }
317
318 #[test]
319 fn test_combo_deserialization() {
320 let json = r#"{
321 "state_timestamp": 1650960943922,
322 "state": "rfq",
323 "legs": [
324 {"instrument_name": "BTC-29APR22-37500-C", "amount": 1},
325 {"instrument_name": "BTC-29APR22-37500-P", "amount": -1}
326 ],
327 "id": "BTC-REV-29APR22-37500",
328 "instrument_id": 52,
329 "creation_timestamp": 1650960943000
330 }"#;
331
332 let combo: Combo = serde_json::from_str(json).unwrap();
333 assert_eq!(combo.id, "BTC-REV-29APR22-37500");
334 assert_eq!(combo.instrument_id, 52);
335 assert_eq!(combo.state, ComboState::Rfq);
336 assert!(combo.is_rfq());
337 assert!(!combo.is_active());
338 assert_eq!(combo.leg_count(), 2);
339 assert_eq!(combo.legs[0].instrument_name, "BTC-29APR22-37500-C");
340 assert_eq!(combo.legs[0].amount, 1);
341 assert_eq!(combo.legs[1].amount, -1);
342 }
343
344 #[test]
345 fn test_leg_prices_response_deserialization() {
346 let json = r#"{
347 "legs": [
348 {"ratio": 1, "instrument_name": "BTC-1NOV24-67000-C", "price": 0.6001, "direction": "buy"},
349 {"ratio": 1, "instrument_name": "BTC-1NOV24-66000-C", "price": 0.0001, "direction": "sell"}
350 ],
351 "amount": 2
352 }"#;
353
354 let response: LegPricesResponse = serde_json::from_str(json).unwrap();
355 assert_eq!(response.amount, 2.0);
356 assert_eq!(response.leg_count(), 2);
357 assert_eq!(response.legs[0].price, 0.6001);
358 assert_eq!(response.legs[1].direction, "sell");
359 }
360
361 #[test]
362 fn test_combo_trade_serialization() {
363 let trade = ComboTrade::new("BTC-29APR22-37500-C", "buy", Some(1.0));
364 let json = serde_json::to_string(&trade).unwrap();
365 assert!(json.contains("\"instrument_name\":\"BTC-29APR22-37500-C\""));
366 assert!(json.contains("\"direction\":\"buy\""));
367 assert!(json.contains("\"amount\":1.0"));
368 }
369
370 #[test]
371 fn test_combo_trade_without_amount() {
372 let trade = ComboTrade::new("BTC-29APR22-37500-C", "buy", None);
373 let json = serde_json::to_string(&trade).unwrap();
374 assert!(!json.contains("amount"));
375 }
376}