Skip to main content

nautilus_polymarket/http/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Instrument parsing for Polymarket markets.
17
18use 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
48// Maker rebate shares per the published fee schedule.
49// Reference: https://docs.polymarket.com/trading/fees
50const REBATE_CRYPTO: Decimal = dec!(0.20);
51const REBATE_SPORTS: Decimal = dec!(0.15);
52const REBATE_STANDARD: Decimal = dec!(0.25);
53
54/// Normalized instrument definition for a single Polymarket outcome token.
55///
56/// Each Polymarket market produces two of these (Yes and No).
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct PolymarketInstrumentDef {
59    /// Nautilus symbol: `{conditionId}-{tokenId}`.
60    pub symbol: Ustr,
61    /// CLOB token ID (ERC1155 token, used for orders/subscriptions).
62    pub token_id: Ustr,
63    /// On-chain condition ID.
64    pub condition_id: Ustr,
65    /// Gamma market ID.
66    pub market_id: String,
67    /// Gamma parent event ID, when unambiguous.
68    pub event_id: Option<Ustr>,
69    /// Original Gamma market response.
70    #[serde(default)]
71    pub gamma_market: String,
72    /// Shared `Value::String` containing the original enclosing event JSON from discovery.
73    pub gamma_event: Option<Arc<Value>>,
74    /// Question ID (resolution hash).
75    pub question_id: Option<String>,
76    /// Outcome label.
77    pub outcome: PolymarketOutcome,
78    /// Market question/title.
79    pub question: String,
80    /// Market description.
81    pub description: Option<String>,
82    /// Canonical price precision (four decimal places).
83    pub price_precision: u8,
84    /// Minimum tick size.
85    pub tick_size: Decimal,
86    /// Minimum order size.
87    pub min_size: Option<Decimal>,
88    /// Market start timestamp (ISO 8601).
89    pub start_date: Option<String>,
90    /// Event window start timestamp (ISO 8601).
91    pub event_start_time: Option<String>,
92    /// Market end timestamp (ISO 8601).
93    pub end_date: Option<String>,
94    /// Whether the market is active and accepting orders.
95    pub active: bool,
96    /// Whether Gamma reports the market closed.
97    #[serde(default)]
98    pub closed: bool,
99    /// URL slug for the market.
100    pub market_slug: Option<String>,
101    /// Whether the market uses the neg-risk CTF exchange contract.
102    pub neg_risk: Option<bool>,
103    /// Source used to resolve the market.
104    pub resolution_source: Option<String>,
105    /// Crypto market resolution configuration.
106    pub crypto_market_config: Option<CryptoMarketConfig>,
107    /// Fee schedule for this market.
108    pub fee_schedule: Option<FeeSchedule>,
109    /// Game ID for sport markets, kept verbatim because Gamma emits both
110    /// numeric and composite `<uuid>:<away>:<home>` forms.
111    pub game_id: Option<String>,
112    /// Market protocol version, which must be `v1`.
113    pub market_version: String,
114}
115
116/// Parses a Gamma market response into instrument definitions.
117///
118/// Each market produces two definitions: one for the Yes outcome
119/// and one for the No outcome.
120pub 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
241// Resolves the maker rebate share from market category metadata, returning
242// 0.20 for crypto, 0.15 for sports, 0.25 for other documented fee-enabled
243// categories, and zero for fee-free or unclassifiable markets.
244pub(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
253// Create missing schedules only for a resolved fee-enabled category.
254// Keep unknown taker rates at zero; the legacy CLOB base fee cannot supply them.
255pub(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
428/// Converts a Polymarket instrument definition into a Nautilus `InstrumentAny`.
429pub 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    // Advertise the tradeable range for the current tick so consumers and testers that clamp to
454    // these bounds land inside the venue's `[tick, 1 - tick]` range; execution-side validation in
455    // `PolymarketOrderBuilder::validate_limit_price` remains the source of truth.
456    let (min_price, max_price) = tick_relative_price_bounds(def.tick_size)?;
457    let price_increment = min_price;
458
459    // Polymarket exposes `orderMinSize` (limit-order minimum shares) and a separate
460    // $1 market-order minimum amount; the instrument model can only carry one
461    // `min_quantity`, so leave it unset and let the venue reject out-of-bounds orders.
462    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-decimal collateral increments
475        .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/// Converts a collection of definitions into Nautilus instruments.
493#[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
507/// Rebuilds an instrument with a new active tick size and canonical price precision.
508///
509/// All other fields are preserved from `existing`. Returns a new `InstrumentAny`.
510pub 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        // min_quantity: see `create_instrument_from_def`
542        .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
556// Returns the tradeable price bounds `[tick_size, 1 - tick_size]` for a Polymarket outcome,
557// mirroring the venue range enforced in `PolymarketOrderBuilder::validate_limit_price`.
558pub(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        // Reserve the event position under both serde_json map ordering configurations
583        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        // Reverse the outcomes order so No comes first
910        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        // `create_instrument_from_def` is shared with the historical loader, which keeps terminal
1155        // state in `resolution_metadata`. Closure is stamped on the live Gamma path instead.
1156        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        // The lower bound is exactly the price increment (one tick)
1279        assert_eq!(binary.min_price, Some(binary.price_increment));
1280    }
1281
1282    #[rstest]
1283    fn test_instrument_bounds_agree_with_execution_validation() {
1284        // On a 0.01-tick market, clamping to the advertised bounds must land inside the
1285        // venue's [tick, 1 - tick] range that `validate_limit_price` enforces, and the old
1286        // static 0.001/0.999 bounds must be rejected by that same validation.
1287        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        // The active tick changes independently of canonical price precision
1318        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        // Bounds reflect the new tick, not the pre-change 0.01-tick range
1333        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}