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};
32use crate::common::{
33    consts::{POLYMARKET_PRICE_PRECISION, POLYMARKET_VENUE, PUSD},
34    enums::PolymarketOutcome,
35};
36
37const DEFAULT_TICK_SIZE: Decimal = dec!(0.001);
38
39/// Normalized instrument definition for a single Polymarket outcome token.
40///
41/// Each Polymarket market produces two of these (Yes and No).
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct PolymarketInstrumentDef {
44    /// Nautilus symbol: `{conditionId}-{tokenId}`.
45    pub symbol: Ustr,
46    /// CLOB token ID (ERC1155 token, used for orders/subscriptions).
47    pub token_id: Ustr,
48    /// On-chain condition ID.
49    pub condition_id: Ustr,
50    /// Gamma market ID.
51    pub market_id: String,
52    /// Question ID (resolution hash).
53    pub question_id: Option<String>,
54    /// Outcome label.
55    pub outcome: PolymarketOutcome,
56    /// Market question/title.
57    pub question: String,
58    /// Market description.
59    pub description: Option<String>,
60    /// Canonical price precision (four decimal places).
61    pub price_precision: u8,
62    /// Minimum tick size.
63    pub tick_size: Decimal,
64    /// Minimum order size.
65    pub min_size: Option<Decimal>,
66    /// Maker fee (decimal, not bps).
67    pub maker_fee: Option<Decimal>,
68    /// Taker fee (decimal, not bps).
69    pub taker_fee: Option<Decimal>,
70    /// Market start timestamp (ISO 8601).
71    pub start_date: Option<String>,
72    /// Event window start timestamp (ISO 8601).
73    pub event_start_time: Option<String>,
74    /// Market end timestamp (ISO 8601).
75    pub end_date: Option<String>,
76    /// Whether the market is active and accepting orders.
77    pub active: bool,
78    /// Whether Gamma reports the market closed.
79    #[serde(default)]
80    pub closed: bool,
81    /// URL slug for the market.
82    pub market_slug: Option<String>,
83    /// Whether the market uses the neg-risk CTF exchange contract.
84    pub neg_risk: Option<bool>,
85    /// Source used to resolve the market.
86    pub resolution_source: Option<String>,
87    /// Crypto market resolution configuration.
88    pub crypto_market_config: Option<CryptoMarketConfig>,
89    /// Fee schedule for this market.
90    pub fee_schedule: Option<FeeSchedule>,
91    /// Game ID for sport markets, kept verbatim because Gamma emits both
92    /// numeric and composite `<uuid>:<away>:<home>` forms.
93    pub game_id: Option<String>,
94}
95
96/// Parses a Gamma market response into instrument definitions.
97///
98/// Each market produces two definitions: one for the Yes outcome
99/// and one for the No outcome.
100pub fn parse_gamma_market(market: &GammaMarket) -> anyhow::Result<Vec<PolymarketInstrumentDef>> {
101    let game_id = market.game_id.clone().or_else(|| {
102        market
103            .events
104            .as_ref()?
105            .iter()
106            .find_map(|event| event.game_id.clone())
107    });
108
109    let token_ids: Vec<String> = serde_json::from_str(&market.clob_token_ids).map_err(|e| {
110        anyhow::anyhow!(
111            "Failed to parse clob_token_ids '{}': {e}",
112            market.clob_token_ids
113        )
114    })?;
115
116    if token_ids.len() != 2 {
117        anyhow::bail!("Expected 2 token IDs, received {}", token_ids.len());
118    }
119
120    let outcomes: Vec<String> = serde_json::from_str(&market.outcomes)
121        .map_err(|e| anyhow::anyhow!("Failed to parse outcomes '{}': {e}", market.outcomes))?;
122
123    if outcomes.len() != 2 {
124        anyhow::bail!("Expected 2 outcomes, received {}", outcomes.len());
125    }
126
127    let tick_size = market
128        .order_price_min_tick_size
129        .unwrap_or(DEFAULT_TICK_SIZE);
130    let price_precision = POLYMARKET_PRICE_PRECISION;
131
132    // Polymarket charges fees using `feeSchedule.rate` on the Gamma market.
133    // Only takers pay; makers are always zero.
134    // Reference: https://docs.polymarket.com/trading/fees
135    let maker_fee: Option<Decimal> = market.fee_schedule.as_ref().map(|_| Decimal::ZERO);
136    let taker_fee: Option<Decimal> = market.fee_schedule.as_ref().map(|fs| fs.rate);
137
138    let min_size = market.order_min_size;
139
140    let active = market.active.unwrap_or(false)
141        && !market.closed.unwrap_or(false)
142        && market.accepting_orders.unwrap_or(false);
143
144    let neg_risk = market.neg_risk;
145
146    let mut defs = Vec::with_capacity(2);
147
148    for (token_id, outcome_label) in token_ids.iter().zip(outcomes.iter()) {
149        let outcome = PolymarketOutcome::from(outcome_label.as_str());
150
151        let symbol_str = format!("{}-{token_id}", market.condition_id);
152
153        defs.push(PolymarketInstrumentDef {
154            symbol: Ustr::from(&symbol_str),
155            token_id: Ustr::from(token_id.as_str()),
156            condition_id: Ustr::from(market.condition_id.as_str()),
157            market_id: market.id.clone(),
158            question_id: market.question_id.clone(),
159            outcome,
160            question: market.question.clone(),
161            description: market.description.clone(),
162            price_precision,
163            tick_size,
164            min_size,
165            maker_fee,
166            taker_fee,
167            start_date: market.start_date.clone(),
168            event_start_time: market.event_start_time.clone(),
169            end_date: market.end_date.clone(),
170            active,
171            closed: market.closed.unwrap_or(false),
172            market_slug: market.market_slug.clone(),
173            neg_risk,
174            resolution_source: market.resolution_source.clone(),
175            crypto_market_config: market.crypto_market_config.clone(),
176            fee_schedule: market.fee_schedule.clone(),
177            game_id: game_id.clone(),
178        });
179    }
180
181    Ok(defs)
182}
183
184/// Converts a Polymarket instrument definition into a Nautilus `InstrumentAny`.
185pub fn create_instrument_from_def(
186    def: &PolymarketInstrumentDef,
187    ts_init: UnixNanos,
188) -> anyhow::Result<InstrumentAny> {
189    let symbol = Symbol::new(def.symbol);
190    let venue = *POLYMARKET_VENUE;
191    let instrument_id = InstrumentId::new(symbol, venue);
192    let raw_symbol = Symbol::new(def.token_id);
193    let currency = get_currency(PUSD);
194
195    let size_increment = Quantity::from("0.000001");
196
197    let activation_ns = def
198        .start_date
199        .as_deref()
200        .and_then(parse_datetime_to_nanos)
201        .unwrap_or_default();
202    let expiration_ns = def
203        .end_date
204        .as_deref()
205        .and_then(parse_datetime_to_nanos)
206        .unwrap_or_default();
207
208    // Advertise the tradeable range for the current tick so consumers and testers that clamp to
209    // these bounds land inside the venue's `[tick, 1 - tick]` range; execution-side validation in
210    // `PolymarketOrderBuilder::validate_limit_price` remains the source of truth.
211    let (min_price, max_price) = tick_relative_price_bounds(def.tick_size)?;
212    let price_increment = min_price;
213
214    // Polymarket exposes `orderMinSize` (limit-order minimum shares) and a separate
215    // $1 market-order minimum amount; the instrument model can only carry one
216    // `min_quantity`, so leave it unset and let the venue reject out-of-bounds orders.
217    let min_quantity: Option<Quantity> = None;
218
219    let info: Params = serde_json::from_value(build_info_json(def))?;
220
221    let binary_option = BinaryOption::builder()
222        .instrument_id(instrument_id)
223        .raw_symbol(raw_symbol)
224        .asset_class(AssetClass::Alternative)
225        .currency(currency)
226        .activation_ns(activation_ns)
227        .expiration_ns(expiration_ns)
228        .price_precision(POLYMARKET_PRICE_PRECISION)
229        // size_precision: 6-decimal collateral increments
230        .size_precision(6)
231        .price_increment(price_increment)
232        .size_increment(size_increment)
233        .outcome(def.outcome.inner())
234        .description(Ustr::from(def.question.as_str()))
235        .maybe_min_quantity(min_quantity)
236        .max_price(max_price)
237        .min_price(min_price)
238        .maybe_maker_fee(def.maker_fee)
239        .maybe_taker_fee(def.taker_fee)
240        .info(info)
241        .ts_event(ts_init)
242        .ts_init(ts_init)
243        .build()?;
244
245    Ok(InstrumentAny::BinaryOption(binary_option))
246}
247
248/// Converts a collection of definitions into Nautilus instruments.
249#[must_use]
250pub fn instruments_from_defs(
251    defs: &[PolymarketInstrumentDef],
252    ts_init: UnixNanos,
253) -> Vec<InstrumentAny> {
254    defs.iter()
255        .filter_map(|def| {
256            create_instrument_from_def(def, ts_init)
257                .map_err(|e| log::warn!("Failed to create instrument {}: {e}", def.symbol))
258                .ok()
259        })
260        .collect()
261}
262
263/// Rebuilds an instrument with a new active tick size and canonical price precision.
264///
265/// All other fields are preserved from `existing`. Returns a new `InstrumentAny`.
266pub fn rebuild_instrument_with_tick_size(
267    existing: &InstrumentAny,
268    new_tick_size: &str,
269    ts_event: UnixNanos,
270    ts_init: UnixNanos,
271) -> anyhow::Result<InstrumentAny> {
272    let bo = match existing {
273        InstrumentAny::BinaryOption(b) => b,
274        other => anyhow::bail!("Expected BinaryOption, was {other:?}"),
275    };
276
277    let tick_size: Decimal = new_tick_size
278        .parse()
279        .map_err(|e| anyhow::anyhow!("Failed to parse tick size '{new_tick_size}': {e}"))?;
280    let (min_price, max_price) = tick_relative_price_bounds(tick_size)?;
281    let price_increment = min_price;
282
283    let rebuilt = BinaryOption::builder()
284        .instrument_id(bo.id)
285        .raw_symbol(bo.raw_symbol)
286        .asset_class(bo.asset_class)
287        .currency(bo.currency)
288        .activation_ns(bo.activation_ns)
289        .expiration_ns(bo.expiration_ns)
290        .price_precision(POLYMARKET_PRICE_PRECISION)
291        .size_precision(bo.size_precision)
292        .price_increment(price_increment)
293        .size_increment(bo.size_increment)
294        .maybe_outcome(bo.outcome)
295        .maybe_description(bo.description)
296        .maybe_max_quantity(bo.max_quantity)
297        // min_quantity: see `create_instrument_from_def`
298        .maybe_max_notional(bo.max_notional)
299        .maybe_min_notional(bo.min_notional)
300        .max_price(max_price)
301        .min_price(min_price)
302        .margin_init(bo.margin_init)
303        .margin_maint(bo.margin_maint)
304        .maker_fee(bo.maker_fee)
305        .taker_fee(bo.taker_fee)
306        .maybe_info(bo.info.clone())
307        .ts_event(ts_event)
308        .ts_init(ts_init)
309        .build()?;
310
311    Ok(InstrumentAny::BinaryOption(rebuilt))
312}
313
314// Returns the tradeable price bounds `[tick_size, 1 - tick_size]` for a Polymarket outcome,
315// mirroring the venue range enforced in `PolymarketOrderBuilder::validate_limit_price`.
316pub(crate) fn tick_relative_price_bounds(tick_size: Decimal) -> anyhow::Result<(Price, Price)> {
317    anyhow::ensure!(
318        tick_size > Decimal::ZERO,
319        "Tick size {tick_size} must be positive"
320    );
321
322    let min_price = Price::from_decimal_dp(tick_size, POLYMARKET_PRICE_PRECISION)?;
323    let max_price = Price::from_decimal_dp(Decimal::ONE - tick_size, POLYMARKET_PRICE_PRECISION)?;
324
325    anyhow::ensure!(
326        min_price.as_decimal() == tick_size,
327        "Tick size {tick_size} is not exactly representable at Polymarket price precision {POLYMARKET_PRICE_PRECISION}"
328    );
329    Ok((min_price, max_price))
330}
331
332fn build_info_json(def: &PolymarketInstrumentDef) -> serde_json::Value {
333    let mut map = serde_json::Map::new();
334    map.insert(
335        "token_id".to_string(),
336        serde_json::Value::String(def.token_id.to_string()),
337    );
338    map.insert(
339        "condition_id".to_string(),
340        serde_json::Value::String(def.condition_id.to_string()),
341    );
342    map.insert(
343        "market_id".to_string(),
344        serde_json::Value::String(def.market_id.clone()),
345    );
346
347    if let Some(qid) = &def.question_id {
348        map.insert(
349            "question_id".to_string(),
350            serde_json::Value::String(qid.clone()),
351        );
352    }
353
354    if let Some(slug) = &def.market_slug {
355        map.insert(
356            "market_slug".to_string(),
357            serde_json::Value::String(slug.clone()),
358        );
359    }
360
361    if let Some(description) = &def.description {
362        map.insert(
363            "description".to_string(),
364            serde_json::Value::String(description.clone()),
365        );
366    }
367
368    if let Some(event_start_time) = &def.event_start_time {
369        map.insert(
370            "event_start_time".to_string(),
371            serde_json::Value::String(event_start_time.clone()),
372        );
373    }
374
375    if let Some(end_date) = &def.end_date {
376        map.insert(
377            "end_date".to_string(),
378            serde_json::Value::String(end_date.clone()),
379        );
380    }
381
382    if let Some(neg_risk) = def.neg_risk {
383        map.insert("neg_risk".to_string(), serde_json::Value::Bool(neg_risk));
384    }
385
386    if let Some(resolution_source) = &def.resolution_source {
387        map.insert(
388            "resolution_source".to_string(),
389            serde_json::Value::String(resolution_source.clone()),
390        );
391    }
392
393    if let Some(crypto_market_config) = &def.crypto_market_config
394        && let Ok(value) = serde_json::to_value(crypto_market_config)
395    {
396        map.insert("crypto_market_config".to_string(), value);
397    }
398
399    if let Some(min_size) = def.min_size {
400        map.insert(
401            "min_order_size".to_string(),
402            serde_json::Value::String(min_size.to_string()),
403        );
404    }
405
406    if let Some(fee_schedule) = &def.fee_schedule
407        && let Ok(value) = serde_json::to_value(fee_schedule)
408    {
409        map.insert("fee_schedule".to_string(), value);
410    }
411
412    if let Some(game_id) = &def.game_id {
413        map.insert(
414            "game_id".to_string(),
415            serde_json::Value::String(game_id.clone()),
416        );
417    }
418
419    serde_json::Value::Object(map)
420}
421
422fn get_currency(code: &str) -> Currency {
423    Currency::try_from_str(code).unwrap_or_else(|| {
424        let currency = Currency::new(code, 6, 0, code, CurrencyType::Crypto);
425        if let Err(e) = Currency::register(currency, false) {
426            log::error!("Failed to register currency '{code}': {e}");
427        }
428        currency
429    })
430}
431
432fn parse_datetime_to_nanos(s: &str) -> Option<UnixNanos> {
433    s.parse::<Timestamp>()
434        .ok()
435        .and_then(|dt| u64::try_from(dt.as_nanosecond()).ok())
436        .map(UnixNanos::from)
437}
438
439#[cfg(test)]
440mod tests {
441    use nautilus_core::UUID4;
442    use nautilus_model::{
443        enums::{OrderSide, TimeInForce},
444        identifiers::{ClientOrderId, StrategyId, TraderId},
445        instruments::Instrument,
446        orders::{LimitOrder, OrderAny},
447    };
448    use rstest::rstest;
449    use rust_decimal_macros::dec;
450
451    use super::*;
452    use crate::execution::order_builder::PolymarketOrderBuilder;
453
454    const UNSUPPORTED_TICK_SIZE: &str = "0.0000000000000000001";
455
456    fn load_gamma_market(filename: &str) -> GammaMarket {
457        let path = format!("test_data/{filename}");
458        let content = std::fs::read_to_string(path).expect("Failed to read test data");
459        serde_json::from_str(&content).expect("Failed to parse test data")
460    }
461
462    fn limit_order_at(price: Price) -> OrderAny {
463        OrderAny::Limit(LimitOrder::new(
464            TraderId::from("TESTER-001"),
465            StrategyId::from("S-001"),
466            InstrumentId::from("TEST.POLYMARKET"),
467            ClientOrderId::from("O-001"),
468            OrderSide::Buy,
469            Quantity::from("10"),
470            price,
471            TimeInForce::Gtc,
472            None,
473            false,
474            false,
475            false,
476            None,
477            None,
478            None,
479            None,
480            None,
481            None,
482            None,
483            None,
484            None,
485            None,
486            None,
487            UUID4::new(),
488            UnixNanos::default(),
489        ))
490    }
491
492    #[rstest]
493    fn test_parse_gamma_market_produces_two_defs() {
494        let market = load_gamma_market("gamma_market.json");
495        let defs = parse_gamma_market(&market).unwrap();
496
497        assert_eq!(defs.len(), 2);
498        assert_eq!(defs[0].outcome, PolymarketOutcome::from("Up"));
499        assert_eq!(defs[1].outcome, PolymarketOutcome::from("Down"));
500    }
501
502    #[rstest]
503    fn test_parse_gamma_market_fields() {
504        let market = load_gamma_market("gamma_market.json");
505        let defs = parse_gamma_market(&market).unwrap();
506        let yes_def = &defs[0];
507
508        assert_eq!(
509            yes_def.condition_id.as_str(),
510            "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b"
511        );
512        assert_eq!(yes_def.market_id, "1557558");
513        assert_eq!(
514            yes_def.question_id.as_deref(),
515            Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
516        );
517        assert_eq!(
518            yes_def.question,
519            "Bitcoin Up or Down - March 12, 5:20AM-5:25AM ET"
520        );
521        assert_eq!(yes_def.tick_size, dec!(0.01));
522        assert_eq!(yes_def.price_precision, POLYMARKET_PRICE_PRECISION);
523        assert_eq!(yes_def.min_size, Some(dec!(5.0)));
524        assert!(yes_def.maker_fee.is_none());
525        assert!(yes_def.taker_fee.is_none());
526        assert!(yes_def.active);
527        assert_eq!(
528            yes_def.market_slug.as_deref(),
529            Some("btc-updown-5m-1773307200")
530        );
531        assert_eq!(yes_def.game_id, None);
532    }
533
534    #[rstest]
535    fn test_parse_gamma_market_sports_game_id_and_fee_schedule() {
536        let money_line = load_gamma_market("gamma_market_sports_market_money_line.json");
537        let map_handicap = load_gamma_market("gamma_market_sports_market_map_handicap.json");
538
539        let money_line_defs = parse_gamma_market(&money_line).unwrap();
540        let map_handicap_defs = parse_gamma_market(&map_handicap).unwrap();
541
542        assert_eq!(money_line_defs[0].game_id.as_deref(), Some("1427074"));
543        assert_eq!(map_handicap_defs[0].game_id.as_deref(), Some("1427074"));
544        assert_eq!(money_line_defs[0].fee_schedule, money_line.fee_schedule);
545        assert_eq!(map_handicap_defs[0].fee_schedule, map_handicap.fee_schedule);
546
547        // Maker fee is always zero for feeSchedule-backed markets
548        assert_eq!(money_line_defs[0].maker_fee, Some(Decimal::ZERO));
549        // Taker fee comes from feeSchedule.rate (sports rate = 0.03)
550        assert_eq!(money_line_defs[0].taker_fee, Some(dec!(0.03)));
551    }
552
553    #[rstest]
554    fn test_parse_gamma_market_symbol_format() {
555        let market = load_gamma_market("gamma_market.json");
556        let defs = parse_gamma_market(&market).unwrap();
557
558        assert_eq!(
559            defs[0].symbol.as_str(),
560            "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875"
561        );
562        assert_eq!(
563            defs[1].symbol.as_str(),
564            "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-71183960810705820955071415844881728181970340514894896943812046065452395013351"
565        );
566    }
567
568    #[rstest]
569    fn test_parse_gamma_market_token_ids() {
570        let market = load_gamma_market("gamma_market.json");
571        let defs = parse_gamma_market(&market).unwrap();
572
573        assert_eq!(
574            defs[0].token_id.as_str(),
575            "104239898038807136052399800151408521467737075933964991162589336683346093173875"
576        );
577        assert_eq!(
578            defs[1].token_id.as_str(),
579            "71183960810705820955071415844881728181970340514894896943812046065452395013351"
580        );
581    }
582
583    #[rstest]
584    fn test_parse_gamma_market_derives_outcome_from_label() {
585        let mut market = load_gamma_market("gamma_market.json");
586
587        // Reverse the outcomes order so No comes first
588        market.outcomes = r#"["No", "Yes"]"#.to_string();
589
590        let defs = parse_gamma_market(&market).unwrap();
591
592        assert_eq!(defs[0].outcome, PolymarketOutcome::no());
593        assert_eq!(defs[1].outcome, PolymarketOutcome::yes());
594    }
595
596    #[rstest]
597    fn test_parse_gamma_market_accepts_arbitrary_outcome_label() {
598        let mut market = load_gamma_market("gamma_market.json");
599        market.outcomes = r#"["Maybe", "No"]"#.to_string();
600
601        let defs = parse_gamma_market(&market).unwrap();
602
603        assert_eq!(defs[0].outcome, PolymarketOutcome::from("Maybe"));
604        assert_eq!(defs[1].outcome, PolymarketOutcome::no());
605    }
606
607    #[rstest]
608    fn test_parse_gamma_market_null_tick_size_uses_default() {
609        let mut market = load_gamma_market("gamma_market.json");
610        market.order_price_min_tick_size = None;
611
612        let defs = parse_gamma_market(&market).unwrap();
613
614        assert_eq!(defs[0].tick_size, dec!(0.001));
615        assert_eq!(defs[0].price_precision, POLYMARKET_PRICE_PRECISION);
616    }
617
618    #[rstest]
619    fn test_parse_gamma_market_closed_is_inactive() {
620        let mut market = load_gamma_market("gamma_market.json");
621        market.closed = Some(true);
622
623        let defs = parse_gamma_market(&market).unwrap();
624
625        assert!(!defs[0].active);
626        assert!(!defs[1].active);
627    }
628
629    #[rstest]
630    fn test_create_instrument_from_def() {
631        let market = load_gamma_market("gamma_market.json");
632        let defs = parse_gamma_market(&market).unwrap();
633        let ts_init = UnixNanos::from(1_000_000_000u64);
634
635        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
636
637        let binary = match &instrument {
638            InstrumentAny::BinaryOption(b) => b,
639            other => panic!("Expected BinaryOption, was {other:?}"),
640        };
641
642        assert_eq!(
643            binary.id.to_string(),
644            "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875.POLYMARKET"
645        );
646        assert_eq!(binary.outcome, Some(Ustr::from("Up")));
647        assert_eq!(binary.asset_class, AssetClass::Alternative);
648        assert_eq!(binary.currency.code.as_str(), "pUSD");
649        assert_eq!(binary.price_precision, POLYMARKET_PRICE_PRECISION);
650        assert_eq!(binary.size_precision, 6);
651        assert_eq!(binary.price_increment(), Price::from("0.01"));
652        assert_eq!(binary.size_increment(), Quantity::from("0.000001"));
653    }
654
655    #[rstest]
656    fn test_create_instrument_info_params() {
657        let market = load_gamma_market("gamma_market.json");
658        let defs = parse_gamma_market(&market).unwrap();
659        let ts_init = UnixNanos::from(1_000_000_000u64);
660
661        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
662
663        let binary = match &instrument {
664            InstrumentAny::BinaryOption(b) => b,
665            other => panic!("Expected BinaryOption, was {other:?}"),
666        };
667
668        let info = binary.info.as_ref().expect("info should be Some");
669        assert_eq!(
670            info.get_str("token_id"),
671            Some("104239898038807136052399800151408521467737075933964991162589336683346093173875")
672        );
673        assert_eq!(
674            info.get_str("condition_id"),
675            Some("0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b")
676        );
677        assert_eq!(info.get_str("market_id"), Some("1557558"));
678        assert_eq!(
679            info.get_str("question_id"),
680            Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
681        );
682        assert_eq!(
683            info.get_str("market_slug"),
684            Some("btc-updown-5m-1773307200")
685        );
686        assert_eq!(
687            info.get_str("event_start_time"),
688            Some("2026-03-12T09:20:00Z")
689        );
690        assert_eq!(info.get_str("game_id"), None);
691        assert_eq!(info.get_str("min_order_size"), Some("5"));
692        assert_eq!(info.get_bool("neg_risk"), Some(false));
693        assert_eq!(info.get("fee_schedule"), None);
694    }
695
696    #[rstest]
697    #[case(
698        Some("Detailed resolution rules with https://example.com/source"),
699        Some("Detailed resolution rules with https://example.com/source")
700    )]
701    #[case(None, None)]
702    fn test_create_instrument_info_description(
703        #[case] description: Option<&str>,
704        #[case] expected: Option<&str>,
705    ) {
706        let mut market = load_gamma_market("gamma_market.json");
707        market.description = description.map(str::to_string);
708        market.resolution_source = None;
709        let defs = parse_gamma_market(&market).unwrap();
710
711        let instrument =
712            create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
713        let InstrumentAny::BinaryOption(binary) = instrument else {
714            panic!("Expected BinaryOption");
715        };
716        let info = binary.info.as_ref().expect("info should be Some");
717
718        assert_eq!(info.get_str("description"), expected);
719        assert_eq!(info.contains_key("description"), expected.is_some());
720        assert_eq!(info.get_str("resolution_source"), None);
721    }
722
723    #[rstest]
724    fn test_create_instrument_info_includes_resolution_and_crypto_market_config() {
725        let market = load_gamma_market("gamma_market_crypto_twap.json");
726
727        let defs = parse_gamma_market(&market).unwrap();
728        assert_eq!(defs[0].end_date.as_deref(), Some("2026-08-22T16:05:00Z"));
729        let instrument =
730            create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
731        let InstrumentAny::BinaryOption(binary) = instrument else {
732            panic!("Expected BinaryOption");
733        };
734        let info = binary.info.as_ref().expect("info should be Some");
735
736        assert_eq!(info.get_str("end_date"), Some("2026-08-22T16:05:00Z"));
737        assert_eq!(
738            info.get_str("resolution_source"),
739            Some("https://data.chain.link/streams/btc-usd-twap-60s-streams")
740        );
741        assert_eq!(
742            info.get("crypto_market_config"),
743            Some(&serde_json::json!({
744                "id": "btc-5m-twap-60",
745                "asset": "btc",
746                "duration": "5m",
747                "twapEnabled": true,
748                "twapLookbackSeconds": 60,
749            }))
750        );
751    }
752
753    #[rstest]
754    fn test_create_instrument_info_omits_missing_neg_risk() {
755        let mut market = load_gamma_market("gamma_market.json");
756        market.neg_risk = None;
757        let defs = parse_gamma_market(&market).unwrap();
758
759        let instrument =
760            create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap();
761        let InstrumentAny::BinaryOption(binary) = instrument else {
762            panic!("Expected BinaryOption");
763        };
764        let info = binary.info.as_ref().expect("info should be Some");
765
766        assert_eq!(info.get_bool("neg_risk"), None);
767    }
768
769    #[rstest]
770    fn test_past_end_market_carries_closure_state_on_the_definition_only() {
771        let mut market = load_gamma_market("gamma_market_past_end_date_open.json");
772        let defs = parse_gamma_market(&market).unwrap();
773
774        assert!(!defs[0].closed);
775
776        market.closed = Some(true);
777        let closed_defs = parse_gamma_market(&market).unwrap();
778
779        assert!(closed_defs[0].closed);
780
781        // `create_instrument_from_def` is shared with the historical loader, which keeps terminal
782        // state in `resolution_metadata`. Closure is stamped on the live Gamma path instead.
783        for def in [&defs[0], &closed_defs[0]] {
784            let instrument =
785                create_instrument_from_def(def, UnixNanos::from(1_000_000_000u64)).unwrap();
786            let binary = match &instrument {
787                InstrumentAny::BinaryOption(binary) => binary,
788                other => panic!("Expected BinaryOption, was {other:?}"),
789            };
790            let info = binary.info.as_ref().expect("info should be present");
791
792            assert_eq!(info.get_bool("closed"), None);
793        }
794    }
795
796    #[rstest]
797    fn test_create_instrument_info_params_includes_game_id_and_fee_schedule() {
798        let market = load_gamma_market("gamma_market_sports_market_money_line.json");
799        let defs = parse_gamma_market(&market).unwrap();
800        let ts_init = UnixNanos::from(1_000_000_000u64);
801
802        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
803
804        let binary = match &instrument {
805            InstrumentAny::BinaryOption(b) => b,
806            other => panic!("Expected BinaryOption, was {other:?}"),
807        };
808
809        let info = binary.info.as_ref().expect("info should be Some");
810        assert_eq!(info.get_str("game_id"), Some("1427074"));
811        assert!(info.get("fee_schedule").is_some());
812    }
813
814    #[rstest]
815    fn test_instruments_from_defs_batch() {
816        let market = load_gamma_market("gamma_market.json");
817        let defs = parse_gamma_market(&market).unwrap();
818        let ts_init = UnixNanos::from(1_000_000_000u64);
819
820        let instruments = instruments_from_defs(&defs, ts_init);
821
822        assert_eq!(instruments.len(), 2);
823    }
824
825    #[rstest]
826    fn test_instruments_from_defs_skips_unsupported_tick_precision() {
827        let mut market = load_gamma_market("gamma_market.json");
828        let valid_defs = parse_gamma_market(&market).unwrap();
829        market.order_price_min_tick_size = Some(UNSUPPORTED_TICK_SIZE.parse().unwrap());
830        let invalid_defs = parse_gamma_market(&market).unwrap();
831        let ts_init = UnixNanos::from(1_000_000_000u64);
832        let expected = create_instrument_from_def(&valid_defs[1], ts_init).unwrap();
833        let defs = [invalid_defs[0].clone(), valid_defs[1].clone()];
834
835        let instruments = instruments_from_defs(&defs, ts_init);
836
837        assert_eq!(instruments.len(), 1);
838        assert_eq!(instruments[0].id(), expected.id());
839        assert_eq!(instruments[0].price_increment(), expected.price_increment());
840        assert_eq!(instruments[0].min_price(), expected.min_price());
841        assert_eq!(instruments[0].max_price(), expected.max_price());
842    }
843
844    #[rstest]
845    #[case::zero("0")]
846    #[case::negative("-0.005")]
847    fn test_tick_relative_price_bounds_rejects_non_positive(#[case] tick_size: &str) {
848        let tick_size: Decimal = tick_size.parse().unwrap();
849
850        let error = tick_relative_price_bounds(tick_size).unwrap_err();
851
852        assert_eq!(
853            error.to_string(),
854            format!("Tick size {tick_size} must be positive")
855        );
856    }
857
858    #[rstest]
859    #[case("0.1", "0.1", "0.9", 1)]
860    #[case("0.01", "0.01", "0.99", 2)]
861    #[case("0.005", "0.005", "0.995", 3)]
862    #[case("0.0025", "0.0025", "0.9975", 4)]
863    #[case("0.001", "0.001", "0.999", 3)]
864    #[case("0.0001", "0.0001", "0.9999", 4)]
865    fn test_create_instrument_tick_relative_price_bounds(
866        #[case] tick_size: &str,
867        #[case] expected_min: &str,
868        #[case] expected_max: &str,
869        #[case] expected_tick_decimals: u8,
870    ) {
871        let mut market = load_gamma_market("gamma_market.json");
872        market.order_price_min_tick_size = Some(tick_size.parse().unwrap());
873        let defs = parse_gamma_market(&market).unwrap();
874        let ts_init = UnixNanos::from(1_000_000_000u64);
875
876        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
877
878        let binary = match &instrument {
879            InstrumentAny::BinaryOption(b) => b,
880            other => panic!("Expected BinaryOption, was {other:?}"),
881        };
882
883        assert_eq!(binary.price_precision, POLYMARKET_PRICE_PRECISION);
884        assert_eq!(binary.price_increment.precision, POLYMARKET_PRICE_PRECISION);
885        assert_eq!(
886            binary.min_price.unwrap().precision,
887            POLYMARKET_PRICE_PRECISION
888        );
889        assert_eq!(
890            binary.max_price.unwrap().precision,
891            POLYMARKET_PRICE_PRECISION
892        );
893        assert_eq!(
894            binary.min_price_increment_precision(),
895            expected_tick_decimals
896        );
897        assert_eq!(binary.min_price, Some(Price::from(expected_min)));
898        assert_eq!(binary.max_price, Some(Price::from(expected_max)));
899        // The lower bound is exactly the price increment (one tick)
900        assert_eq!(binary.min_price, Some(binary.price_increment));
901    }
902
903    #[rstest]
904    fn test_instrument_bounds_agree_with_execution_validation() {
905        // On a 0.01-tick market, clamping to the advertised bounds must land inside the
906        // venue's [tick, 1 - tick] range that `validate_limit_price` enforces, and the old
907        // static 0.001/0.999 bounds must be rejected by that same validation.
908        let mut market = load_gamma_market("gamma_market.json");
909        market.order_price_min_tick_size = Some(dec!(0.01));
910        let defs = parse_gamma_market(&market).unwrap();
911        let ts_init = UnixNanos::from(1_000_000_000u64);
912
913        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
914        let tick = instrument.price_increment();
915        let min_price = instrument.min_price().expect("min_price");
916        let max_price = instrument.max_price().expect("max_price");
917
918        assert_eq!(min_price, Price::from("0.01"));
919        assert_eq!(max_price, Price::from("0.99"));
920
921        let at_min = limit_order_at(min_price);
922        let at_max = limit_order_at(max_price);
923        assert!(PolymarketOrderBuilder::validate_limit_price(&at_min, tick).is_ok());
924        assert!(PolymarketOrderBuilder::validate_limit_price(&at_max, tick).is_ok());
925
926        let at_old_min = limit_order_at(Price::from("0.001"));
927        let at_old_max = limit_order_at(Price::from("0.999"));
928        assert!(PolymarketOrderBuilder::validate_limit_price(&at_old_min, tick).is_err());
929        assert!(PolymarketOrderBuilder::validate_limit_price(&at_old_max, tick).is_err());
930    }
931
932    #[rstest]
933    fn test_rebuild_instrument_with_tick_size() {
934        let market = load_gamma_market("gamma_market.json");
935        let defs = parse_gamma_market(&market).unwrap();
936        let ts_init = UnixNanos::from(1_000_000_000u64);
937
938        // The active tick changes independently of canonical price precision
939        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
940        assert_eq!(instrument.price_precision(), POLYMARKET_PRICE_PRECISION);
941
942        let ts_event = UnixNanos::from(2_000_000_000u64);
943        let rebuilt =
944            rebuild_instrument_with_tick_size(&instrument, "0.001", ts_event, ts_event).unwrap();
945
946        assert_eq!(rebuilt.price_precision(), POLYMARKET_PRICE_PRECISION);
947        assert_eq!(
948            rebuilt.price_increment().precision,
949            POLYMARKET_PRICE_PRECISION
950        );
951        assert_eq!(rebuilt.min_price_increment_precision(), 3);
952        assert_eq!(rebuilt.price_increment(), Price::from("0.001"));
953        // Bounds reflect the new tick, not the pre-change 0.01-tick range
954        assert_eq!(rebuilt.min_price(), Some(Price::from("0.001")));
955        assert_eq!(rebuilt.max_price(), Some(Price::from("0.999")));
956    }
957
958    #[rstest]
959    #[case("0.005", "0.0025", 4, "0.9975")]
960    #[case("0.0025", "0.005", 3, "0.995")]
961    fn test_rebuild_instrument_between_non_power_ticks(
962        #[case] old_tick: &str,
963        #[case] new_tick: &str,
964        #[case] expected_tick_decimals: u8,
965        #[case] expected_max: &str,
966    ) {
967        let market = load_gamma_market("gamma_market.json");
968        let defs = parse_gamma_market(&market).unwrap();
969        let ts_init = UnixNanos::from(1_000_000_000u64);
970        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
971        let instrument =
972            rebuild_instrument_with_tick_size(&instrument, old_tick, ts_init, ts_init).unwrap();
973
974        let rebuilt =
975            rebuild_instrument_with_tick_size(&instrument, new_tick, ts_init, ts_init).unwrap();
976
977        assert_eq!(instrument.price_increment(), Price::from(old_tick));
978        assert_eq!(rebuilt.price_precision(), POLYMARKET_PRICE_PRECISION);
979        assert_eq!(
980            rebuilt.price_increment().precision,
981            POLYMARKET_PRICE_PRECISION
982        );
983        assert_eq!(
984            rebuilt.min_price_increment_precision(),
985            expected_tick_decimals
986        );
987        assert_eq!(rebuilt.price_increment(), Price::from(new_tick));
988        assert_eq!(rebuilt.min_price(), Some(Price::from(new_tick)));
989        assert_eq!(rebuilt.max_price(), Some(Price::from(expected_max)));
990    }
991
992    #[rstest]
993    fn test_rebuild_instrument_with_tick_size_rejects_unsupported_tick_precision() {
994        let market = load_gamma_market("gamma_market.json");
995        let defs = parse_gamma_market(&market).unwrap();
996        let ts_init = UnixNanos::from(1_000_000_000u64);
997        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
998        let error =
999            rebuild_instrument_with_tick_size(&instrument, UNSUPPORTED_TICK_SIZE, ts_init, ts_init)
1000                .unwrap_err();
1001
1002        assert_eq!(
1003            error.to_string(),
1004            format!(
1005                "Tick size {UNSUPPORTED_TICK_SIZE} is not exactly representable at Polymarket price precision {POLYMARKET_PRICE_PRECISION}"
1006            )
1007        );
1008    }
1009
1010    #[rstest]
1011    fn test_rebuild_instrument_preserves_fields() {
1012        let market = load_gamma_market("gamma_market.json");
1013        let defs = parse_gamma_market(&market).unwrap();
1014        let ts_init = UnixNanos::from(1_000_000_000u64);
1015
1016        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
1017        let ts_event = UnixNanos::from(2_000_000_000u64);
1018        let rebuilt =
1019            rebuild_instrument_with_tick_size(&instrument, "0.01", ts_event, ts_event).unwrap();
1020
1021        assert_eq!(rebuilt.id(), instrument.id());
1022        assert_eq!(rebuilt.raw_symbol(), instrument.raw_symbol());
1023        assert_eq!(rebuilt.size_precision(), instrument.size_precision());
1024
1025        let orig_bo = match &instrument {
1026            InstrumentAny::BinaryOption(b) => b,
1027            _ => panic!(),
1028        };
1029        let new_bo = match &rebuilt {
1030            InstrumentAny::BinaryOption(b) => b,
1031            _ => panic!(),
1032        };
1033        assert_eq!(new_bo.outcome, orig_bo.outcome);
1034        assert_eq!(new_bo.currency, orig_bo.currency);
1035    }
1036}