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