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 Some(info),
236 ts_init,
237 ts_init,
238 )?;
239
240 Ok(InstrumentAny::BinaryOption(binary_option))
241}
242
243#[must_use]
245pub fn instruments_from_defs(
246 defs: &[PolymarketInstrumentDef],
247 ts_init: UnixNanos,
248) -> Vec<InstrumentAny> {
249 defs.iter()
250 .filter_map(|def| {
251 create_instrument_from_def(def, ts_init)
252 .map_err(|e| log::warn!("Failed to create instrument {}: {e}", def.symbol))
253 .ok()
254 })
255 .collect()
256}
257
258pub fn rebuild_instrument_with_tick_size(
262 existing: &InstrumentAny,
263 new_tick_size: &str,
264 ts_event: UnixNanos,
265 ts_init: UnixNanos,
266) -> anyhow::Result<InstrumentAny> {
267 let bo = match existing {
268 InstrumentAny::BinaryOption(b) => b,
269 other => anyhow::bail!("Expected BinaryOption, was {other:?}"),
270 };
271
272 let tick_size: Decimal = new_tick_size
273 .parse()
274 .map_err(|e| anyhow::anyhow!("Failed to parse tick size '{new_tick_size}': {e}"))?;
275 let price_precision = tick_size.scale() as u8;
276 let price_increment = Price::from(tick_size.to_string());
277
278 let rebuilt = BinaryOption::new_checked(
279 bo.id,
280 bo.raw_symbol,
281 bo.asset_class,
282 bo.currency,
283 bo.activation_ns,
284 bo.expiration_ns,
285 price_precision,
286 bo.size_precision,
287 price_increment,
288 bo.size_increment,
289 bo.outcome,
290 bo.description,
291 bo.max_quantity,
292 None, bo.max_notional,
294 bo.min_notional,
295 bo.max_price,
296 bo.min_price,
297 Some(bo.margin_init),
298 Some(bo.margin_maint),
299 Some(bo.maker_fee),
300 Some(bo.taker_fee),
301 bo.info.clone(),
302 ts_event,
303 ts_init,
304 )?;
305
306 Ok(InstrumentAny::BinaryOption(rebuilt))
307}
308
309fn build_info_json(def: &PolymarketInstrumentDef) -> serde_json::Value {
310 let mut map = serde_json::Map::new();
311 map.insert(
312 "token_id".to_string(),
313 serde_json::Value::String(def.token_id.to_string()),
314 );
315 map.insert(
316 "condition_id".to_string(),
317 serde_json::Value::String(def.condition_id.to_string()),
318 );
319 map.insert(
320 "market_id".to_string(),
321 serde_json::Value::String(def.market_id.clone()),
322 );
323
324 if let Some(qid) = &def.question_id {
325 map.insert(
326 "question_id".to_string(),
327 serde_json::Value::String(qid.clone()),
328 );
329 }
330
331 if let Some(slug) = &def.market_slug {
332 map.insert(
333 "market_slug".to_string(),
334 serde_json::Value::String(slug.clone()),
335 );
336 }
337
338 map.insert(
339 "neg_risk".to_string(),
340 serde_json::Value::Bool(def.neg_risk),
341 );
342
343 if let Some(fee_schedule) = &def.fee_schedule
344 && let Ok(value) = serde_json::to_value(fee_schedule)
345 {
346 map.insert("fee_schedule".to_string(), value);
347 }
348
349 if let Some(game_id) = def.game_id {
350 map.insert("game_id".to_string(), serde_json::Value::from(game_id));
351 }
352
353 serde_json::Value::Object(map)
354}
355
356fn get_currency(code: &str) -> Currency {
357 Currency::try_from_str(code).unwrap_or_else(|| {
358 let currency = Currency::new(code, 6, 0, code, CurrencyType::Crypto);
359 if let Err(e) = Currency::register(currency, false) {
360 log::error!("Failed to register currency '{code}': {e}");
361 }
362 currency
363 })
364}
365
366fn parse_datetime_to_nanos(s: &str) -> Option<UnixNanos> {
367 chrono::DateTime::parse_from_rfc3339(s)
368 .ok()
369 .and_then(|dt| dt.timestamp_nanos_opt())
370 .map(|ns| UnixNanos::from(ns as u64))
371}
372
373#[cfg(test)]
374mod tests {
375 use nautilus_model::instruments::Instrument;
376 use rstest::rstest;
377 use rust_decimal_macros::dec;
378
379 use super::*;
380
381 fn load_gamma_market(filename: &str) -> GammaMarket {
382 let path = format!("test_data/{filename}");
383 let content = std::fs::read_to_string(path).expect("Failed to read test data");
384 serde_json::from_str(&content).expect("Failed to parse test data")
385 }
386
387 #[rstest]
388 fn test_parse_gamma_market_produces_two_defs() {
389 let market = load_gamma_market("gamma_market.json");
390 let defs = parse_gamma_market(&market).unwrap();
391
392 assert_eq!(defs.len(), 2);
393 assert_eq!(defs[0].outcome, PolymarketOutcome::from("Up"));
394 assert_eq!(defs[1].outcome, PolymarketOutcome::from("Down"));
395 }
396
397 #[rstest]
398 fn test_parse_gamma_market_fields() {
399 let market = load_gamma_market("gamma_market.json");
400 let defs = parse_gamma_market(&market).unwrap();
401 let yes_def = &defs[0];
402
403 assert_eq!(
404 yes_def.condition_id.as_str(),
405 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b"
406 );
407 assert_eq!(yes_def.market_id, "1557558");
408 assert_eq!(
409 yes_def.question_id.as_deref(),
410 Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
411 );
412 assert_eq!(
413 yes_def.question,
414 "Bitcoin Up or Down - March 12, 5:20AM-5:25AM ET"
415 );
416 assert_eq!(yes_def.tick_size, dec!(0.01));
417 assert_eq!(yes_def.price_precision, 2);
418 assert_eq!(yes_def.min_size, Some(dec!(5.0)));
419 assert!(yes_def.maker_fee.is_none());
420 assert!(yes_def.taker_fee.is_none());
421 assert!(yes_def.active);
422 assert_eq!(
423 yes_def.market_slug.as_deref(),
424 Some("btc-updown-5m-1773307200")
425 );
426 assert_eq!(yes_def.game_id, None);
427 }
428
429 #[rstest]
430 fn test_parse_gamma_market_sports_game_id_and_fee_schedule() {
431 let money_line = load_gamma_market("gamma_market_sports_market_money_line.json");
432 let map_handicap = load_gamma_market("gamma_market_sports_market_map_handicap.json");
433
434 let money_line_defs = parse_gamma_market(&money_line).unwrap();
435 let map_handicap_defs = parse_gamma_market(&map_handicap).unwrap();
436
437 assert_eq!(money_line_defs[0].game_id, Some(1_427_074));
438 assert_eq!(map_handicap_defs[0].game_id, Some(1_427_074));
439 assert_eq!(money_line_defs[0].fee_schedule, money_line.fee_schedule);
440 assert_eq!(map_handicap_defs[0].fee_schedule, map_handicap.fee_schedule);
441
442 assert_eq!(money_line_defs[0].maker_fee, Some(Decimal::ZERO));
444 assert_eq!(money_line_defs[0].taker_fee, Some(dec!(0.03)));
446 }
447
448 #[rstest]
449 fn test_parse_gamma_market_symbol_format() {
450 let market = load_gamma_market("gamma_market.json");
451 let defs = parse_gamma_market(&market).unwrap();
452
453 assert_eq!(
454 defs[0].symbol.as_str(),
455 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875"
456 );
457 assert_eq!(
458 defs[1].symbol.as_str(),
459 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-71183960810705820955071415844881728181970340514894896943812046065452395013351"
460 );
461 }
462
463 #[rstest]
464 fn test_parse_gamma_market_token_ids() {
465 let market = load_gamma_market("gamma_market.json");
466 let defs = parse_gamma_market(&market).unwrap();
467
468 assert_eq!(
469 defs[0].token_id.as_str(),
470 "104239898038807136052399800151408521467737075933964991162589336683346093173875"
471 );
472 assert_eq!(
473 defs[1].token_id.as_str(),
474 "71183960810705820955071415844881728181970340514894896943812046065452395013351"
475 );
476 }
477
478 #[rstest]
479 fn test_parse_gamma_market_derives_outcome_from_label() {
480 let mut market = load_gamma_market("gamma_market.json");
481
482 market.outcomes = r#"["No", "Yes"]"#.to_string();
484
485 let defs = parse_gamma_market(&market).unwrap();
486
487 assert_eq!(defs[0].outcome, PolymarketOutcome::no());
488 assert_eq!(defs[1].outcome, PolymarketOutcome::yes());
489 }
490
491 #[rstest]
492 fn test_parse_gamma_market_accepts_arbitrary_outcome_label() {
493 let mut market = load_gamma_market("gamma_market.json");
494 market.outcomes = r#"["Maybe", "No"]"#.to_string();
495
496 let defs = parse_gamma_market(&market).unwrap();
497
498 assert_eq!(defs[0].outcome, PolymarketOutcome::from("Maybe"));
499 assert_eq!(defs[1].outcome, PolymarketOutcome::no());
500 }
501
502 #[rstest]
503 fn test_parse_gamma_market_null_tick_size_uses_default() {
504 let mut market = load_gamma_market("gamma_market.json");
505 market.order_price_min_tick_size = None;
506
507 let defs = parse_gamma_market(&market).unwrap();
508
509 assert_eq!(defs[0].tick_size, dec!(0.001));
510 assert_eq!(defs[0].price_precision, 3);
511 }
512
513 #[rstest]
514 fn test_parse_gamma_market_closed_is_inactive() {
515 let mut market = load_gamma_market("gamma_market.json");
516 market.closed = Some(true);
517
518 let defs = parse_gamma_market(&market).unwrap();
519
520 assert!(!defs[0].active);
521 assert!(!defs[1].active);
522 }
523
524 #[rstest]
525 fn test_create_instrument_from_def() {
526 let market = load_gamma_market("gamma_market.json");
527 let defs = parse_gamma_market(&market).unwrap();
528 let ts_init = UnixNanos::from(1_000_000_000u64);
529
530 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
531
532 let binary = match &instrument {
533 InstrumentAny::BinaryOption(b) => b,
534 other => panic!("Expected BinaryOption, was {other:?}"),
535 };
536
537 assert_eq!(
538 binary.id.to_string(),
539 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875.POLYMARKET"
540 );
541 assert_eq!(binary.outcome, Some(Ustr::from("Up")));
542 assert_eq!(binary.asset_class, AssetClass::Alternative);
543 assert_eq!(binary.currency.code.as_str(), "pUSD");
544 assert_eq!(binary.price_precision, 2);
545 assert_eq!(binary.size_precision, 6);
546 assert_eq!(binary.price_increment(), Price::from("0.01"));
547 assert_eq!(binary.size_increment(), Quantity::from("0.000001"));
548 }
549
550 #[rstest]
551 fn test_create_instrument_info_params() {
552 let market = load_gamma_market("gamma_market.json");
553 let defs = parse_gamma_market(&market).unwrap();
554 let ts_init = UnixNanos::from(1_000_000_000u64);
555
556 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
557
558 let binary = match &instrument {
559 InstrumentAny::BinaryOption(b) => b,
560 other => panic!("Expected BinaryOption, was {other:?}"),
561 };
562
563 let info = binary.info.as_ref().expect("info should be Some");
564 assert_eq!(
565 info.get_str("token_id"),
566 Some("104239898038807136052399800151408521467737075933964991162589336683346093173875")
567 );
568 assert_eq!(
569 info.get_str("condition_id"),
570 Some("0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b")
571 );
572 assert_eq!(info.get_str("market_id"), Some("1557558"));
573 assert_eq!(
574 info.get_str("question_id"),
575 Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
576 );
577 assert_eq!(
578 info.get_str("market_slug"),
579 Some("btc-updown-5m-1773307200")
580 );
581 assert_eq!(info.get_u64("game_id"), None);
582 assert_eq!(info.get("fee_schedule"), None);
583 }
584
585 #[rstest]
586 fn test_create_instrument_info_params_includes_game_id_and_fee_schedule() {
587 let market = load_gamma_market("gamma_market_sports_market_money_line.json");
588 let defs = parse_gamma_market(&market).unwrap();
589 let ts_init = UnixNanos::from(1_000_000_000u64);
590
591 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
592
593 let binary = match &instrument {
594 InstrumentAny::BinaryOption(b) => b,
595 other => panic!("Expected BinaryOption, was {other:?}"),
596 };
597
598 let info = binary.info.as_ref().expect("info should be Some");
599 assert_eq!(info.get_u64("game_id"), Some(1_427_074));
600 assert!(info.get("fee_schedule").is_some());
601 }
602
603 #[rstest]
604 fn test_instruments_from_defs_batch() {
605 let market = load_gamma_market("gamma_market.json");
606 let defs = parse_gamma_market(&market).unwrap();
607 let ts_init = UnixNanos::from(1_000_000_000u64);
608
609 let instruments = instruments_from_defs(&defs, ts_init);
610
611 assert_eq!(instruments.len(), 2);
612 }
613
614 #[rstest]
615 fn test_create_instrument_max_min_price() {
616 let market = load_gamma_market("gamma_market.json");
617 let defs = parse_gamma_market(&market).unwrap();
618 let ts_init = UnixNanos::from(1_000_000_000u64);
619
620 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
621
622 let binary = match &instrument {
623 InstrumentAny::BinaryOption(b) => b,
624 other => panic!("Expected BinaryOption, was {other:?}"),
625 };
626
627 assert_eq!(binary.max_price, Some(Price::from("0.999")));
628 assert_eq!(binary.min_price, Some(Price::from("0.001")));
629 }
630
631 #[rstest]
632 fn test_rebuild_instrument_with_tick_size() {
633 let market = load_gamma_market("gamma_market.json");
634 let defs = parse_gamma_market(&market).unwrap();
635 let ts_init = UnixNanos::from(1_000_000_000u64);
636
637 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
639 assert_eq!(instrument.price_precision(), 2);
640
641 let ts_event = UnixNanos::from(2_000_000_000u64);
642 let rebuilt =
643 rebuild_instrument_with_tick_size(&instrument, "0.001", ts_event, ts_event).unwrap();
644
645 assert_eq!(rebuilt.price_precision(), 3);
646 assert_eq!(rebuilt.price_increment(), Price::from("0.001"));
647 }
648
649 #[rstest]
650 fn test_rebuild_instrument_preserves_fields() {
651 let market = load_gamma_market("gamma_market.json");
652 let defs = parse_gamma_market(&market).unwrap();
653 let ts_init = UnixNanos::from(1_000_000_000u64);
654
655 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
656 let ts_event = UnixNanos::from(2_000_000_000u64);
657 let rebuilt =
658 rebuild_instrument_with_tick_size(&instrument, "0.01", ts_event, ts_event).unwrap();
659
660 assert_eq!(rebuilt.id(), instrument.id());
661 assert_eq!(rebuilt.raw_symbol(), instrument.raw_symbol());
662 assert_eq!(rebuilt.size_precision(), instrument.size_precision());
663
664 let orig_bo = match &instrument {
665 InstrumentAny::BinaryOption(b) => b,
666 _ => panic!(),
667 };
668 let new_bo = match &rebuilt {
669 InstrumentAny::BinaryOption(b) => b,
670 _ => panic!(),
671 };
672 assert_eq!(new_bo.outcome, orig_bo.outcome);
673 assert_eq!(new_bo.currency, orig_bo.currency);
674 }
675}