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