1use jiff::Timestamp;
19use nautilus_core::{Params, UnixNanos};
20use nautilus_model::{
21 enums::{AssetClass, CurrencyType},
22 identifiers::{InstrumentId, Symbol},
23 instruments::{BinaryOption, InstrumentAny},
24 types::{Currency, Price, Quantity},
25};
26use rust_decimal::Decimal;
27use rust_decimal_macros::dec;
28use serde::{Deserialize, Serialize};
29use ustr::Ustr;
30
31use super::models::{CryptoMarketConfig, FeeSchedule, GammaMarket};
32use crate::common::{
33 consts::{POLYMARKET_PRICE_PRECISION, POLYMARKET_VENUE, PUSD},
34 enums::PolymarketOutcome,
35};
36
37const DEFAULT_TICK_SIZE: Decimal = dec!(0.001);
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct PolymarketInstrumentDef {
44 pub symbol: Ustr,
46 pub token_id: Ustr,
48 pub condition_id: Ustr,
50 pub market_id: String,
52 pub question_id: Option<String>,
54 pub outcome: PolymarketOutcome,
56 pub question: String,
58 pub description: Option<String>,
60 pub price_precision: u8,
62 pub tick_size: Decimal,
64 pub min_size: Option<Decimal>,
66 pub maker_fee: Option<Decimal>,
68 pub taker_fee: Option<Decimal>,
70 pub start_date: Option<String>,
72 pub event_start_time: Option<String>,
74 pub end_date: Option<String>,
76 pub active: bool,
78 #[serde(default)]
80 pub closed: bool,
81 pub market_slug: Option<String>,
83 pub neg_risk: Option<bool>,
85 pub resolution_source: Option<String>,
87 pub crypto_market_config: Option<CryptoMarketConfig>,
89 pub fee_schedule: Option<FeeSchedule>,
91 pub game_id: Option<String>,
94}
95
96pub fn parse_gamma_market(market: &GammaMarket) -> anyhow::Result<Vec<PolymarketInstrumentDef>> {
101 let game_id = market.game_id.clone().or_else(|| {
102 market
103 .events
104 .as_ref()?
105 .iter()
106 .find_map(|event| event.game_id.clone())
107 });
108
109 let token_ids: Vec<String> = serde_json::from_str(&market.clob_token_ids).map_err(|e| {
110 anyhow::anyhow!(
111 "Failed to parse clob_token_ids '{}': {e}",
112 market.clob_token_ids
113 )
114 })?;
115
116 if token_ids.len() != 2 {
117 anyhow::bail!("Expected 2 token IDs, received {}", token_ids.len());
118 }
119
120 let outcomes: Vec<String> = serde_json::from_str(&market.outcomes)
121 .map_err(|e| anyhow::anyhow!("Failed to parse outcomes '{}': {e}", market.outcomes))?;
122
123 if outcomes.len() != 2 {
124 anyhow::bail!("Expected 2 outcomes, received {}", outcomes.len());
125 }
126
127 let tick_size = market
128 .order_price_min_tick_size
129 .unwrap_or(DEFAULT_TICK_SIZE);
130 let price_precision = POLYMARKET_PRICE_PRECISION;
131
132 let maker_fee: Option<Decimal> = market.fee_schedule.as_ref().map(|_| Decimal::ZERO);
136 let taker_fee: Option<Decimal> = market.fee_schedule.as_ref().map(|fs| fs.rate);
137
138 let min_size = market.order_min_size;
139
140 let active = market.active.unwrap_or(false)
141 && !market.closed.unwrap_or(false)
142 && market.accepting_orders.unwrap_or(false);
143
144 let neg_risk = market.neg_risk;
145
146 let mut defs = Vec::with_capacity(2);
147
148 for (token_id, outcome_label) in token_ids.iter().zip(outcomes.iter()) {
149 let outcome = PolymarketOutcome::from(outcome_label.as_str());
150
151 let symbol_str = format!("{}-{token_id}", market.condition_id);
152
153 defs.push(PolymarketInstrumentDef {
154 symbol: Ustr::from(&symbol_str),
155 token_id: Ustr::from(token_id.as_str()),
156 condition_id: Ustr::from(market.condition_id.as_str()),
157 market_id: market.id.clone(),
158 question_id: market.question_id.clone(),
159 outcome,
160 question: market.question.clone(),
161 description: market.description.clone(),
162 price_precision,
163 tick_size,
164 min_size,
165 maker_fee,
166 taker_fee,
167 start_date: market.start_date.clone(),
168 event_start_time: market.event_start_time.clone(),
169 end_date: market.end_date.clone(),
170 active,
171 closed: market.closed.unwrap_or(false),
172 market_slug: market.market_slug.clone(),
173 neg_risk,
174 resolution_source: market.resolution_source.clone(),
175 crypto_market_config: market.crypto_market_config.clone(),
176 fee_schedule: market.fee_schedule.clone(),
177 game_id: game_id.clone(),
178 });
179 }
180
181 Ok(defs)
182}
183
184pub fn create_instrument_from_def(
186 def: &PolymarketInstrumentDef,
187 ts_init: UnixNanos,
188) -> anyhow::Result<InstrumentAny> {
189 let symbol = Symbol::new(def.symbol);
190 let venue = *POLYMARKET_VENUE;
191 let instrument_id = InstrumentId::new(symbol, venue);
192 let raw_symbol = Symbol::new(def.token_id);
193 let currency = get_currency(PUSD);
194
195 let size_increment = Quantity::from("0.000001");
196
197 let activation_ns = def
198 .start_date
199 .as_deref()
200 .and_then(parse_datetime_to_nanos)
201 .unwrap_or_default();
202 let expiration_ns = def
203 .end_date
204 .as_deref()
205 .and_then(parse_datetime_to_nanos)
206 .unwrap_or_default();
207
208 let (min_price, max_price) = tick_relative_price_bounds(def.tick_size)?;
212 let price_increment = min_price;
213
214 let min_quantity: Option<Quantity> = None;
218
219 let info: Params = serde_json::from_value(build_info_json(def))?;
220
221 let binary_option = BinaryOption::builder()
222 .instrument_id(instrument_id)
223 .raw_symbol(raw_symbol)
224 .asset_class(AssetClass::Alternative)
225 .currency(currency)
226 .activation_ns(activation_ns)
227 .expiration_ns(expiration_ns)
228 .price_precision(POLYMARKET_PRICE_PRECISION)
229 .size_precision(6)
231 .price_increment(price_increment)
232 .size_increment(size_increment)
233 .outcome(def.outcome.inner())
234 .description(Ustr::from(def.question.as_str()))
235 .maybe_min_quantity(min_quantity)
236 .max_price(max_price)
237 .min_price(min_price)
238 .maybe_maker_fee(def.maker_fee)
239 .maybe_taker_fee(def.taker_fee)
240 .info(info)
241 .ts_event(ts_init)
242 .ts_init(ts_init)
243 .build()?;
244
245 Ok(InstrumentAny::BinaryOption(binary_option))
246}
247
248#[must_use]
250pub fn instruments_from_defs(
251 defs: &[PolymarketInstrumentDef],
252 ts_init: UnixNanos,
253) -> Vec<InstrumentAny> {
254 defs.iter()
255 .filter_map(|def| {
256 create_instrument_from_def(def, ts_init)
257 .map_err(|e| log::warn!("Failed to create instrument {}: {e}", def.symbol))
258 .ok()
259 })
260 .collect()
261}
262
263pub fn rebuild_instrument_with_tick_size(
267 existing: &InstrumentAny,
268 new_tick_size: &str,
269 ts_event: UnixNanos,
270 ts_init: UnixNanos,
271) -> anyhow::Result<InstrumentAny> {
272 let bo = match existing {
273 InstrumentAny::BinaryOption(b) => b,
274 other => anyhow::bail!("Expected BinaryOption, was {other:?}"),
275 };
276
277 let tick_size: Decimal = new_tick_size
278 .parse()
279 .map_err(|e| anyhow::anyhow!("Failed to parse tick size '{new_tick_size}': {e}"))?;
280 let (min_price, max_price) = tick_relative_price_bounds(tick_size)?;
281 let price_increment = min_price;
282
283 let rebuilt = BinaryOption::builder()
284 .instrument_id(bo.id)
285 .raw_symbol(bo.raw_symbol)
286 .asset_class(bo.asset_class)
287 .currency(bo.currency)
288 .activation_ns(bo.activation_ns)
289 .expiration_ns(bo.expiration_ns)
290 .price_precision(POLYMARKET_PRICE_PRECISION)
291 .size_precision(bo.size_precision)
292 .price_increment(price_increment)
293 .size_increment(bo.size_increment)
294 .maybe_outcome(bo.outcome)
295 .maybe_description(bo.description)
296 .maybe_max_quantity(bo.max_quantity)
297 .maybe_max_notional(bo.max_notional)
299 .maybe_min_notional(bo.min_notional)
300 .max_price(max_price)
301 .min_price(min_price)
302 .margin_init(bo.margin_init)
303 .margin_maint(bo.margin_maint)
304 .maker_fee(bo.maker_fee)
305 .taker_fee(bo.taker_fee)
306 .maybe_info(bo.info.clone())
307 .ts_event(ts_event)
308 .ts_init(ts_init)
309 .build()?;
310
311 Ok(InstrumentAny::BinaryOption(rebuilt))
312}
313
314pub(crate) fn tick_relative_price_bounds(tick_size: Decimal) -> anyhow::Result<(Price, Price)> {
317 anyhow::ensure!(
318 tick_size > Decimal::ZERO,
319 "Tick size {tick_size} must be positive"
320 );
321
322 let min_price = Price::from_decimal_dp(tick_size, POLYMARKET_PRICE_PRECISION)?;
323 let max_price = Price::from_decimal_dp(Decimal::ONE - tick_size, POLYMARKET_PRICE_PRECISION)?;
324
325 anyhow::ensure!(
326 min_price.as_decimal() == tick_size,
327 "Tick size {tick_size} is not exactly representable at Polymarket price precision {POLYMARKET_PRICE_PRECISION}"
328 );
329 Ok((min_price, max_price))
330}
331
332fn build_info_json(def: &PolymarketInstrumentDef) -> serde_json::Value {
333 let mut map = serde_json::Map::new();
334 map.insert(
335 "token_id".to_string(),
336 serde_json::Value::String(def.token_id.to_string()),
337 );
338 map.insert(
339 "condition_id".to_string(),
340 serde_json::Value::String(def.condition_id.to_string()),
341 );
342 map.insert(
343 "market_id".to_string(),
344 serde_json::Value::String(def.market_id.clone()),
345 );
346
347 if let Some(qid) = &def.question_id {
348 map.insert(
349 "question_id".to_string(),
350 serde_json::Value::String(qid.clone()),
351 );
352 }
353
354 if let Some(slug) = &def.market_slug {
355 map.insert(
356 "market_slug".to_string(),
357 serde_json::Value::String(slug.clone()),
358 );
359 }
360
361 if let Some(description) = &def.description {
362 map.insert(
363 "description".to_string(),
364 serde_json::Value::String(description.clone()),
365 );
366 }
367
368 if let Some(event_start_time) = &def.event_start_time {
369 map.insert(
370 "event_start_time".to_string(),
371 serde_json::Value::String(event_start_time.clone()),
372 );
373 }
374
375 if let Some(end_date) = &def.end_date {
376 map.insert(
377 "end_date".to_string(),
378 serde_json::Value::String(end_date.clone()),
379 );
380 }
381
382 if let Some(neg_risk) = def.neg_risk {
383 map.insert("neg_risk".to_string(), serde_json::Value::Bool(neg_risk));
384 }
385
386 if let Some(resolution_source) = &def.resolution_source {
387 map.insert(
388 "resolution_source".to_string(),
389 serde_json::Value::String(resolution_source.clone()),
390 );
391 }
392
393 if let Some(crypto_market_config) = &def.crypto_market_config
394 && let Ok(value) = serde_json::to_value(crypto_market_config)
395 {
396 map.insert("crypto_market_config".to_string(), value);
397 }
398
399 if let Some(min_size) = def.min_size {
400 map.insert(
401 "min_order_size".to_string(),
402 serde_json::Value::String(min_size.to_string()),
403 );
404 }
405
406 if let Some(fee_schedule) = &def.fee_schedule
407 && let Ok(value) = serde_json::to_value(fee_schedule)
408 {
409 map.insert("fee_schedule".to_string(), value);
410 }
411
412 if let Some(game_id) = &def.game_id {
413 map.insert(
414 "game_id".to_string(),
415 serde_json::Value::String(game_id.clone()),
416 );
417 }
418
419 serde_json::Value::Object(map)
420}
421
422fn get_currency(code: &str) -> Currency {
423 Currency::try_from_str(code).unwrap_or_else(|| {
424 let currency = Currency::new(code, 6, 0, code, CurrencyType::Crypto);
425 if let Err(e) = Currency::register(currency, false) {
426 log::error!("Failed to register currency '{code}': {e}");
427 }
428 currency
429 })
430}
431
432fn parse_datetime_to_nanos(s: &str) -> Option<UnixNanos> {
433 s.parse::<Timestamp>()
434 .ok()
435 .and_then(|dt| u64::try_from(dt.as_nanosecond()).ok())
436 .map(UnixNanos::from)
437}
438
439#[cfg(test)]
440mod tests {
441 use nautilus_core::UUID4;
442 use nautilus_model::{
443 enums::{OrderSide, TimeInForce},
444 identifiers::{ClientOrderId, StrategyId, TraderId},
445 instruments::Instrument,
446 orders::{LimitOrder, OrderAny},
447 };
448 use rstest::rstest;
449 use rust_decimal_macros::dec;
450
451 use super::*;
452 use crate::execution::order_builder::PolymarketOrderBuilder;
453
454 const UNSUPPORTED_TICK_SIZE: &str = "0.0000000000000000001";
455
456 fn load_gamma_market(filename: &str) -> GammaMarket {
457 let path = format!("test_data/{filename}");
458 let content = std::fs::read_to_string(path).expect("Failed to read test data");
459 serde_json::from_str(&content).expect("Failed to parse test data")
460 }
461
462 fn limit_order_at(price: Price) -> OrderAny {
463 OrderAny::Limit(LimitOrder::new(
464 TraderId::from("TESTER-001"),
465 StrategyId::from("S-001"),
466 InstrumentId::from("TEST.POLYMARKET"),
467 ClientOrderId::from("O-001"),
468 OrderSide::Buy,
469 Quantity::from("10"),
470 price,
471 TimeInForce::Gtc,
472 None,
473 false,
474 false,
475 false,
476 None,
477 None,
478 None,
479 None,
480 None,
481 None,
482 None,
483 None,
484 None,
485 None,
486 None,
487 UUID4::new(),
488 UnixNanos::default(),
489 ))
490 }
491
492 #[rstest]
493 fn test_parse_gamma_market_produces_two_defs() {
494 let market = load_gamma_market("gamma_market.json");
495 let defs = parse_gamma_market(&market).unwrap();
496
497 assert_eq!(defs.len(), 2);
498 assert_eq!(defs[0].outcome, PolymarketOutcome::from("Up"));
499 assert_eq!(defs[1].outcome, PolymarketOutcome::from("Down"));
500 }
501
502 #[rstest]
503 fn test_parse_gamma_market_fields() {
504 let market = load_gamma_market("gamma_market.json");
505 let defs = parse_gamma_market(&market).unwrap();
506 let yes_def = &defs[0];
507
508 assert_eq!(
509 yes_def.condition_id.as_str(),
510 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b"
511 );
512 assert_eq!(yes_def.market_id, "1557558");
513 assert_eq!(
514 yes_def.question_id.as_deref(),
515 Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
516 );
517 assert_eq!(
518 yes_def.question,
519 "Bitcoin Up or Down - March 12, 5:20AM-5:25AM ET"
520 );
521 assert_eq!(yes_def.tick_size, dec!(0.01));
522 assert_eq!(yes_def.price_precision, POLYMARKET_PRICE_PRECISION);
523 assert_eq!(yes_def.min_size, Some(dec!(5.0)));
524 assert!(yes_def.maker_fee.is_none());
525 assert!(yes_def.taker_fee.is_none());
526 assert!(yes_def.active);
527 assert_eq!(
528 yes_def.market_slug.as_deref(),
529 Some("btc-updown-5m-1773307200")
530 );
531 assert_eq!(yes_def.game_id, None);
532 }
533
534 #[rstest]
535 fn test_parse_gamma_market_sports_game_id_and_fee_schedule() {
536 let money_line = load_gamma_market("gamma_market_sports_market_money_line.json");
537 let map_handicap = load_gamma_market("gamma_market_sports_market_map_handicap.json");
538
539 let money_line_defs = parse_gamma_market(&money_line).unwrap();
540 let map_handicap_defs = parse_gamma_market(&map_handicap).unwrap();
541
542 assert_eq!(money_line_defs[0].game_id.as_deref(), Some("1427074"));
543 assert_eq!(map_handicap_defs[0].game_id.as_deref(), Some("1427074"));
544 assert_eq!(money_line_defs[0].fee_schedule, money_line.fee_schedule);
545 assert_eq!(map_handicap_defs[0].fee_schedule, map_handicap.fee_schedule);
546
547 assert_eq!(money_line_defs[0].maker_fee, Some(Decimal::ZERO));
549 assert_eq!(money_line_defs[0].taker_fee, Some(dec!(0.03)));
551 }
552
553 #[rstest]
554 fn test_parse_gamma_market_symbol_format() {
555 let market = load_gamma_market("gamma_market.json");
556 let defs = parse_gamma_market(&market).unwrap();
557
558 assert_eq!(
559 defs[0].symbol.as_str(),
560 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875"
561 );
562 assert_eq!(
563 defs[1].symbol.as_str(),
564 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-71183960810705820955071415844881728181970340514894896943812046065452395013351"
565 );
566 }
567
568 #[rstest]
569 fn test_parse_gamma_market_token_ids() {
570 let market = load_gamma_market("gamma_market.json");
571 let defs = parse_gamma_market(&market).unwrap();
572
573 assert_eq!(
574 defs[0].token_id.as_str(),
575 "104239898038807136052399800151408521467737075933964991162589336683346093173875"
576 );
577 assert_eq!(
578 defs[1].token_id.as_str(),
579 "71183960810705820955071415844881728181970340514894896943812046065452395013351"
580 );
581 }
582
583 #[rstest]
584 fn test_parse_gamma_market_derives_outcome_from_label() {
585 let mut market = load_gamma_market("gamma_market.json");
586
587 market.outcomes = r#"["No", "Yes"]"#.to_string();
589
590 let defs = parse_gamma_market(&market).unwrap();
591
592 assert_eq!(defs[0].outcome, PolymarketOutcome::no());
593 assert_eq!(defs[1].outcome, PolymarketOutcome::yes());
594 }
595
596 #[rstest]
597 fn test_parse_gamma_market_accepts_arbitrary_outcome_label() {
598 let mut market = load_gamma_market("gamma_market.json");
599 market.outcomes = r#"["Maybe", "No"]"#.to_string();
600
601 let defs = parse_gamma_market(&market).unwrap();
602
603 assert_eq!(defs[0].outcome, PolymarketOutcome::from("Maybe"));
604 assert_eq!(defs[1].outcome, PolymarketOutcome::no());
605 }
606
607 #[rstest]
608 fn test_parse_gamma_market_null_tick_size_uses_default() {
609 let mut market = load_gamma_market("gamma_market.json");
610 market.order_price_min_tick_size = None;
611
612 let defs = parse_gamma_market(&market).unwrap();
613
614 assert_eq!(defs[0].tick_size, dec!(0.001));
615 assert_eq!(defs[0].price_precision, POLYMARKET_PRICE_PRECISION);
616 }
617
618 #[rstest]
619 fn test_parse_gamma_market_closed_is_inactive() {
620 let mut market = load_gamma_market("gamma_market.json");
621 market.closed = Some(true);
622
623 let defs = parse_gamma_market(&market).unwrap();
624
625 assert!(!defs[0].active);
626 assert!(!defs[1].active);
627 }
628
629 #[rstest]
630 fn test_create_instrument_from_def() {
631 let market = load_gamma_market("gamma_market.json");
632 let defs = parse_gamma_market(&market).unwrap();
633 let ts_init = UnixNanos::from(1_000_000_000u64);
634
635 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
636
637 let binary = match &instrument {
638 InstrumentAny::BinaryOption(b) => b,
639 other => panic!("Expected BinaryOption, was {other:?}"),
640 };
641
642 assert_eq!(
643 binary.id.to_string(),
644 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875.POLYMARKET"
645 );
646 assert_eq!(binary.outcome, Some(Ustr::from("Up")));
647 assert_eq!(binary.asset_class, AssetClass::Alternative);
648 assert_eq!(binary.currency.code.as_str(), "pUSD");
649 assert_eq!(binary.price_precision, POLYMARKET_PRICE_PRECISION);
650 assert_eq!(binary.size_precision, 6);
651 assert_eq!(binary.price_increment(), Price::from("0.01"));
652 assert_eq!(binary.size_increment(), Quantity::from("0.000001"));
653 }
654
655 #[rstest]
656 fn test_create_instrument_info_params() {
657 let market = load_gamma_market("gamma_market.json");
658 let defs = parse_gamma_market(&market).unwrap();
659 let ts_init = UnixNanos::from(1_000_000_000u64);
660
661 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
662
663 let binary = match &instrument {
664 InstrumentAny::BinaryOption(b) => b,
665 other => panic!("Expected BinaryOption, was {other:?}"),
666 };
667
668 let info = binary.info.as_ref().expect("info should be Some");
669 assert_eq!(
670 info.get_str("token_id"),
671 Some("104239898038807136052399800151408521467737075933964991162589336683346093173875")
672 );
673 assert_eq!(
674 info.get_str("condition_id"),
675 Some("0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b")
676 );
677 assert_eq!(info.get_str("market_id"), Some("1557558"));
678 assert_eq!(
679 info.get_str("question_id"),
680 Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
681 );
682 assert_eq!(
683 info.get_str("market_slug"),
684 Some("btc-updown-5m-1773307200")
685 );
686 assert_eq!(
687 info.get_str("event_start_time"),
688 Some("2026-03-12T09:20:00Z")
689 );
690 assert_eq!(info.get_str("game_id"), None);
691 assert_eq!(info.get_str("min_order_size"), Some("5"));
692 assert_eq!(info.get_bool("neg_risk"), Some(false));
693 assert_eq!(info.get("fee_schedule"), None);
694 }
695
696 #[rstest]
697 #[case(
698 Some("Detailed resolution rules with https://example.com/source"),
699 Some("Detailed resolution rules with https://example.com/source")
700 )]
701 #[case(None, None)]
702 fn test_create_instrument_info_description(
703 #[case] description: Option<&str>,
704 #[case] expected: Option<&str>,
705 ) {
706 let mut market = load_gamma_market("gamma_market.json");
707 market.description = description.map(str::to_string);
708 market.resolution_source = None;
709 let defs = parse_gamma_market(&market).unwrap();
710
711 let instrument =
712 create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
713 let InstrumentAny::BinaryOption(binary) = instrument else {
714 panic!("Expected BinaryOption");
715 };
716 let info = binary.info.as_ref().expect("info should be Some");
717
718 assert_eq!(info.get_str("description"), expected);
719 assert_eq!(info.contains_key("description"), expected.is_some());
720 assert_eq!(info.get_str("resolution_source"), None);
721 }
722
723 #[rstest]
724 fn test_create_instrument_info_includes_resolution_and_crypto_market_config() {
725 let market = load_gamma_market("gamma_market_crypto_twap.json");
726
727 let defs = parse_gamma_market(&market).unwrap();
728 assert_eq!(defs[0].end_date.as_deref(), Some("2026-08-22T16:05:00Z"));
729 let instrument =
730 create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
731 let InstrumentAny::BinaryOption(binary) = instrument else {
732 panic!("Expected BinaryOption");
733 };
734 let info = binary.info.as_ref().expect("info should be Some");
735
736 assert_eq!(info.get_str("end_date"), Some("2026-08-22T16:05:00Z"));
737 assert_eq!(
738 info.get_str("resolution_source"),
739 Some("https://data.chain.link/streams/btc-usd-twap-60s-streams")
740 );
741 assert_eq!(
742 info.get("crypto_market_config"),
743 Some(&serde_json::json!({
744 "id": "btc-5m-twap-60",
745 "asset": "btc",
746 "duration": "5m",
747 "twapEnabled": true,
748 "twapLookbackSeconds": 60,
749 }))
750 );
751 }
752
753 #[rstest]
754 fn test_create_instrument_info_omits_missing_neg_risk() {
755 let mut market = load_gamma_market("gamma_market.json");
756 market.neg_risk = None;
757 let defs = parse_gamma_market(&market).unwrap();
758
759 let instrument =
760 create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
761 let InstrumentAny::BinaryOption(binary) = instrument else {
762 panic!("Expected BinaryOption");
763 };
764 let info = binary.info.as_ref().expect("info should be Some");
765
766 assert_eq!(info.get_bool("neg_risk"), None);
767 }
768
769 #[rstest]
770 fn test_past_end_market_carries_closure_state_on_the_definition_only() {
771 let mut market = load_gamma_market("gamma_market_past_end_date_open.json");
772 let defs = parse_gamma_market(&market).unwrap();
773
774 assert!(!defs[0].closed);
775
776 market.closed = Some(true);
777 let closed_defs = parse_gamma_market(&market).unwrap();
778
779 assert!(closed_defs[0].closed);
780
781 for def in [&defs[0], &closed_defs[0]] {
784 let instrument =
785 create_instrument_from_def(def, UnixNanos::from(1_000_000_000u64)).unwrap();
786 let binary = match &instrument {
787 InstrumentAny::BinaryOption(binary) => binary,
788 other => panic!("Expected BinaryOption, was {other:?}"),
789 };
790 let info = binary.info.as_ref().expect("info should be present");
791
792 assert_eq!(info.get_bool("closed"), None);
793 }
794 }
795
796 #[rstest]
797 fn test_create_instrument_info_params_includes_game_id_and_fee_schedule() {
798 let market = load_gamma_market("gamma_market_sports_market_money_line.json");
799 let defs = parse_gamma_market(&market).unwrap();
800 let ts_init = UnixNanos::from(1_000_000_000u64);
801
802 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
803
804 let binary = match &instrument {
805 InstrumentAny::BinaryOption(b) => b,
806 other => panic!("Expected BinaryOption, was {other:?}"),
807 };
808
809 let info = binary.info.as_ref().expect("info should be Some");
810 assert_eq!(info.get_str("game_id"), Some("1427074"));
811 assert!(info.get("fee_schedule").is_some());
812 }
813
814 #[rstest]
815 fn test_instruments_from_defs_batch() {
816 let market = load_gamma_market("gamma_market.json");
817 let defs = parse_gamma_market(&market).unwrap();
818 let ts_init = UnixNanos::from(1_000_000_000u64);
819
820 let instruments = instruments_from_defs(&defs, ts_init);
821
822 assert_eq!(instruments.len(), 2);
823 }
824
825 #[rstest]
826 fn test_instruments_from_defs_skips_unsupported_tick_precision() {
827 let mut market = load_gamma_market("gamma_market.json");
828 let valid_defs = parse_gamma_market(&market).unwrap();
829 market.order_price_min_tick_size = Some(UNSUPPORTED_TICK_SIZE.parse().unwrap());
830 let invalid_defs = parse_gamma_market(&market).unwrap();
831 let ts_init = UnixNanos::from(1_000_000_000u64);
832 let expected = create_instrument_from_def(&valid_defs[1], ts_init).unwrap();
833 let defs = [invalid_defs[0].clone(), valid_defs[1].clone()];
834
835 let instruments = instruments_from_defs(&defs, ts_init);
836
837 assert_eq!(instruments.len(), 1);
838 assert_eq!(instruments[0].id(), expected.id());
839 assert_eq!(instruments[0].price_increment(), expected.price_increment());
840 assert_eq!(instruments[0].min_price(), expected.min_price());
841 assert_eq!(instruments[0].max_price(), expected.max_price());
842 }
843
844 #[rstest]
845 #[case::zero("0")]
846 #[case::negative("-0.005")]
847 fn test_tick_relative_price_bounds_rejects_non_positive(#[case] tick_size: &str) {
848 let tick_size: Decimal = tick_size.parse().unwrap();
849
850 let error = tick_relative_price_bounds(tick_size).unwrap_err();
851
852 assert_eq!(
853 error.to_string(),
854 format!("Tick size {tick_size} must be positive")
855 );
856 }
857
858 #[rstest]
859 #[case("0.1", "0.1", "0.9", 1)]
860 #[case("0.01", "0.01", "0.99", 2)]
861 #[case("0.005", "0.005", "0.995", 3)]
862 #[case("0.0025", "0.0025", "0.9975", 4)]
863 #[case("0.001", "0.001", "0.999", 3)]
864 #[case("0.0001", "0.0001", "0.9999", 4)]
865 fn test_create_instrument_tick_relative_price_bounds(
866 #[case] tick_size: &str,
867 #[case] expected_min: &str,
868 #[case] expected_max: &str,
869 #[case] expected_tick_decimals: u8,
870 ) {
871 let mut market = load_gamma_market("gamma_market.json");
872 market.order_price_min_tick_size = Some(tick_size.parse().unwrap());
873 let defs = parse_gamma_market(&market).unwrap();
874 let ts_init = UnixNanos::from(1_000_000_000u64);
875
876 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
877
878 let binary = match &instrument {
879 InstrumentAny::BinaryOption(b) => b,
880 other => panic!("Expected BinaryOption, was {other:?}"),
881 };
882
883 assert_eq!(binary.price_precision, POLYMARKET_PRICE_PRECISION);
884 assert_eq!(binary.price_increment.precision, POLYMARKET_PRICE_PRECISION);
885 assert_eq!(
886 binary.min_price.unwrap().precision,
887 POLYMARKET_PRICE_PRECISION
888 );
889 assert_eq!(
890 binary.max_price.unwrap().precision,
891 POLYMARKET_PRICE_PRECISION
892 );
893 assert_eq!(
894 binary.min_price_increment_precision(),
895 expected_tick_decimals
896 );
897 assert_eq!(binary.min_price, Some(Price::from(expected_min)));
898 assert_eq!(binary.max_price, Some(Price::from(expected_max)));
899 assert_eq!(binary.min_price, Some(binary.price_increment));
901 }
902
903 #[rstest]
904 fn test_instrument_bounds_agree_with_execution_validation() {
905 let mut market = load_gamma_market("gamma_market.json");
909 market.order_price_min_tick_size = Some(dec!(0.01));
910 let defs = parse_gamma_market(&market).unwrap();
911 let ts_init = UnixNanos::from(1_000_000_000u64);
912
913 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
914 let tick = instrument.price_increment();
915 let min_price = instrument.min_price().expect("min_price");
916 let max_price = instrument.max_price().expect("max_price");
917
918 assert_eq!(min_price, Price::from("0.01"));
919 assert_eq!(max_price, Price::from("0.99"));
920
921 let at_min = limit_order_at(min_price);
922 let at_max = limit_order_at(max_price);
923 assert!(PolymarketOrderBuilder::validate_limit_price(&at_min, tick).is_ok());
924 assert!(PolymarketOrderBuilder::validate_limit_price(&at_max, tick).is_ok());
925
926 let at_old_min = limit_order_at(Price::from("0.001"));
927 let at_old_max = limit_order_at(Price::from("0.999"));
928 assert!(PolymarketOrderBuilder::validate_limit_price(&at_old_min, tick).is_err());
929 assert!(PolymarketOrderBuilder::validate_limit_price(&at_old_max, tick).is_err());
930 }
931
932 #[rstest]
933 fn test_rebuild_instrument_with_tick_size() {
934 let market = load_gamma_market("gamma_market.json");
935 let defs = parse_gamma_market(&market).unwrap();
936 let ts_init = UnixNanos::from(1_000_000_000u64);
937
938 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
940 assert_eq!(instrument.price_precision(), POLYMARKET_PRICE_PRECISION);
941
942 let ts_event = UnixNanos::from(2_000_000_000u64);
943 let rebuilt =
944 rebuild_instrument_with_tick_size(&instrument, "0.001", ts_event, ts_event).unwrap();
945
946 assert_eq!(rebuilt.price_precision(), POLYMARKET_PRICE_PRECISION);
947 assert_eq!(
948 rebuilt.price_increment().precision,
949 POLYMARKET_PRICE_PRECISION
950 );
951 assert_eq!(rebuilt.min_price_increment_precision(), 3);
952 assert_eq!(rebuilt.price_increment(), Price::from("0.001"));
953 assert_eq!(rebuilt.min_price(), Some(Price::from("0.001")));
955 assert_eq!(rebuilt.max_price(), Some(Price::from("0.999")));
956 }
957
958 #[rstest]
959 #[case("0.005", "0.0025", 4, "0.9975")]
960 #[case("0.0025", "0.005", 3, "0.995")]
961 fn test_rebuild_instrument_between_non_power_ticks(
962 #[case] old_tick: &str,
963 #[case] new_tick: &str,
964 #[case] expected_tick_decimals: u8,
965 #[case] expected_max: &str,
966 ) {
967 let market = load_gamma_market("gamma_market.json");
968 let defs = parse_gamma_market(&market).unwrap();
969 let ts_init = UnixNanos::from(1_000_000_000u64);
970 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
971 let instrument =
972 rebuild_instrument_with_tick_size(&instrument, old_tick, ts_init, ts_init).unwrap();
973
974 let rebuilt =
975 rebuild_instrument_with_tick_size(&instrument, new_tick, ts_init, ts_init).unwrap();
976
977 assert_eq!(instrument.price_increment(), Price::from(old_tick));
978 assert_eq!(rebuilt.price_precision(), POLYMARKET_PRICE_PRECISION);
979 assert_eq!(
980 rebuilt.price_increment().precision,
981 POLYMARKET_PRICE_PRECISION
982 );
983 assert_eq!(
984 rebuilt.min_price_increment_precision(),
985 expected_tick_decimals
986 );
987 assert_eq!(rebuilt.price_increment(), Price::from(new_tick));
988 assert_eq!(rebuilt.min_price(), Some(Price::from(new_tick)));
989 assert_eq!(rebuilt.max_price(), Some(Price::from(expected_max)));
990 }
991
992 #[rstest]
993 fn test_rebuild_instrument_with_tick_size_rejects_unsupported_tick_precision() {
994 let market = load_gamma_market("gamma_market.json");
995 let defs = parse_gamma_market(&market).unwrap();
996 let ts_init = UnixNanos::from(1_000_000_000u64);
997 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
998 let error =
999 rebuild_instrument_with_tick_size(&instrument, UNSUPPORTED_TICK_SIZE, ts_init, ts_init)
1000 .unwrap_err();
1001
1002 assert_eq!(
1003 error.to_string(),
1004 format!(
1005 "Tick size {UNSUPPORTED_TICK_SIZE} is not exactly representable at Polymarket price precision {POLYMARKET_PRICE_PRECISION}"
1006 )
1007 );
1008 }
1009
1010 #[rstest]
1011 fn test_rebuild_instrument_preserves_fields() {
1012 let market = load_gamma_market("gamma_market.json");
1013 let defs = parse_gamma_market(&market).unwrap();
1014 let ts_init = UnixNanos::from(1_000_000_000u64);
1015
1016 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
1017 let ts_event = UnixNanos::from(2_000_000_000u64);
1018 let rebuilt =
1019 rebuild_instrument_with_tick_size(&instrument, "0.01", ts_event, ts_event).unwrap();
1020
1021 assert_eq!(rebuilt.id(), instrument.id());
1022 assert_eq!(rebuilt.raw_symbol(), instrument.raw_symbol());
1023 assert_eq!(rebuilt.size_precision(), instrument.size_precision());
1024
1025 let orig_bo = match &instrument {
1026 InstrumentAny::BinaryOption(b) => b,
1027 _ => panic!(),
1028 };
1029 let new_bo = match &rebuilt {
1030 InstrumentAny::BinaryOption(b) => b,
1031 _ => panic!(),
1032 };
1033 assert_eq!(new_bo.outcome, orig_bo.outcome);
1034 assert_eq!(new_bo.currency, orig_bo.currency);
1035 }
1036}