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, GammaTag};
32use crate::common::{
33 consts::{POLYMARKET_PRICE_PRECISION, POLYMARKET_VENUE, PUSD},
34 enums::PolymarketOutcome,
35 parse::parse_decimal_exact,
36};
37
38const DEFAULT_TICK_SIZE: Decimal = dec!(0.001);
39
40const REBATE_CRYPTO: Decimal = dec!(0.20);
43const REBATE_SPORTS: Decimal = dec!(0.15);
44const REBATE_STANDARD: Decimal = dec!(0.25);
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct PolymarketInstrumentDef {
51 pub symbol: Ustr,
53 pub token_id: Ustr,
55 pub condition_id: Ustr,
57 pub market_id: String,
59 pub event_id: Option<Ustr>,
61 #[serde(default)]
63 pub gamma_market: String,
64 pub gamma_event: Option<String>,
66 pub question_id: Option<String>,
68 pub outcome: PolymarketOutcome,
70 pub question: String,
72 pub description: Option<String>,
74 pub price_precision: u8,
76 pub tick_size: Decimal,
78 pub min_size: Option<Decimal>,
80 pub maker_fee: Option<Decimal>,
82 pub taker_fee: Option<Decimal>,
84 pub start_date: Option<String>,
86 pub event_start_time: Option<String>,
88 pub end_date: Option<String>,
90 pub active: bool,
92 #[serde(default)]
94 pub closed: bool,
95 pub market_slug: Option<String>,
97 pub neg_risk: Option<bool>,
99 pub resolution_source: Option<String>,
101 pub crypto_market_config: Option<CryptoMarketConfig>,
103 pub fee_schedule: Option<FeeSchedule>,
105 pub game_id: Option<String>,
108}
109
110pub fn parse_gamma_market(market: &GammaMarket) -> anyhow::Result<Vec<PolymarketInstrumentDef>> {
115 let event_id = market_event_id(market);
116
117 let game_id = market.game_id.clone().or_else(|| {
118 market
119 .events
120 .as_ref()?
121 .iter()
122 .find_map(|event| event.game_id.clone())
123 });
124
125 let token_ids: Vec<String> = serde_json::from_str(&market.clob_token_ids).map_err(|e| {
126 anyhow::anyhow!(
127 "Failed to parse clob_token_ids '{}': {e}",
128 market.clob_token_ids
129 )
130 })?;
131
132 if token_ids.len() != 2 {
133 anyhow::bail!("Expected 2 token IDs, received {}", token_ids.len());
134 }
135
136 let outcomes: Vec<String> = serde_json::from_str(&market.outcomes)
137 .map_err(|e| anyhow::anyhow!("Failed to parse outcomes '{}': {e}", market.outcomes))?;
138
139 if outcomes.len() != 2 {
140 anyhow::bail!("Expected 2 outcomes, received {}", outcomes.len());
141 }
142
143 let tick_size = market
144 .order_price_min_tick_size
145 .unwrap_or(DEFAULT_TICK_SIZE);
146 let price_precision = POLYMARKET_PRICE_PRECISION;
147
148 let maker_fee: Option<Decimal> = market.fee_schedule.as_ref().map(|_| Decimal::ZERO);
152 let taker_fee: Option<Decimal> = market.fee_schedule.as_ref().map(|fs| fs.rate);
153
154 let min_size = market.order_min_size;
155
156 let active = market.active.unwrap_or(false)
157 && !market.closed.unwrap_or(false)
158 && market.accepting_orders.unwrap_or(false);
159
160 let neg_risk = market.neg_risk;
161
162 let mut defs = Vec::with_capacity(2);
163
164 for (token_id, outcome_label) in token_ids.iter().zip(outcomes.iter()) {
165 let outcome = PolymarketOutcome::from(outcome_label.as_str());
166
167 let symbol_str = format!("{}-{token_id}", market.condition_id);
168
169 defs.push(PolymarketInstrumentDef {
170 symbol: Ustr::from(&symbol_str),
171 token_id: Ustr::from(token_id.as_str()),
172 condition_id: Ustr::from(market.condition_id.as_str()),
173 market_id: market.id.clone(),
174 event_id,
175 gamma_market: market.raw.clone(),
176 gamma_event: market.parent_event.as_ref().map(|event| event.raw.clone()),
177 question_id: market.question_id.clone(),
178 outcome,
179 question: market.question.clone(),
180 description: market.description.clone(),
181 price_precision,
182 tick_size,
183 min_size,
184 maker_fee,
185 taker_fee,
186 start_date: market.start_date.clone(),
187 event_start_time: market.event_start_time.clone(),
188 end_date: market.end_date.clone(),
189 active,
190 closed: market.closed.unwrap_or(false),
191 market_slug: market.market_slug.clone(),
192 neg_risk,
193 resolution_source: market.resolution_source.clone(),
194 crypto_market_config: market.crypto_market_config.clone(),
195 fee_schedule: market.fee_schedule.clone(),
196 game_id: game_id.clone(),
197 });
198 }
199
200 Ok(defs)
201}
202
203fn market_event_id(market: &GammaMarket) -> Option<Ustr> {
204 if let Some(event) = &market.parent_event {
205 return (!event.id.is_empty()).then(|| Ustr::from(event.id.as_str()));
206 }
207
208 let events = market.events.as_ref()?;
209 let first = events.first()?;
210 if first.id.is_empty() || events.iter().any(|event| event.id != first.id) {
211 log::warn!("Ambiguous Gamma parent event for market {}", market.id);
212 return None;
213 }
214
215 Some(Ustr::from(first.id.as_str()))
216}
217
218#[derive(Clone, Copy, Debug, PartialEq, Eq)]
219enum FeeCategory {
220 Crypto,
221 Sports,
222 Standard,
223 FeeFree,
224}
225
226pub(crate) fn resolve_maker_rebate_rate(market: &GammaMarket) -> Decimal {
230 match classify_market(market) {
231 Some(FeeCategory::Crypto) => REBATE_CRYPTO,
232 Some(FeeCategory::Sports) => REBATE_SPORTS,
233 Some(FeeCategory::Standard) => REBATE_STANDARD,
234 Some(FeeCategory::FeeFree) | None => Decimal::ZERO,
235 }
236}
237
238pub(crate) fn enrich_market_fee_schedule(market: &mut GammaMarket) {
244 let rebate = resolve_maker_rebate_rate(market);
245 match market.fee_schedule.as_mut() {
246 Some(schedule) => {
247 schedule.rebate_rate = rebate;
248 }
249 None => {
250 if rebate.is_zero() {
251 return;
252 }
253
254 market.fee_schedule = Some(FeeSchedule {
255 exponent: Decimal::ONE,
256 rate: Decimal::ZERO,
257 taker_only: true,
258 rebate_rate: rebate,
259 });
260 }
261 }
262}
263
264fn classify_market(market: &GammaMarket) -> Option<FeeCategory> {
265 if market.fees_enabled == Some(false) {
266 return Some(FeeCategory::FeeFree);
267 }
268
269 if let Some(fee_type) = market.fee_type.as_deref()
270 && let Some(category) = classify_fee_type(fee_type)
271 {
272 return Some(category);
273 }
274
275 if market.crypto_market_config.is_some() {
276 return Some(FeeCategory::Crypto);
277 }
278
279 if market.sports_market_type.is_some() || market.game_id.is_some() {
280 return Some(FeeCategory::Sports);
281 }
282
283 let mut candidates = Vec::new();
284
285 if let Some(category) = market.category.as_deref()
286 && let Some(matched) = classify_label(category)
287 {
288 candidates.push(matched);
289 }
290
291 candidates.extend(classify_tags(market.tags.as_deref()));
292
293 if let Some(parent) = market.parent_event.as_ref() {
294 if let Some(category) = parent.category.as_deref()
295 && let Some(matched) = classify_label(category)
296 {
297 candidates.push(matched);
298 }
299
300 candidates.extend(classify_tags(parent.tags.as_deref()));
301 if parent.game_id.is_some() {
302 candidates.push(FeeCategory::Sports);
303 }
304 }
305
306 if let Some(events) = market.events.as_ref() {
307 for event in events {
308 if let Some(category) = event.category.as_deref()
309 && let Some(matched) = classify_label(category)
310 {
311 candidates.push(matched);
312 }
313
314 candidates.extend(classify_tags(event.tags.as_deref()));
315 if event.game_id.is_some() {
316 candidates.push(FeeCategory::Sports);
317 }
318 }
319 }
320
321 let first = candidates.first()?;
322 if candidates.iter().all(|category| category == first) {
323 return Some(*first);
324 }
325
326 None
327}
328
329fn classify_fee_type(fee_type: &str) -> Option<FeeCategory> {
330 let normalized = fee_type.trim().to_lowercase();
331
332 if normalized.contains("crypto") {
333 return Some(FeeCategory::Crypto);
334 }
335
336 if normalized.contains("sport") {
337 return Some(FeeCategory::Sports);
338 }
339
340 if normalized.contains("geopolit") {
341 return Some(FeeCategory::FeeFree);
342 }
343
344 for token in [
345 "finance", "politic", "economic", "culture", "weather", "mention", "tech", "general",
346 "other", "prices",
347 ] {
348 if normalized.contains(token) {
349 return Some(FeeCategory::Standard);
350 }
351 }
352
353 None
354}
355
356fn classify_tags(tags: Option<&[GammaTag]>) -> Vec<FeeCategory> {
357 let mut categories = Vec::new();
358
359 for tag in tags.unwrap_or(&[]) {
360 for value in [tag.label.as_deref(), tag.slug.as_deref()]
361 .into_iter()
362 .flatten()
363 {
364 if let Some(category) = classify_label(value) {
365 categories.push(category);
366 }
367 }
368 }
369
370 categories
371}
372
373fn classify_label(value: &str) -> Option<FeeCategory> {
374 let normalized = value.trim().to_lowercase();
375
376 if normalized.contains("crypto") {
377 return Some(FeeCategory::Crypto);
378 }
379
380 if normalized.contains("sport") || normalized.contains("esport") {
381 return Some(FeeCategory::Sports);
382 }
383
384 if normalized.contains("geopolit") || normalized.contains("world event") {
385 return Some(FeeCategory::FeeFree);
386 }
387
388 for token in [
389 "finance", "politic", "economic", "culture", "weather", "mention", "tech", "general",
390 "other",
391 ] {
392 if normalized.contains(token) {
393 return Some(FeeCategory::Standard);
394 }
395 }
396
397 None
398}
399
400pub fn create_instrument_from_def(
402 def: &PolymarketInstrumentDef,
403 ts_init: UnixNanos,
404) -> anyhow::Result<InstrumentAny> {
405 let symbol = Symbol::new(def.symbol);
406 let venue = *POLYMARKET_VENUE;
407 let instrument_id = InstrumentId::new(symbol, venue);
408 let raw_symbol = Symbol::new(def.token_id);
409 let currency = get_currency(PUSD);
410
411 let size_increment = Quantity::from("0.000001");
412
413 let activation_ns = def
414 .start_date
415 .as_deref()
416 .and_then(parse_datetime_to_nanos)
417 .unwrap_or_default();
418 let expiration_ns = def
419 .end_date
420 .as_deref()
421 .and_then(parse_datetime_to_nanos)
422 .unwrap_or_default();
423
424 let (min_price, max_price) = tick_relative_price_bounds(def.tick_size)?;
428 let price_increment = min_price;
429
430 let min_quantity: Option<Quantity> = None;
434
435 let info: Params = serde_json::from_value(build_info_json(def))?;
436
437 let binary_option = BinaryOption::builder()
438 .instrument_id(instrument_id)
439 .raw_symbol(raw_symbol)
440 .asset_class(AssetClass::Alternative)
441 .currency(currency)
442 .activation_ns(activation_ns)
443 .expiration_ns(expiration_ns)
444 .price_precision(POLYMARKET_PRICE_PRECISION)
445 .size_precision(6)
447 .price_increment(price_increment)
448 .size_increment(size_increment)
449 .maybe_event_id(def.event_id)
450 .outcome(def.outcome.inner())
451 .description(Ustr::from(def.question.as_str()))
452 .maybe_min_quantity(min_quantity)
453 .max_price(max_price)
454 .min_price(min_price)
455 .maybe_maker_fee(def.maker_fee)
456 .maybe_taker_fee(def.taker_fee)
457 .info(info)
458 .ts_event(ts_init)
459 .ts_init(ts_init)
460 .build()?;
461
462 Ok(InstrumentAny::BinaryOption(binary_option))
463}
464
465#[must_use]
467pub fn instruments_from_defs(
468 defs: &[PolymarketInstrumentDef],
469 ts_init: UnixNanos,
470) -> Vec<InstrumentAny> {
471 defs.iter()
472 .filter_map(|def| {
473 create_instrument_from_def(def, ts_init)
474 .map_err(|e| log::warn!("Failed to create instrument {}: {e}", def.symbol))
475 .ok()
476 })
477 .collect()
478}
479
480pub fn rebuild_instrument_with_tick_size(
484 existing: &InstrumentAny,
485 new_tick_size: &str,
486 ts_event: UnixNanos,
487 ts_init: UnixNanos,
488) -> anyhow::Result<InstrumentAny> {
489 let bo = match existing {
490 InstrumentAny::BinaryOption(b) => b,
491 other => anyhow::bail!("Expected BinaryOption, was {other:?}"),
492 };
493
494 let tick_size = parse_decimal_exact(new_tick_size)
495 .map_err(|e| anyhow::anyhow!("Failed to parse tick size '{new_tick_size}': {e}"))?;
496 let (min_price, max_price) = tick_relative_price_bounds(tick_size)?;
497 let price_increment = min_price;
498
499 let rebuilt = BinaryOption::builder()
500 .instrument_id(bo.id)
501 .raw_symbol(bo.raw_symbol)
502 .asset_class(bo.asset_class)
503 .currency(bo.currency)
504 .activation_ns(bo.activation_ns)
505 .expiration_ns(bo.expiration_ns)
506 .price_precision(POLYMARKET_PRICE_PRECISION)
507 .size_precision(bo.size_precision)
508 .price_increment(price_increment)
509 .size_increment(bo.size_increment)
510 .maybe_event_id(bo.event_id)
511 .maybe_outcome(bo.outcome)
512 .maybe_description(bo.description)
513 .maybe_max_quantity(bo.max_quantity)
514 .maybe_max_notional(bo.max_notional)
516 .maybe_min_notional(bo.min_notional)
517 .max_price(max_price)
518 .min_price(min_price)
519 .margin_init(bo.margin_init)
520 .margin_maint(bo.margin_maint)
521 .maker_fee(bo.maker_fee)
522 .taker_fee(bo.taker_fee)
523 .maybe_info(bo.info.clone())
524 .ts_event(ts_event)
525 .ts_init(ts_init)
526 .build()?;
527
528 Ok(InstrumentAny::BinaryOption(rebuilt))
529}
530
531pub(crate) fn tick_relative_price_bounds(tick_size: Decimal) -> anyhow::Result<(Price, Price)> {
534 anyhow::ensure!(
535 tick_size > Decimal::ZERO,
536 "Tick size {tick_size} must be positive"
537 );
538
539 let min_price = Price::from_decimal_dp(tick_size, POLYMARKET_PRICE_PRECISION)?;
540 let max_price = Price::from_decimal_dp(Decimal::ONE - tick_size, POLYMARKET_PRICE_PRECISION)?;
541
542 anyhow::ensure!(
543 min_price.as_decimal() == tick_size,
544 "Tick size {tick_size} is not exactly representable at Polymarket price precision {POLYMARKET_PRICE_PRECISION}"
545 );
546 Ok((min_price, max_price))
547}
548
549fn build_info_json(def: &PolymarketInstrumentDef) -> serde_json::Value {
550 let mut map = serde_json::Map::new();
551 map.insert("gamma_market".to_string(), def.gamma_market.clone().into());
552 if let Some(event) = &def.gamma_event {
553 map.insert("gamma_event".to_string(), event.clone().into());
554 }
555
556 if let Some(event_id) = def.event_id {
557 map.insert("event_id".to_string(), event_id.to_string().into());
558 }
559
560 map.insert(
561 "token_id".to_string(),
562 serde_json::Value::String(def.token_id.to_string()),
563 );
564 map.insert(
565 "condition_id".to_string(),
566 serde_json::Value::String(def.condition_id.to_string()),
567 );
568 map.insert(
569 "market_id".to_string(),
570 serde_json::Value::String(def.market_id.clone()),
571 );
572
573 if let Some(qid) = &def.question_id {
574 map.insert(
575 "question_id".to_string(),
576 serde_json::Value::String(qid.clone()),
577 );
578 }
579
580 if let Some(slug) = &def.market_slug {
581 map.insert(
582 "market_slug".to_string(),
583 serde_json::Value::String(slug.clone()),
584 );
585 }
586
587 if let Some(description) = &def.description {
588 map.insert(
589 "description".to_string(),
590 serde_json::Value::String(description.clone()),
591 );
592 }
593
594 if let Some(event_start_time) = &def.event_start_time {
595 map.insert(
596 "event_start_time".to_string(),
597 serde_json::Value::String(event_start_time.clone()),
598 );
599 }
600
601 if let Some(end_date) = &def.end_date {
602 map.insert(
603 "end_date".to_string(),
604 serde_json::Value::String(end_date.clone()),
605 );
606 }
607
608 if let Some(neg_risk) = def.neg_risk {
609 map.insert("neg_risk".to_string(), serde_json::Value::Bool(neg_risk));
610 }
611
612 if let Some(resolution_source) = &def.resolution_source {
613 map.insert(
614 "resolution_source".to_string(),
615 serde_json::Value::String(resolution_source.clone()),
616 );
617 }
618
619 if let Some(crypto_market_config) = &def.crypto_market_config
620 && let Ok(value) = serde_json::to_value(crypto_market_config)
621 {
622 map.insert("crypto_market_config".to_string(), value);
623 }
624
625 if let Some(min_size) = def.min_size {
626 map.insert(
627 "min_order_size".to_string(),
628 serde_json::Value::String(min_size.to_string()),
629 );
630 }
631
632 if let Some(fee_schedule) = &def.fee_schedule {
633 map.insert("fee_schedule".to_string(), fee_schedule.to_info());
634 }
635
636 if let Some(game_id) = &def.game_id {
637 map.insert(
638 "game_id".to_string(),
639 serde_json::Value::String(game_id.clone()),
640 );
641 }
642
643 serde_json::Value::Object(map)
644}
645
646fn get_currency(code: &str) -> Currency {
647 Currency::try_from_str(code).unwrap_or_else(|| {
648 let currency = Currency::new(code, 6, 0, code, CurrencyType::Crypto);
649 if let Err(e) = Currency::register(currency, false) {
650 log::error!("Failed to register currency '{code}': {e}");
651 }
652 currency
653 })
654}
655
656fn parse_datetime_to_nanos(s: &str) -> Option<UnixNanos> {
657 s.parse::<Timestamp>()
658 .ok()
659 .and_then(|dt| u64::try_from(dt.as_nanosecond()).ok())
660 .map(UnixNanos::from)
661}
662
663#[cfg(test)]
664mod tests {
665 use nautilus_core::UUID4;
666 use nautilus_model::{
667 enums::{OrderSide, TimeInForce},
668 identifiers::{ClientOrderId, StrategyId, TraderId},
669 instruments::Instrument,
670 orders::{LimitOrder, OrderAny},
671 };
672 use rstest::rstest;
673 use rust_decimal_macros::dec;
674
675 use super::*;
676 use crate::execution::order_builder::PolymarketOrderBuilder;
677
678 const UNSUPPORTED_TICK_SIZE: &str = "0.0000000000000000001";
679
680 fn load_gamma_market(filename: &str) -> GammaMarket {
681 let path = format!("test_data/{filename}");
682 let content = std::fs::read_to_string(path).expect("Failed to read test data");
683 serde_json::from_str(&content).expect("Failed to parse test data")
684 }
685
686 fn limit_order_at(price: Price) -> OrderAny {
687 OrderAny::Limit(LimitOrder::new(
688 TraderId::from("TESTER-001"),
689 StrategyId::from("S-001"),
690 InstrumentId::from("TEST.POLYMARKET"),
691 ClientOrderId::from("O-001"),
692 OrderSide::Buy,
693 Quantity::from("10"),
694 price,
695 TimeInForce::Gtc,
696 None,
697 false,
698 false,
699 false,
700 None,
701 None,
702 None,
703 None,
704 None,
705 None,
706 None,
707 None,
708 None,
709 None,
710 None,
711 UUID4::new(),
712 UnixNanos::default(),
713 ))
714 }
715
716 #[rstest]
717 fn test_gamma_metadata_survives_parsing_and_tick_updates() {
718 let raw = include_str!("../../test_data/gamma_market_metadata.json");
719 let expected = raw.trim();
720 let mut market: GammaMarket = serde_json::from_str(raw).unwrap();
721 market.order_min_size = Some(dec!(10));
722 let defs = parse_gamma_market(&market).unwrap();
723 assert_eq!(defs.len(), 2);
724
725 for (def, outcome) in defs.iter().zip(["Yes", "No"]) {
726 let instrument = create_instrument_from_def(def, UnixNanos::default()).unwrap();
727 let rebuilt =
728 rebuild_instrument_with_tick_size(&instrument, "0.001", 1.into(), 2.into())
729 .unwrap();
730
731 for instrument in [instrument, rebuilt] {
732 let InstrumentAny::BinaryOption(binary) = instrument else {
733 unreachable!()
734 };
735
736 let info = binary.info.unwrap();
737 assert_eq!(binary.event_id, Some(Ustr::from("event-456")));
738 assert_eq!(binary.outcome, Some(Ustr::from(outcome)));
739 assert_eq!(info.get_str("event_id"), Some("event-456"));
740 assert_eq!(info.get_str("min_order_size"), Some("10"));
741 assert_eq!(info.get_str("gamma_market"), Some(expected));
742 }
743 }
744 }
745
746 #[rstest]
747 fn test_enclosing_event_takes_precedence_over_linked_events() {
748 let mut market = load_gamma_market("gamma_market_metadata.json");
749 let parent = market.events.as_ref().unwrap()[0].clone();
750 market.events.as_mut().unwrap()[0].id = "related-event".to_string();
751 market.parent_event = Some(std::sync::Arc::new(parent));
752 assert_eq!(
753 market_event_id(&market).map(|id| id.as_str()),
754 Some("event-456")
755 );
756 }
757
758 #[rstest]
759 #[case::missing(None, None)]
760 #[case::empty(Some(vec![]), None)]
761 #[case::unique(Some(vec!["event-1"]), Some("event-1"))]
762 #[case::duplicate(Some(vec!["event-1", "event-1"]), Some("event-1"))]
763 #[case::ambiguous(Some(vec!["event-1", "event-2"]), None)]
764 #[case::blank(Some(vec![""]), None)]
765 fn test_market_event_id(#[case] ids: Option<Vec<&str>>, #[case] expected: Option<&str>) {
766 let mut market = load_gamma_market("gamma_market_metadata.json");
767 let template = market.events.as_ref().unwrap()[0].clone();
768 market.events = ids.map(|ids| {
769 ids.into_iter()
770 .map(|id| {
771 let mut event = template.clone();
772 event.id = id.to_string();
773 event
774 })
775 .collect()
776 });
777
778 assert_eq!(market_event_id(&market).map(|id| id.as_str()), expected);
779 }
780
781 #[rstest]
782 fn test_parse_gamma_market_produces_two_defs() {
783 let market = load_gamma_market("gamma_market.json");
784 let defs = parse_gamma_market(&market).unwrap();
785
786 assert_eq!(defs.len(), 2);
787 assert_eq!(defs[0].outcome, PolymarketOutcome::from("Up"));
788 assert_eq!(defs[1].outcome, PolymarketOutcome::from("Down"));
789 }
790
791 #[rstest]
792 fn test_parse_gamma_market_fields() {
793 let market = load_gamma_market("gamma_market.json");
794 let defs = parse_gamma_market(&market).unwrap();
795 let yes_def = &defs[0];
796
797 assert_eq!(
798 yes_def.condition_id.as_str(),
799 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b"
800 );
801 assert_eq!(yes_def.market_id, "1557558");
802 assert_eq!(
803 yes_def.question_id.as_deref(),
804 Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
805 );
806 assert_eq!(
807 yes_def.question,
808 "Bitcoin Up or Down - March 12, 5:20AM-5:25AM ET"
809 );
810 assert_eq!(yes_def.tick_size, dec!(0.01));
811 assert_eq!(yes_def.price_precision, POLYMARKET_PRICE_PRECISION);
812 assert_eq!(yes_def.min_size, Some(dec!(5.0)));
813 assert!(yes_def.maker_fee.is_none());
814 assert!(yes_def.taker_fee.is_none());
815 assert!(yes_def.active);
816 assert_eq!(
817 yes_def.market_slug.as_deref(),
818 Some("btc-updown-5m-1773307200")
819 );
820 assert_eq!(yes_def.game_id, None);
821 }
822
823 #[rstest]
824 fn test_parse_gamma_market_sports_game_id_and_fee_schedule() {
825 let money_line = load_gamma_market("gamma_market_sports_market_money_line.json");
826 let map_handicap = load_gamma_market("gamma_market_sports_market_map_handicap.json");
827
828 let money_line_defs = parse_gamma_market(&money_line).unwrap();
829 let map_handicap_defs = parse_gamma_market(&map_handicap).unwrap();
830
831 assert_eq!(money_line_defs[0].game_id.as_deref(), Some("1427074"));
832 assert_eq!(map_handicap_defs[0].game_id.as_deref(), Some("1427074"));
833 assert_eq!(money_line_defs[0].fee_schedule, money_line.fee_schedule);
834 assert_eq!(map_handicap_defs[0].fee_schedule, map_handicap.fee_schedule);
835
836 assert_eq!(money_line_defs[0].maker_fee, Some(Decimal::ZERO));
838 assert_eq!(money_line_defs[0].taker_fee, Some(dec!(0.03)));
840 }
841
842 #[rstest]
843 fn test_parse_gamma_market_symbol_format() {
844 let market = load_gamma_market("gamma_market.json");
845 let defs = parse_gamma_market(&market).unwrap();
846
847 assert_eq!(
848 defs[0].symbol.as_str(),
849 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875"
850 );
851 assert_eq!(
852 defs[1].symbol.as_str(),
853 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-71183960810705820955071415844881728181970340514894896943812046065452395013351"
854 );
855 }
856
857 #[rstest]
858 fn test_parse_gamma_market_token_ids() {
859 let market = load_gamma_market("gamma_market.json");
860 let defs = parse_gamma_market(&market).unwrap();
861
862 assert_eq!(
863 defs[0].token_id.as_str(),
864 "104239898038807136052399800151408521467737075933964991162589336683346093173875"
865 );
866 assert_eq!(
867 defs[1].token_id.as_str(),
868 "71183960810705820955071415844881728181970340514894896943812046065452395013351"
869 );
870 }
871
872 #[rstest]
873 fn test_parse_gamma_market_derives_outcome_from_label() {
874 let mut market = load_gamma_market("gamma_market.json");
875
876 market.outcomes = r#"["No", "Yes"]"#.to_string();
878
879 let defs = parse_gamma_market(&market).unwrap();
880
881 assert_eq!(defs[0].outcome, PolymarketOutcome::no());
882 assert_eq!(defs[1].outcome, PolymarketOutcome::yes());
883 }
884
885 #[rstest]
886 fn test_parse_gamma_market_accepts_arbitrary_outcome_label() {
887 let mut market = load_gamma_market("gamma_market.json");
888 market.outcomes = r#"["Maybe", "No"]"#.to_string();
889
890 let defs = parse_gamma_market(&market).unwrap();
891
892 assert_eq!(defs[0].outcome, PolymarketOutcome::from("Maybe"));
893 assert_eq!(defs[1].outcome, PolymarketOutcome::no());
894 }
895
896 #[rstest]
897 fn test_parse_gamma_market_null_tick_size_uses_default() {
898 let mut market = load_gamma_market("gamma_market.json");
899 market.order_price_min_tick_size = None;
900
901 let defs = parse_gamma_market(&market).unwrap();
902
903 assert_eq!(defs[0].tick_size, dec!(0.001));
904 assert_eq!(defs[0].price_precision, POLYMARKET_PRICE_PRECISION);
905 }
906
907 #[rstest]
908 fn test_parse_gamma_market_closed_is_inactive() {
909 let mut market = load_gamma_market("gamma_market.json");
910 market.closed = Some(true);
911
912 let defs = parse_gamma_market(&market).unwrap();
913
914 assert!(!defs[0].active);
915 assert!(!defs[1].active);
916 }
917
918 #[rstest]
919 fn test_create_instrument_from_def() {
920 let market = load_gamma_market("gamma_market.json");
921 let defs = parse_gamma_market(&market).unwrap();
922 let ts_init = UnixNanos::from(1_000_000_000u64);
923
924 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
925
926 let binary = match &instrument {
927 InstrumentAny::BinaryOption(b) => b,
928 other => panic!("Expected BinaryOption, was {other:?}"),
929 };
930
931 assert_eq!(
932 binary.id.to_string(),
933 "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875.POLYMARKET"
934 );
935 assert_eq!(binary.outcome, Some(Ustr::from("Up")));
936 assert_eq!(binary.asset_class, AssetClass::Alternative);
937 assert_eq!(binary.currency.code, "pUSD");
938 assert_eq!(binary.price_precision, POLYMARKET_PRICE_PRECISION);
939 assert_eq!(binary.size_precision, 6);
940 assert_eq!(binary.price_increment(), Price::from("0.01"));
941 assert_eq!(binary.size_increment(), Quantity::from("0.000001"));
942 }
943
944 #[rstest]
945 fn test_create_instrument_info_params() {
946 let market = load_gamma_market("gamma_market.json");
947 let defs = parse_gamma_market(&market).unwrap();
948 let ts_init = UnixNanos::from(1_000_000_000u64);
949
950 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
951
952 let binary = match &instrument {
953 InstrumentAny::BinaryOption(b) => b,
954 other => panic!("Expected BinaryOption, was {other:?}"),
955 };
956
957 let info = binary.info.as_ref().expect("info should be Some");
958 assert_eq!(
959 info.get_str("token_id"),
960 Some("104239898038807136052399800151408521467737075933964991162589336683346093173875")
961 );
962 assert_eq!(
963 info.get_str("condition_id"),
964 Some("0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b")
965 );
966 assert_eq!(info.get_str("market_id"), Some("1557558"));
967 assert_eq!(
968 info.get_str("question_id"),
969 Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
970 );
971 assert_eq!(
972 info.get_str("market_slug"),
973 Some("btc-updown-5m-1773307200")
974 );
975 assert_eq!(
976 info.get_str("event_start_time"),
977 Some("2026-03-12T09:20:00Z")
978 );
979 assert_eq!(info.get_str("game_id"), None);
980 assert_eq!(info.get_str("min_order_size"), Some("5"));
981 assert_eq!(info.get_bool("neg_risk"), Some(false));
982 assert_eq!(info.get("fee_schedule"), None);
983 }
984
985 #[rstest]
986 #[case(
987 Some("Detailed resolution rules with https://example.com/source"),
988 Some("Detailed resolution rules with https://example.com/source")
989 )]
990 #[case(None, None)]
991 fn test_create_instrument_info_description(
992 #[case] description: Option<&str>,
993 #[case] expected: Option<&str>,
994 ) {
995 let mut market = load_gamma_market("gamma_market.json");
996 market.description = description.map(str::to_string);
997 market.resolution_source = None;
998 let defs = parse_gamma_market(&market).unwrap();
999
1000 let instrument =
1001 create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
1002 let InstrumentAny::BinaryOption(binary) = instrument else {
1003 panic!("Expected BinaryOption");
1004 };
1005 let info = binary.info.as_ref().expect("info should be Some");
1006
1007 assert_eq!(info.get_str("description"), expected);
1008 assert_eq!(info.contains_key("description"), expected.is_some());
1009 assert_eq!(info.get_str("resolution_source"), None);
1010 }
1011
1012 #[rstest]
1013 fn test_create_instrument_info_includes_resolution_and_crypto_market_config() {
1014 let market = load_gamma_market("gamma_market_crypto_twap.json");
1015
1016 let defs = parse_gamma_market(&market).unwrap();
1017 assert_eq!(defs[0].end_date.as_deref(), Some("2026-08-22T16:05:00Z"));
1018 let instrument =
1019 create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
1020 let InstrumentAny::BinaryOption(binary) = instrument else {
1021 panic!("Expected BinaryOption");
1022 };
1023 let info = binary.info.as_ref().expect("info should be Some");
1024
1025 assert_eq!(info.get_str("end_date"), Some("2026-08-22T16:05:00Z"));
1026 assert_eq!(
1027 info.get_str("resolution_source"),
1028 Some("https://data.chain.link/streams/btc-usd-twap-60s-streams")
1029 );
1030 assert_eq!(
1031 info.get("crypto_market_config"),
1032 Some(&serde_json::json!({
1033 "id": "btc-5m-twap-60",
1034 "asset": "btc",
1035 "duration": "5m",
1036 "twapEnabled": true,
1037 "twapLookbackSeconds": 60,
1038 }))
1039 );
1040 }
1041
1042 #[rstest]
1043 fn test_create_instrument_info_omits_missing_neg_risk() {
1044 let mut market = load_gamma_market("gamma_market.json");
1045 market.neg_risk = None;
1046 let defs = parse_gamma_market(&market).unwrap();
1047
1048 let instrument =
1049 create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
1050 let InstrumentAny::BinaryOption(binary) = instrument else {
1051 panic!("Expected BinaryOption");
1052 };
1053 let info = binary.info.as_ref().expect("info should be Some");
1054
1055 assert_eq!(info.get_bool("neg_risk"), None);
1056 }
1057
1058 #[rstest]
1059 fn test_past_end_market_carries_closure_state_on_the_definition_only() {
1060 let mut market = load_gamma_market("gamma_market_past_end_date_open.json");
1061 let defs = parse_gamma_market(&market).unwrap();
1062
1063 assert!(!defs[0].closed);
1064
1065 market.closed = Some(true);
1066 let closed_defs = parse_gamma_market(&market).unwrap();
1067
1068 assert!(closed_defs[0].closed);
1069
1070 for def in [&defs[0], &closed_defs[0]] {
1073 let instrument =
1074 create_instrument_from_def(def, UnixNanos::from(1_000_000_000u64)).unwrap();
1075 let binary = match &instrument {
1076 InstrumentAny::BinaryOption(binary) => binary,
1077 other => panic!("Expected BinaryOption, was {other:?}"),
1078 };
1079 let info = binary.info.as_ref().expect("info should be present");
1080
1081 assert_eq!(info.get_bool("closed"), None);
1082 }
1083 }
1084
1085 #[rstest]
1086 fn test_create_instrument_info_params_includes_game_id_and_fee_schedule() {
1087 let market = load_gamma_market("gamma_market_sports_market_money_line.json");
1088 let defs = parse_gamma_market(&market).unwrap();
1089 let ts_init = UnixNanos::from(1_000_000_000u64);
1090
1091 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
1092
1093 let binary = match &instrument {
1094 InstrumentAny::BinaryOption(b) => b,
1095 other => panic!("Expected BinaryOption, was {other:?}"),
1096 };
1097
1098 let info = binary.info.as_ref().expect("info should be Some");
1099 assert_eq!(info.get_str("game_id"), Some("1427074"));
1100 assert!(info.get("fee_schedule").is_some());
1101 }
1102
1103 #[rstest]
1104 fn test_instruments_from_defs_batch() {
1105 let market = load_gamma_market("gamma_market.json");
1106 let defs = parse_gamma_market(&market).unwrap();
1107 let ts_init = UnixNanos::from(1_000_000_000u64);
1108
1109 let instruments = instruments_from_defs(&defs, ts_init);
1110
1111 assert_eq!(instruments.len(), 2);
1112 }
1113
1114 #[rstest]
1115 fn test_instruments_from_defs_skips_unsupported_tick_precision() {
1116 let mut market = load_gamma_market("gamma_market.json");
1117 let valid_defs = parse_gamma_market(&market).unwrap();
1118 market.order_price_min_tick_size = Some(UNSUPPORTED_TICK_SIZE.parse().unwrap());
1119 let invalid_defs = parse_gamma_market(&market).unwrap();
1120 let ts_init = UnixNanos::from(1_000_000_000u64);
1121 let expected = create_instrument_from_def(&valid_defs[1], ts_init).unwrap();
1122 let defs = [invalid_defs[0].clone(), valid_defs[1].clone()];
1123
1124 let instruments = instruments_from_defs(&defs, ts_init);
1125
1126 assert_eq!(instruments.len(), 1);
1127 assert_eq!(instruments[0].id(), expected.id());
1128 assert_eq!(instruments[0].price_increment(), expected.price_increment());
1129 assert_eq!(instruments[0].min_price(), expected.min_price());
1130 assert_eq!(instruments[0].max_price(), expected.max_price());
1131 }
1132
1133 #[rstest]
1134 #[case::zero("0")]
1135 #[case::negative("-0.005")]
1136 fn test_tick_relative_price_bounds_rejects_non_positive(#[case] tick_size: &str) {
1137 let tick_size: Decimal = tick_size.parse().unwrap();
1138
1139 let error = tick_relative_price_bounds(tick_size).unwrap_err();
1140
1141 assert_eq!(
1142 error.to_string(),
1143 format!("Tick size {tick_size} must be positive")
1144 );
1145 }
1146
1147 #[rstest]
1148 #[case("0.1", "0.1", "0.9", 1)]
1149 #[case("0.01", "0.01", "0.99", 2)]
1150 #[case("0.005", "0.005", "0.995", 3)]
1151 #[case("0.0025", "0.0025", "0.9975", 4)]
1152 #[case("0.001", "0.001", "0.999", 3)]
1153 #[case("0.0001", "0.0001", "0.9999", 4)]
1154 fn test_create_instrument_tick_relative_price_bounds(
1155 #[case] tick_size: &str,
1156 #[case] expected_min: &str,
1157 #[case] expected_max: &str,
1158 #[case] expected_tick_decimals: u8,
1159 ) {
1160 let mut market = load_gamma_market("gamma_market.json");
1161 market.order_price_min_tick_size = Some(tick_size.parse().unwrap());
1162 let defs = parse_gamma_market(&market).unwrap();
1163 let ts_init = UnixNanos::from(1_000_000_000u64);
1164
1165 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
1166
1167 let binary = match &instrument {
1168 InstrumentAny::BinaryOption(b) => b,
1169 other => panic!("Expected BinaryOption, was {other:?}"),
1170 };
1171
1172 assert_eq!(binary.price_precision, POLYMARKET_PRICE_PRECISION);
1173 assert_eq!(binary.price_increment.precision, POLYMARKET_PRICE_PRECISION);
1174 assert_eq!(
1175 binary.min_price.unwrap().precision,
1176 POLYMARKET_PRICE_PRECISION
1177 );
1178 assert_eq!(
1179 binary.max_price.unwrap().precision,
1180 POLYMARKET_PRICE_PRECISION
1181 );
1182 assert_eq!(
1183 binary.min_price_increment_precision(),
1184 expected_tick_decimals
1185 );
1186 assert_eq!(binary.min_price, Some(Price::from(expected_min)));
1187 assert_eq!(binary.max_price, Some(Price::from(expected_max)));
1188 assert_eq!(binary.min_price, Some(binary.price_increment));
1190 }
1191
1192 #[rstest]
1193 fn test_instrument_bounds_agree_with_execution_validation() {
1194 let mut market = load_gamma_market("gamma_market.json");
1198 market.order_price_min_tick_size = Some(dec!(0.01));
1199 let defs = parse_gamma_market(&market).unwrap();
1200 let ts_init = UnixNanos::from(1_000_000_000u64);
1201
1202 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
1203 let tick = instrument.price_increment();
1204 let min_price = instrument.min_price().expect("min_price");
1205 let max_price = instrument.max_price().expect("max_price");
1206
1207 assert_eq!(min_price, Price::from("0.01"));
1208 assert_eq!(max_price, Price::from("0.99"));
1209
1210 let at_min = limit_order_at(min_price);
1211 let at_max = limit_order_at(max_price);
1212 assert!(PolymarketOrderBuilder::validate_limit_price(&at_min, tick).is_ok());
1213 assert!(PolymarketOrderBuilder::validate_limit_price(&at_max, tick).is_ok());
1214
1215 let at_old_min = limit_order_at(Price::from("0.001"));
1216 let at_old_max = limit_order_at(Price::from("0.999"));
1217 assert!(PolymarketOrderBuilder::validate_limit_price(&at_old_min, tick).is_err());
1218 assert!(PolymarketOrderBuilder::validate_limit_price(&at_old_max, tick).is_err());
1219 }
1220
1221 #[rstest]
1222 fn test_rebuild_instrument_with_tick_size() {
1223 let market = load_gamma_market("gamma_market.json");
1224 let defs = parse_gamma_market(&market).unwrap();
1225 let ts_init = UnixNanos::from(1_000_000_000u64);
1226
1227 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
1229 assert_eq!(instrument.price_precision(), POLYMARKET_PRICE_PRECISION);
1230
1231 let ts_event = UnixNanos::from(2_000_000_000u64);
1232 let rebuilt =
1233 rebuild_instrument_with_tick_size(&instrument, "0.001", ts_event, ts_event).unwrap();
1234
1235 assert_eq!(rebuilt.price_precision(), POLYMARKET_PRICE_PRECISION);
1236 assert_eq!(
1237 rebuilt.price_increment().precision,
1238 POLYMARKET_PRICE_PRECISION
1239 );
1240 assert_eq!(rebuilt.min_price_increment_precision(), 3);
1241 assert_eq!(rebuilt.price_increment(), Price::from("0.001"));
1242 assert_eq!(rebuilt.min_price(), Some(Price::from("0.001")));
1244 assert_eq!(rebuilt.max_price(), Some(Price::from("0.999")));
1245 }
1246
1247 #[rstest]
1248 #[case("0.005", "0.0025", 4, "0.9975")]
1249 #[case("0.0025", "0.005", 3, "0.995")]
1250 fn test_rebuild_instrument_between_non_power_ticks(
1251 #[case] old_tick: &str,
1252 #[case] new_tick: &str,
1253 #[case] expected_tick_decimals: u8,
1254 #[case] expected_max: &str,
1255 ) {
1256 let market = load_gamma_market("gamma_market.json");
1257 let defs = parse_gamma_market(&market).unwrap();
1258 let ts_init = UnixNanos::from(1_000_000_000u64);
1259 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
1260 let instrument =
1261 rebuild_instrument_with_tick_size(&instrument, old_tick, ts_init, ts_init).unwrap();
1262
1263 let rebuilt =
1264 rebuild_instrument_with_tick_size(&instrument, new_tick, ts_init, ts_init).unwrap();
1265
1266 assert_eq!(instrument.price_increment(), Price::from(old_tick));
1267 assert_eq!(rebuilt.price_precision(), POLYMARKET_PRICE_PRECISION);
1268 assert_eq!(
1269 rebuilt.price_increment().precision,
1270 POLYMARKET_PRICE_PRECISION
1271 );
1272 assert_eq!(
1273 rebuilt.min_price_increment_precision(),
1274 expected_tick_decimals
1275 );
1276 assert_eq!(rebuilt.price_increment(), Price::from(new_tick));
1277 assert_eq!(rebuilt.min_price(), Some(Price::from(new_tick)));
1278 assert_eq!(rebuilt.max_price(), Some(Price::from(expected_max)));
1279 }
1280
1281 #[rstest]
1282 fn test_rebuild_instrument_with_tick_size_rejects_unsupported_tick_precision() {
1283 let market = load_gamma_market("gamma_market.json");
1284 let defs = parse_gamma_market(&market).unwrap();
1285 let ts_init = UnixNanos::from(1_000_000_000u64);
1286 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
1287 let error =
1288 rebuild_instrument_with_tick_size(&instrument, UNSUPPORTED_TICK_SIZE, ts_init, ts_init)
1289 .unwrap_err();
1290
1291 assert_eq!(
1292 error.to_string(),
1293 format!(
1294 "Tick size {UNSUPPORTED_TICK_SIZE} is not exactly representable at Polymarket price precision {POLYMARKET_PRICE_PRECISION}"
1295 )
1296 );
1297 }
1298
1299 #[rstest]
1300 fn test_rebuild_instrument_preserves_fields() {
1301 let market = load_gamma_market("gamma_market.json");
1302 let defs = parse_gamma_market(&market).unwrap();
1303 let ts_init = UnixNanos::from(1_000_000_000u64);
1304
1305 let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
1306 let ts_event = UnixNanos::from(2_000_000_000u64);
1307 let rebuilt =
1308 rebuild_instrument_with_tick_size(&instrument, "0.01", ts_event, ts_event).unwrap();
1309
1310 assert_eq!(rebuilt.id(), instrument.id());
1311 assert_eq!(rebuilt.raw_symbol(), instrument.raw_symbol());
1312 assert_eq!(rebuilt.size_precision(), instrument.size_precision());
1313
1314 let orig_bo = match &instrument {
1315 InstrumentAny::BinaryOption(b) => b,
1316 _ => panic!(),
1317 };
1318 let new_bo = match &rebuilt {
1319 InstrumentAny::BinaryOption(b) => b,
1320 _ => panic!(),
1321 };
1322 assert_eq!(new_bo.outcome, orig_bo.outcome);
1323 assert_eq!(new_bo.currency, orig_bo.currency);
1324 }
1325
1326 #[rstest]
1327 #[case("gamma_market_fee_crypto.json", dec!(0.20))]
1328 #[case("gamma_market_fee_sports.json", dec!(0.15))]
1329 #[case("gamma_market_fee_politics.json", dec!(0.25))]
1330 #[case("gamma_market_fee_free.json", Decimal::ZERO)]
1331 #[case("gamma_market_fee_unclassifiable.json", Decimal::ZERO)]
1332 #[case("gamma_market_fee_zero_rate.json", dec!(0.20))]
1333 #[case("gamma_market_fee_conflict.json", Decimal::ZERO)]
1334 #[case("gamma_market_fee_priority.json", dec!(0.25))]
1335 fn test_resolve_maker_rebate_rate(#[case] filename: &str, #[case] expected: Decimal) {
1336 let market = load_gamma_market(filename);
1337
1338 assert_eq!(resolve_maker_rebate_rate(&market), expected);
1339 }
1340
1341 #[rstest]
1342 fn test_enrich_market_fee_schedule_overwrites_rebate_and_keeps_rate() {
1343 let mut market = load_gamma_market("gamma_market_fee_crypto.json");
1344 let original_rate = market.fee_schedule.as_ref().unwrap().rate;
1345
1346 enrich_market_fee_schedule(&mut market);
1347
1348 let schedule = market.fee_schedule.as_ref().unwrap();
1349 assert_eq!(schedule.rate, original_rate);
1350 assert_eq!(schedule.rebate_rate, dec!(0.20));
1351 assert_eq!(schedule.exponent, Decimal::ONE);
1352 assert!(schedule.taker_only);
1353 }
1354
1355 #[rstest]
1356 fn test_enrich_market_fee_schedule_creates_schedule_for_fee_enabled() {
1357 let mut market = load_gamma_market("gamma_market_fee_crypto.json");
1358 market.fee_schedule = None;
1359
1360 enrich_market_fee_schedule(&mut market);
1361
1362 let schedule = market.fee_schedule.as_ref().unwrap();
1363 assert_eq!(schedule.rate, Decimal::ZERO);
1364 assert_eq!(schedule.rebate_rate, dec!(0.20));
1365 assert_eq!(schedule.exponent, Decimal::ONE);
1366 assert!(schedule.taker_only);
1367 }
1368
1369 #[rstest]
1370 #[case("gamma_market_fee_free.json")]
1371 #[case("gamma_market_fee_unclassifiable.json")]
1372 fn test_enrich_market_fee_schedule_keeps_zero_without_inventing(#[case] filename: &str) {
1373 let mut market = load_gamma_market(filename);
1374 market.fee_schedule = None;
1375
1376 enrich_market_fee_schedule(&mut market);
1377
1378 assert!(market.fee_schedule.is_none());
1379 }
1380}