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