1use nautilus_core::{Params, UnixNanos};
19use nautilus_model::{
20 enums::{AssetClass, CurrencyType},
21 identifiers::{InstrumentId, Symbol},
22 instruments::{BinaryOption, InstrumentAny},
23 types::{Currency, Price, Quantity},
24};
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27use ustr::Ustr;
28
29use super::models::{FeeSchedule, GammaMarket};
30use crate::common::{
31 consts::{MAX_PRICE, MIN_PRICE, POLYMARKET_VENUE, PUSD},
32 enums::PolymarketOutcome,
33};
34
35const DEFAULT_TICK_SIZE: &str = "0.001";
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct PolymarketInstrumentDef {
42 pub symbol: Ustr,
44 pub token_id: Ustr,
46 pub condition_id: Ustr,
48 pub market_id: String,
50 pub question_id: Option<String>,
52 pub outcome: PolymarketOutcome,
54 pub question: String,
56 pub description: Option<String>,
58 pub price_precision: u8,
60 pub tick_size: Decimal,
62 pub min_size: Option<Decimal>,
64 pub maker_fee: Option<Decimal>,
66 pub taker_fee: Option<Decimal>,
68 pub start_date: Option<String>,
70 pub end_date: Option<String>,
72 pub active: bool,
74 pub market_slug: Option<String>,
76 pub neg_risk: bool,
78 pub fee_schedule: Option<FeeSchedule>,
80 pub game_id: Option<u64>,
82}
83
84pub fn parse_gamma_market(market: &GammaMarket) -> anyhow::Result<Vec<PolymarketInstrumentDef>> {
89 let game_id = market.game_id.or_else(|| {
90 market
91 .events
92 .as_ref()?
93 .iter()
94 .find_map(|event| event.game_id)
95 });
96
97 let token_ids: Vec<String> = serde_json::from_str(&market.clob_token_ids).map_err(|e| {
98 anyhow::anyhow!(
99 "Failed to parse clob_token_ids '{}': {e}",
100 market.clob_token_ids
101 )
102 })?;
103
104 if token_ids.len() != 2 {
105 anyhow::bail!("Expected 2 token IDs, received {}", token_ids.len());
106 }
107
108 let outcomes: Vec<String> = serde_json::from_str(&market.outcomes)
109 .map_err(|e| anyhow::anyhow!("Failed to parse outcomes '{}': {e}", market.outcomes))?;
110
111 if outcomes.len() != 2 {
112 anyhow::bail!("Expected 2 outcomes, received {}", outcomes.len());
113 }
114
115 let tick_size_str = market
116 .order_price_min_tick_size
117 .map_or_else(|| DEFAULT_TICK_SIZE.to_string(), |ts| ts.to_string());
118 let tick_size: Decimal = tick_size_str
119 .parse()
120 .map_err(|e| anyhow::anyhow!("Failed to parse tick size '{tick_size_str}': {e}"))?;
121 let price_precision = tick_size.scale() as u8;
122
123 let maker_fee: Option<Decimal> = market.fee_schedule.as_ref().map(|_| Decimal::ZERO);
127 let taker_fee: Option<Decimal> = market
128 .fee_schedule
129 .as_ref()
130 .and_then(|fs| Decimal::try_from(fs.rate).ok());
131
132 let min_size: Option<Decimal> = market
133 .order_min_size
134 .map(|s| s.to_string().parse())
135 .transpose()
136 .map_err(|e| anyhow::anyhow!("Failed to parse min size: {e}"))?;
137
138 let active = market.active.unwrap_or(false)
139 && !market.closed.unwrap_or(false)
140 && market.accepting_orders.unwrap_or(false);
141
142 let neg_risk = market.neg_risk.unwrap_or(false);
143
144 let mut defs = Vec::with_capacity(2);
145
146 for (token_id, outcome_label) in token_ids.iter().zip(outcomes.iter()) {
147 let outcome = PolymarketOutcome::from(outcome_label.as_str());
148
149 let symbol_str = format!("{}-{token_id}", market.condition_id);
150
151 defs.push(PolymarketInstrumentDef {
152 symbol: Ustr::from(&symbol_str),
153 token_id: Ustr::from(token_id.as_str()),
154 condition_id: Ustr::from(market.condition_id.as_str()),
155 market_id: market.id.clone(),
156 question_id: market.question_id.clone(),
157 outcome,
158 question: market.question.clone(),
159 description: market.description.clone(),
160 price_precision,
161 tick_size,
162 min_size,
163 maker_fee,
164 taker_fee,
165 start_date: market.start_date.clone(),
166 end_date: market.end_date.clone(),
167 active,
168 market_slug: market.market_slug.clone(),
169 neg_risk,
170 fee_schedule: market.fee_schedule.clone(),
171 game_id,
172 });
173 }
174
175 Ok(defs)
176}
177
178pub fn create_instrument_from_def(
180 def: &PolymarketInstrumentDef,
181 ts_init: UnixNanos,
182) -> anyhow::Result<InstrumentAny> {
183 let symbol = Symbol::new(def.symbol);
184 let venue = *POLYMARKET_VENUE;
185 let instrument_id = InstrumentId::new(symbol, venue);
186 let raw_symbol = Symbol::new(def.token_id);
187 let currency = get_currency(PUSD);
188
189 let price_increment = Price::from(def.tick_size.to_string());
190 let size_increment = Quantity::from("0.000001");
191
192 let activation_ns = def
193 .start_date
194 .as_deref()
195 .and_then(parse_datetime_to_nanos)
196 .unwrap_or_default();
197 let expiration_ns = def
198 .end_date
199 .as_deref()
200 .and_then(parse_datetime_to_nanos)
201 .unwrap_or_default();
202
203 let max_price = Some(Price::from(MAX_PRICE));
204 let min_price = Some(Price::from(MIN_PRICE));
205 let min_quantity: Option<Quantity> = None;
209
210 let info: Params = serde_json::from_value(build_info_json(def))?;
211
212 let binary_option = BinaryOption::new_checked(
213 instrument_id,
214 raw_symbol,
215 AssetClass::Alternative,
216 currency,
217 activation_ns,
218 expiration_ns,
219 def.price_precision,
220 6, price_increment,
222 size_increment,
223 Some(def.outcome.inner()),
224 Some(Ustr::from(def.question.as_str())),
225 None, min_quantity,
227 None, None, max_price,
230 min_price,
231 None, None, def.maker_fee,
234 def.taker_fee,
235 None,
236 Some(info),
237 ts_init,
238 ts_init,
239 )?;
240
241 Ok(InstrumentAny::BinaryOption(binary_option))
242}
243
244#[must_use]
246pub fn instruments_from_defs(
247 defs: &[PolymarketInstrumentDef],
248 ts_init: UnixNanos,
249) -> Vec<InstrumentAny> {
250 defs.iter()
251 .filter_map(|def| {
252 create_instrument_from_def(def, ts_init)
253 .map_err(|e| log::warn!("Failed to create instrument {}: {e}", def.symbol))
254 .ok()
255 })
256 .collect()
257}
258
259pub fn rebuild_instrument_with_tick_size(
263 existing: &InstrumentAny,
264 new_tick_size: &str,
265 ts_event: UnixNanos,
266 ts_init: UnixNanos,
267) -> anyhow::Result<InstrumentAny> {
268 let bo = match existing {
269 InstrumentAny::BinaryOption(b) => b,
270 other => anyhow::bail!("Expected BinaryOption, was {other:?}"),
271 };
272
273 let tick_size: Decimal = new_tick_size
274 .parse()
275 .map_err(|e| anyhow::anyhow!("Failed to parse tick size '{new_tick_size}': {e}"))?;
276 let price_precision = tick_size.scale() as u8;
277 let price_increment = Price::from(tick_size.to_string());
278
279 let rebuilt = BinaryOption::new_checked(
280 bo.id,
281 bo.raw_symbol,
282 bo.asset_class,
283 bo.currency,
284 bo.activation_ns,
285 bo.expiration_ns,
286 price_precision,
287 bo.size_precision,
288 price_increment,
289 bo.size_increment,
290 bo.outcome,
291 bo.description,
292 bo.max_quantity,
293 None, bo.max_notional,
295 bo.min_notional,
296 bo.max_price,
297 bo.min_price,
298 Some(bo.margin_init),
299 Some(bo.margin_maint),
300 Some(bo.maker_fee),
301 Some(bo.taker_fee),
302 None,
303 bo.info.clone(),
304 ts_event,
305 ts_init,
306 )?;
307
308 Ok(InstrumentAny::BinaryOption(rebuilt))
309}
310
311fn build_info_json(def: &PolymarketInstrumentDef) -> serde_json::Value {
312 let mut map = serde_json::Map::new();
313 map.insert(
314 "token_id".to_string(),
315 serde_json::Value::String(def.token_id.to_string()),
316 );
317 map.insert(
318 "condition_id".to_string(),
319 serde_json::Value::String(def.condition_id.to_string()),
320 );
321 map.insert(
322 "market_id".to_string(),
323 serde_json::Value::String(def.market_id.clone()),
324 );
325
326 if let Some(qid) = &def.question_id {
327 map.insert(
328 "question_id".to_string(),
329 serde_json::Value::String(qid.clone()),
330 );
331 }
332
333 if let Some(slug) = &def.market_slug {
334 map.insert(
335 "market_slug".to_string(),
336 serde_json::Value::String(slug.clone()),
337 );
338 }
339
340 map.insert(
341 "neg_risk".to_string(),
342 serde_json::Value::Bool(def.neg_risk),
343 );
344
345 if let Some(fee_schedule) = &def.fee_schedule
346 && let Ok(value) = serde_json::to_value(fee_schedule)
347 {
348 map.insert("fee_schedule".to_string(), value);
349 }
350
351 if let Some(game_id) = def.game_id {
352 map.insert("game_id".to_string(), serde_json::Value::from(game_id));
353 }
354
355 serde_json::Value::Object(map)
356}
357
358fn get_currency(code: &str) -> Currency {
359 Currency::try_from_str(code).unwrap_or_else(|| {
360 let currency = Currency::new(code, 6, 0, code, CurrencyType::Crypto);
361 if let Err(e) = Currency::register(currency, false) {
362 log::error!("Failed to register currency '{code}': {e}");
363 }
364 currency
365 })
366}
367
368fn parse_datetime_to_nanos(s: &str) -> Option<UnixNanos> {
369 chrono::DateTime::parse_from_rfc3339(s)
370 .ok()
371 .and_then(|dt| dt.timestamp_nanos_opt())
372 .map(|ns| UnixNanos::from(ns as u64))
373}
374
375#[cfg(test)]
376mod tests {
377 use nautilus_model::instruments::Instrument;
378 use rstest::rstest;
379 use rust_decimal_macros::dec;
380
381 use super::*;
382
383 fn load_gamma_market(filename: &str) -> GammaMarket {
384 let path = format!("test_data/{filename}");
385 let content = std::fs::read_to_string(path).expect("Failed to read test data");
386 serde_json::from_str(&content).expect("Failed to parse test data")
387 }
388
389 #[rstest]
390 fn test_parse_gamma_market_produces_two_defs() {
391 let market = load_gamma_market("gamma_market.json");
392 let defs = parse_gamma_market(&market).unwrap();
393
394 assert_eq!(defs.len(), 2);
395 assert_eq!(defs[0].outcome, PolymarketOutcome::from("Up"));
396 assert_eq!(defs[1].outcome, PolymarketOutcome::from("Down"));
397 }
398
399 #[rstest]
400 fn test_parse_gamma_market_fields() {
401 let market = load_gamma_market("gamma_market.json");
402 let defs = parse_gamma_market(&market).unwrap();
403 let yes_def = &defs[0];
404
405 assert_eq!(
406 yes_def.condition_id.as_str(),
407 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b"
408 );
409 assert_eq!(yes_def.market_id, "1557558");
410 assert_eq!(
411 yes_def.question_id.as_deref(),
412 Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
413 );
414 assert_eq!(
415 yes_def.question,
416 "Bitcoin Up or Down - March 12, 5:20AM-5:25AM ET"
417 );
418 assert_eq!(yes_def.tick_size, dec!(0.01));
419 assert_eq!(yes_def.price_precision, 2);
420 assert_eq!(yes_def.min_size, Some(dec!(5.0)));
421 assert!(yes_def.maker_fee.is_none());
422 assert!(yes_def.taker_fee.is_none());
423 assert!(yes_def.active);
424 assert_eq!(
425 yes_def.market_slug.as_deref(),
426 Some("btc-updown-5m-1773307200")
427 );
428 assert_eq!(yes_def.game_id, None);
429 }
430
431 #[rstest]
432 fn test_parse_gamma_market_sports_game_id_and_fee_schedule() {
433 let money_line = load_gamma_market("gamma_market_sports_market_money_line.json");
434 let map_handicap = load_gamma_market("gamma_market_sports_market_map_handicap.json");
435
436 let money_line_defs = parse_gamma_market(&money_line).unwrap();
437 let map_handicap_defs = parse_gamma_market(&map_handicap).unwrap();
438
439 assert_eq!(money_line_defs[0].game_id, Some(1_427_074));
440 assert_eq!(map_handicap_defs[0].game_id, Some(1_427_074));
441 assert_eq!(money_line_defs[0].fee_schedule, money_line.fee_schedule);
442 assert_eq!(map_handicap_defs[0].fee_schedule, map_handicap.fee_schedule);
443
444 assert_eq!(money_line_defs[0].maker_fee, Some(Decimal::ZERO));
446 assert_eq!(money_line_defs[0].taker_fee, Some(dec!(0.03)));
448 }
449
450 #[rstest]
451 fn test_parse_gamma_market_symbol_format() {
452 let market = load_gamma_market("gamma_market.json");
453 let defs = parse_gamma_market(&market).unwrap();
454
455 assert_eq!(
456 defs[0].symbol.as_str(),
457 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875"
458 );
459 assert_eq!(
460 defs[1].symbol.as_str(),
461 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-71183960810705820955071415844881728181970340514894896943812046065452395013351"
462 );
463 }
464
465 #[rstest]
466 fn test_parse_gamma_market_token_ids() {
467 let market = load_gamma_market("gamma_market.json");
468 let defs = parse_gamma_market(&market).unwrap();
469
470 assert_eq!(
471 defs[0].token_id.as_str(),
472 "104239898038807136052399800151408521467737075933964991162589336683346093173875"
473 );
474 assert_eq!(
475 defs[1].token_id.as_str(),
476 "71183960810705820955071415844881728181970340514894896943812046065452395013351"
477 );
478 }
479
480 #[rstest]
481 fn test_parse_gamma_market_derives_outcome_from_label() {
482 let mut market = load_gamma_market("gamma_market.json");
483
484 market.outcomes = r#"["No", "Yes"]"#.to_string();
486
487 let defs = parse_gamma_market(&market).unwrap();
488
489 assert_eq!(defs[0].outcome, PolymarketOutcome::no());
490 assert_eq!(defs[1].outcome, PolymarketOutcome::yes());
491 }
492
493 #[rstest]
494 fn test_parse_gamma_market_accepts_arbitrary_outcome_label() {
495 let mut market = load_gamma_market("gamma_market.json");
496 market.outcomes = r#"["Maybe", "No"]"#.to_string();
497
498 let defs = parse_gamma_market(&market).unwrap();
499
500 assert_eq!(defs[0].outcome, PolymarketOutcome::from("Maybe"));
501 assert_eq!(defs[1].outcome, PolymarketOutcome::no());
502 }
503
504 #[rstest]
505 fn test_parse_gamma_market_null_tick_size_uses_default() {
506 let mut market = load_gamma_market("gamma_market.json");
507 market.order_price_min_tick_size = None;
508
509 let defs = parse_gamma_market(&market).unwrap();
510
511 assert_eq!(defs[0].tick_size, dec!(0.001));
512 assert_eq!(defs[0].price_precision, 3);
513 }
514
515 #[rstest]
516 fn test_parse_gamma_market_closed_is_inactive() {
517 let mut market = load_gamma_market("gamma_market.json");
518 market.closed = Some(true);
519
520 let defs = parse_gamma_market(&market).unwrap();
521
522 assert!(!defs[0].active);
523 assert!(!defs[1].active);
524 }
525
526 #[rstest]
527 fn test_create_instrument_from_def() {
528 let market = load_gamma_market("gamma_market.json");
529 let defs = parse_gamma_market(&market).unwrap();
530 let ts_init = UnixNanos::from(1_000_000_000u64);
531
532 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
533
534 let binary = match &instrument {
535 InstrumentAny::BinaryOption(b) => b,
536 other => panic!("Expected BinaryOption, was {other:?}"),
537 };
538
539 assert_eq!(
540 binary.id.to_string(),
541 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875.POLYMARKET"
542 );
543 assert_eq!(binary.outcome, Some(Ustr::from("Up")));
544 assert_eq!(binary.asset_class, AssetClass::Alternative);
545 assert_eq!(binary.currency.code.as_str(), "pUSD");
546 assert_eq!(binary.price_precision, 2);
547 assert_eq!(binary.size_precision, 6);
548 assert_eq!(binary.price_increment(), Price::from("0.01"));
549 assert_eq!(binary.size_increment(), Quantity::from("0.000001"));
550 }
551
552 #[rstest]
553 fn test_create_instrument_info_params() {
554 let market = load_gamma_market("gamma_market.json");
555 let defs = parse_gamma_market(&market).unwrap();
556 let ts_init = UnixNanos::from(1_000_000_000u64);
557
558 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
559
560 let binary = match &instrument {
561 InstrumentAny::BinaryOption(b) => b,
562 other => panic!("Expected BinaryOption, was {other:?}"),
563 };
564
565 let info = binary.info.as_ref().expect("info should be Some");
566 assert_eq!(
567 info.get_str("token_id"),
568 Some("104239898038807136052399800151408521467737075933964991162589336683346093173875")
569 );
570 assert_eq!(
571 info.get_str("condition_id"),
572 Some("0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b")
573 );
574 assert_eq!(info.get_str("market_id"), Some("1557558"));
575 assert_eq!(
576 info.get_str("question_id"),
577 Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
578 );
579 assert_eq!(
580 info.get_str("market_slug"),
581 Some("btc-updown-5m-1773307200")
582 );
583 assert_eq!(info.get_u64("game_id"), None);
584 assert_eq!(info.get("fee_schedule"), None);
585 }
586
587 #[rstest]
588 fn test_create_instrument_info_params_includes_game_id_and_fee_schedule() {
589 let market = load_gamma_market("gamma_market_sports_market_money_line.json");
590 let defs = parse_gamma_market(&market).unwrap();
591 let ts_init = UnixNanos::from(1_000_000_000u64);
592
593 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
594
595 let binary = match &instrument {
596 InstrumentAny::BinaryOption(b) => b,
597 other => panic!("Expected BinaryOption, was {other:?}"),
598 };
599
600 let info = binary.info.as_ref().expect("info should be Some");
601 assert_eq!(info.get_u64("game_id"), Some(1_427_074));
602 assert!(info.get("fee_schedule").is_some());
603 }
604
605 #[rstest]
606 fn test_instruments_from_defs_batch() {
607 let market = load_gamma_market("gamma_market.json");
608 let defs = parse_gamma_market(&market).unwrap();
609 let ts_init = UnixNanos::from(1_000_000_000u64);
610
611 let instruments = instruments_from_defs(&defs, ts_init);
612
613 assert_eq!(instruments.len(), 2);
614 }
615
616 #[rstest]
617 fn test_create_instrument_max_min_price() {
618 let market = load_gamma_market("gamma_market.json");
619 let defs = parse_gamma_market(&market).unwrap();
620 let ts_init = UnixNanos::from(1_000_000_000u64);
621
622 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
623
624 let binary = match &instrument {
625 InstrumentAny::BinaryOption(b) => b,
626 other => panic!("Expected BinaryOption, was {other:?}"),
627 };
628
629 assert_eq!(binary.max_price, Some(Price::from("0.999")));
630 assert_eq!(binary.min_price, Some(Price::from("0.001")));
631 }
632
633 #[rstest]
634 fn test_rebuild_instrument_with_tick_size() {
635 let market = load_gamma_market("gamma_market.json");
636 let defs = parse_gamma_market(&market).unwrap();
637 let ts_init = UnixNanos::from(1_000_000_000u64);
638
639 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
641 assert_eq!(instrument.price_precision(), 2);
642
643 let ts_event = UnixNanos::from(2_000_000_000u64);
644 let rebuilt =
645 rebuild_instrument_with_tick_size(&instrument, "0.001", ts_event, ts_event).unwrap();
646
647 assert_eq!(rebuilt.price_precision(), 3);
648 assert_eq!(rebuilt.price_increment(), Price::from("0.001"));
649 }
650
651 #[rstest]
652 fn test_rebuild_instrument_preserves_fields() {
653 let market = load_gamma_market("gamma_market.json");
654 let defs = parse_gamma_market(&market).unwrap();
655 let ts_init = UnixNanos::from(1_000_000_000u64);
656
657 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
658 let ts_event = UnixNanos::from(2_000_000_000u64);
659 let rebuilt =
660 rebuild_instrument_with_tick_size(&instrument, "0.01", ts_event, ts_event).unwrap();
661
662 assert_eq!(rebuilt.id(), instrument.id());
663 assert_eq!(rebuilt.raw_symbol(), instrument.raw_symbol());
664 assert_eq!(rebuilt.size_precision(), instrument.size_precision());
665
666 let orig_bo = match &instrument {
667 InstrumentAny::BinaryOption(b) => b,
668 _ => panic!(),
669 };
670 let new_bo = match &rebuilt {
671 InstrumentAny::BinaryOption(b) => b,
672 _ => panic!(),
673 };
674 assert_eq!(new_bo.outcome, orig_bo.outcome);
675 assert_eq!(new_bo.currency, orig_bo.currency);
676 }
677}