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