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 nautilus_core::{Params, UnixNanos};
19use nautilus_model::{
20    enums::{AssetClass, CurrencyType},
21    identifiers::{InstrumentId, Symbol},
22    instruments::{BinaryOption, InstrumentAny},
23    types::{Currency, Price, Quantity},
24};
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27use ustr::Ustr;
28
29use super::models::{FeeSchedule, GammaMarket};
30use crate::common::{
31    consts::{MAX_PRICE, MIN_PRICE, POLYMARKET_VENUE, PUSD},
32    enums::PolymarketOutcome,
33};
34
35const DEFAULT_TICK_SIZE: &str = "0.001";
36
37/// Normalized instrument definition for a single Polymarket outcome token.
38///
39/// Each Polymarket market produces two of these (Yes and No).
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct PolymarketInstrumentDef {
42    /// Nautilus symbol: `{conditionId}-{tokenId}`.
43    pub symbol: Ustr,
44    /// CLOB token ID (ERC1155 token, used for orders/subscriptions).
45    pub token_id: Ustr,
46    /// On-chain condition ID.
47    pub condition_id: Ustr,
48    /// Gamma market ID.
49    pub market_id: String,
50    /// Question ID (resolution hash).
51    pub question_id: Option<String>,
52    /// Outcome label.
53    pub outcome: PolymarketOutcome,
54    /// Market question/title.
55    pub question: String,
56    /// Market description.
57    pub description: Option<String>,
58    /// Price precision (decimal places).
59    pub price_precision: u8,
60    /// Minimum tick size.
61    pub tick_size: Decimal,
62    /// Minimum order size.
63    pub min_size: Option<Decimal>,
64    /// Maker fee (decimal, not bps).
65    pub maker_fee: Option<Decimal>,
66    /// Taker fee (decimal, not bps).
67    pub taker_fee: Option<Decimal>,
68    /// Market start timestamp (ISO 8601).
69    pub start_date: Option<String>,
70    /// Market end timestamp (ISO 8601).
71    pub end_date: Option<String>,
72    /// Whether the market is active and accepting orders.
73    pub active: bool,
74    /// URL slug for the market.
75    pub market_slug: Option<String>,
76    /// Whether the market uses the neg-risk CTF exchange contract.
77    pub neg_risk: bool,
78    /// Fee schedule for this market.
79    pub fee_schedule: Option<FeeSchedule>,
80    /// Game ID for sport markets.
81    pub game_id: Option<u64>,
82}
83
84/// Parses a Gamma market response into instrument definitions.
85///
86/// Each market produces two definitions: one for the Yes outcome
87/// and one for the No outcome.
88pub fn parse_gamma_market(market: &GammaMarket) -> anyhow::Result<Vec<PolymarketInstrumentDef>> {
89    let game_id = market.game_id.or_else(|| {
90        market
91            .events
92            .as_ref()?
93            .iter()
94            .find_map(|event| event.game_id)
95    });
96
97    let token_ids: Vec<String> = serde_json::from_str(&market.clob_token_ids).map_err(|e| {
98        anyhow::anyhow!(
99            "Failed to parse clob_token_ids '{}': {e}",
100            market.clob_token_ids
101        )
102    })?;
103
104    if token_ids.len() != 2 {
105        anyhow::bail!("Expected 2 token IDs, received {}", token_ids.len());
106    }
107
108    let outcomes: Vec<String> = serde_json::from_str(&market.outcomes)
109        .map_err(|e| anyhow::anyhow!("Failed to parse outcomes '{}': {e}", market.outcomes))?;
110
111    if outcomes.len() != 2 {
112        anyhow::bail!("Expected 2 outcomes, received {}", outcomes.len());
113    }
114
115    let tick_size_str = market
116        .order_price_min_tick_size
117        .map_or_else(|| DEFAULT_TICK_SIZE.to_string(), |ts| ts.to_string());
118    let tick_size: Decimal = tick_size_str
119        .parse()
120        .map_err(|e| anyhow::anyhow!("Failed to parse tick size '{tick_size_str}': {e}"))?;
121    let price_precision = tick_size.scale() as u8;
122
123    // Polymarket charges fees using `feeSchedule.rate` on the Gamma market.
124    // Only takers pay; makers are always zero.
125    // Reference: https://docs.polymarket.com/trading/fees
126    let maker_fee: Option<Decimal> = market.fee_schedule.as_ref().map(|_| Decimal::ZERO);
127    let taker_fee: Option<Decimal> = market
128        .fee_schedule
129        .as_ref()
130        .and_then(|fs| Decimal::try_from(fs.rate).ok());
131
132    let min_size: Option<Decimal> = market
133        .order_min_size
134        .map(|s| s.to_string().parse())
135        .transpose()
136        .map_err(|e| anyhow::anyhow!("Failed to parse min size: {e}"))?;
137
138    let active = market.active.unwrap_or(false)
139        && !market.closed.unwrap_or(false)
140        && market.accepting_orders.unwrap_or(false);
141
142    let neg_risk = market.neg_risk.unwrap_or(false);
143
144    let mut defs = Vec::with_capacity(2);
145
146    for (token_id, outcome_label) in token_ids.iter().zip(outcomes.iter()) {
147        let outcome = PolymarketOutcome::from(outcome_label.as_str());
148
149        let symbol_str = format!("{}-{token_id}", market.condition_id);
150
151        defs.push(PolymarketInstrumentDef {
152            symbol: Ustr::from(&symbol_str),
153            token_id: Ustr::from(token_id.as_str()),
154            condition_id: Ustr::from(market.condition_id.as_str()),
155            market_id: market.id.clone(),
156            question_id: market.question_id.clone(),
157            outcome,
158            question: market.question.clone(),
159            description: market.description.clone(),
160            price_precision,
161            tick_size,
162            min_size,
163            maker_fee,
164            taker_fee,
165            start_date: market.start_date.clone(),
166            end_date: market.end_date.clone(),
167            active,
168            market_slug: market.market_slug.clone(),
169            neg_risk,
170            fee_schedule: market.fee_schedule.clone(),
171            game_id,
172        });
173    }
174
175    Ok(defs)
176}
177
178/// Converts a Polymarket instrument definition into a Nautilus `InstrumentAny`.
179pub fn create_instrument_from_def(
180    def: &PolymarketInstrumentDef,
181    ts_init: UnixNanos,
182) -> anyhow::Result<InstrumentAny> {
183    let symbol = Symbol::new(def.symbol);
184    let venue = *POLYMARKET_VENUE;
185    let instrument_id = InstrumentId::new(symbol, venue);
186    let raw_symbol = Symbol::new(def.token_id);
187    let currency = get_currency(PUSD);
188
189    let price_increment = Price::from(def.tick_size.to_string());
190    let size_increment = Quantity::from("0.000001");
191
192    let activation_ns = def
193        .start_date
194        .as_deref()
195        .and_then(parse_datetime_to_nanos)
196        .unwrap_or_default();
197    let expiration_ns = def
198        .end_date
199        .as_deref()
200        .and_then(parse_datetime_to_nanos)
201        .unwrap_or_default();
202
203    let max_price = Some(Price::from(MAX_PRICE));
204    let min_price = Some(Price::from(MIN_PRICE));
205    // Polymarket exposes `orderMinSize` (limit-order minimum shares) and a separate
206    // $1 market-order minimum amount; the instrument model can only carry one
207    // `min_quantity`, so leave it unset and let the venue reject out-of-bounds orders.
208    let min_quantity: Option<Quantity> = None;
209
210    let info: Params = serde_json::from_value(build_info_json(def))?;
211
212    let binary_option = BinaryOption::new_checked(
213        instrument_id,
214        raw_symbol,
215        AssetClass::Alternative,
216        currency,
217        activation_ns,
218        expiration_ns,
219        def.price_precision,
220        6, // size_precision: 6-decimal collateral increments
221        price_increment,
222        size_increment,
223        Some(def.outcome.inner()),
224        Some(Ustr::from(def.question.as_str())),
225        None, // max_quantity
226        min_quantity,
227        None, // max_notional
228        None, // min_notional
229        max_price,
230        min_price,
231        None, // margin_init
232        None, // margin_maint
233        def.maker_fee,
234        def.taker_fee,
235        Some(info),
236        ts_init,
237        ts_init,
238    )?;
239
240    Ok(InstrumentAny::BinaryOption(binary_option))
241}
242
243/// Converts a collection of definitions into Nautilus instruments.
244#[must_use]
245pub fn instruments_from_defs(
246    defs: &[PolymarketInstrumentDef],
247    ts_init: UnixNanos,
248) -> Vec<InstrumentAny> {
249    defs.iter()
250        .filter_map(|def| {
251            create_instrument_from_def(def, ts_init)
252                .map_err(|e| log::warn!("Failed to create instrument {}: {e}", def.symbol))
253                .ok()
254        })
255        .collect()
256}
257
258/// Rebuilds an instrument with a new tick size (price precision + price increment).
259///
260/// All other fields are preserved from `existing`. Returns a new `InstrumentAny`.
261pub fn rebuild_instrument_with_tick_size(
262    existing: &InstrumentAny,
263    new_tick_size: &str,
264    ts_event: UnixNanos,
265    ts_init: UnixNanos,
266) -> anyhow::Result<InstrumentAny> {
267    let bo = match existing {
268        InstrumentAny::BinaryOption(b) => b,
269        other => anyhow::bail!("Expected BinaryOption, was {other:?}"),
270    };
271
272    let tick_size: Decimal = new_tick_size
273        .parse()
274        .map_err(|e| anyhow::anyhow!("Failed to parse tick size '{new_tick_size}': {e}"))?;
275    let price_precision = tick_size.scale() as u8;
276    let price_increment = Price::from(tick_size.to_string());
277
278    let rebuilt = BinaryOption::new_checked(
279        bo.id,
280        bo.raw_symbol,
281        bo.asset_class,
282        bo.currency,
283        bo.activation_ns,
284        bo.expiration_ns,
285        price_precision,
286        bo.size_precision,
287        price_increment,
288        bo.size_increment,
289        bo.outcome,
290        bo.description,
291        bo.max_quantity,
292        None, // min_quantity: see `create_instrument_from_def`
293        bo.max_notional,
294        bo.min_notional,
295        bo.max_price,
296        bo.min_price,
297        Some(bo.margin_init),
298        Some(bo.margin_maint),
299        Some(bo.maker_fee),
300        Some(bo.taker_fee),
301        bo.info.clone(),
302        ts_event,
303        ts_init,
304    )?;
305
306    Ok(InstrumentAny::BinaryOption(rebuilt))
307}
308
309fn build_info_json(def: &PolymarketInstrumentDef) -> serde_json::Value {
310    let mut map = serde_json::Map::new();
311    map.insert(
312        "token_id".to_string(),
313        serde_json::Value::String(def.token_id.to_string()),
314    );
315    map.insert(
316        "condition_id".to_string(),
317        serde_json::Value::String(def.condition_id.to_string()),
318    );
319    map.insert(
320        "market_id".to_string(),
321        serde_json::Value::String(def.market_id.clone()),
322    );
323
324    if let Some(qid) = &def.question_id {
325        map.insert(
326            "question_id".to_string(),
327            serde_json::Value::String(qid.clone()),
328        );
329    }
330
331    if let Some(slug) = &def.market_slug {
332        map.insert(
333            "market_slug".to_string(),
334            serde_json::Value::String(slug.clone()),
335        );
336    }
337
338    map.insert(
339        "neg_risk".to_string(),
340        serde_json::Value::Bool(def.neg_risk),
341    );
342
343    if let Some(fee_schedule) = &def.fee_schedule
344        && let Ok(value) = serde_json::to_value(fee_schedule)
345    {
346        map.insert("fee_schedule".to_string(), value);
347    }
348
349    if let Some(game_id) = def.game_id {
350        map.insert("game_id".to_string(), serde_json::Value::from(game_id));
351    }
352
353    serde_json::Value::Object(map)
354}
355
356fn get_currency(code: &str) -> Currency {
357    Currency::try_from_str(code).unwrap_or_else(|| {
358        let currency = Currency::new(code, 6, 0, code, CurrencyType::Crypto);
359        if let Err(e) = Currency::register(currency, false) {
360            log::error!("Failed to register currency '{code}': {e}");
361        }
362        currency
363    })
364}
365
366fn parse_datetime_to_nanos(s: &str) -> Option<UnixNanos> {
367    chrono::DateTime::parse_from_rfc3339(s)
368        .ok()
369        .and_then(|dt| dt.timestamp_nanos_opt())
370        .map(|ns| UnixNanos::from(ns as u64))
371}
372
373#[cfg(test)]
374mod tests {
375    use nautilus_model::instruments::Instrument;
376    use rstest::rstest;
377    use rust_decimal_macros::dec;
378
379    use super::*;
380
381    fn load_gamma_market(filename: &str) -> GammaMarket {
382        let path = format!("test_data/{filename}");
383        let content = std::fs::read_to_string(path).expect("Failed to read test data");
384        serde_json::from_str(&content).expect("Failed to parse test data")
385    }
386
387    #[rstest]
388    fn test_parse_gamma_market_produces_two_defs() {
389        let market = load_gamma_market("gamma_market.json");
390        let defs = parse_gamma_market(&market).unwrap();
391
392        assert_eq!(defs.len(), 2);
393        assert_eq!(defs[0].outcome, PolymarketOutcome::from("Up"));
394        assert_eq!(defs[1].outcome, PolymarketOutcome::from("Down"));
395    }
396
397    #[rstest]
398    fn test_parse_gamma_market_fields() {
399        let market = load_gamma_market("gamma_market.json");
400        let defs = parse_gamma_market(&market).unwrap();
401        let yes_def = &defs[0];
402
403        assert_eq!(
404            yes_def.condition_id.as_str(),
405            "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b"
406        );
407        assert_eq!(yes_def.market_id, "1557558");
408        assert_eq!(
409            yes_def.question_id.as_deref(),
410            Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
411        );
412        assert_eq!(
413            yes_def.question,
414            "Bitcoin Up or Down - March 12, 5:20AM-5:25AM ET"
415        );
416        assert_eq!(yes_def.tick_size, dec!(0.01));
417        assert_eq!(yes_def.price_precision, 2);
418        assert_eq!(yes_def.min_size, Some(dec!(5.0)));
419        assert!(yes_def.maker_fee.is_none());
420        assert!(yes_def.taker_fee.is_none());
421        assert!(yes_def.active);
422        assert_eq!(
423            yes_def.market_slug.as_deref(),
424            Some("btc-updown-5m-1773307200")
425        );
426        assert_eq!(yes_def.game_id, None);
427    }
428
429    #[rstest]
430    fn test_parse_gamma_market_sports_game_id_and_fee_schedule() {
431        let money_line = load_gamma_market("gamma_market_sports_market_money_line.json");
432        let map_handicap = load_gamma_market("gamma_market_sports_market_map_handicap.json");
433
434        let money_line_defs = parse_gamma_market(&money_line).unwrap();
435        let map_handicap_defs = parse_gamma_market(&map_handicap).unwrap();
436
437        assert_eq!(money_line_defs[0].game_id, Some(1_427_074));
438        assert_eq!(map_handicap_defs[0].game_id, Some(1_427_074));
439        assert_eq!(money_line_defs[0].fee_schedule, money_line.fee_schedule);
440        assert_eq!(map_handicap_defs[0].fee_schedule, map_handicap.fee_schedule);
441
442        // Maker fee is always zero for feeSchedule-backed markets
443        assert_eq!(money_line_defs[0].maker_fee, Some(Decimal::ZERO));
444        // Taker fee comes from feeSchedule.rate (sports rate = 0.03)
445        assert_eq!(money_line_defs[0].taker_fee, Some(dec!(0.03)));
446    }
447
448    #[rstest]
449    fn test_parse_gamma_market_symbol_format() {
450        let market = load_gamma_market("gamma_market.json");
451        let defs = parse_gamma_market(&market).unwrap();
452
453        assert_eq!(
454            defs[0].symbol.as_str(),
455            "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875"
456        );
457        assert_eq!(
458            defs[1].symbol.as_str(),
459            "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-71183960810705820955071415844881728181970340514894896943812046065452395013351"
460        );
461    }
462
463    #[rstest]
464    fn test_parse_gamma_market_token_ids() {
465        let market = load_gamma_market("gamma_market.json");
466        let defs = parse_gamma_market(&market).unwrap();
467
468        assert_eq!(
469            defs[0].token_id.as_str(),
470            "104239898038807136052399800151408521467737075933964991162589336683346093173875"
471        );
472        assert_eq!(
473            defs[1].token_id.as_str(),
474            "71183960810705820955071415844881728181970340514894896943812046065452395013351"
475        );
476    }
477
478    #[rstest]
479    fn test_parse_gamma_market_derives_outcome_from_label() {
480        let mut market = load_gamma_market("gamma_market.json");
481
482        // Reverse the outcomes order so No comes first
483        market.outcomes = r#"["No", "Yes"]"#.to_string();
484
485        let defs = parse_gamma_market(&market).unwrap();
486
487        assert_eq!(defs[0].outcome, PolymarketOutcome::no());
488        assert_eq!(defs[1].outcome, PolymarketOutcome::yes());
489    }
490
491    #[rstest]
492    fn test_parse_gamma_market_accepts_arbitrary_outcome_label() {
493        let mut market = load_gamma_market("gamma_market.json");
494        market.outcomes = r#"["Maybe", "No"]"#.to_string();
495
496        let defs = parse_gamma_market(&market).unwrap();
497
498        assert_eq!(defs[0].outcome, PolymarketOutcome::from("Maybe"));
499        assert_eq!(defs[1].outcome, PolymarketOutcome::no());
500    }
501
502    #[rstest]
503    fn test_parse_gamma_market_null_tick_size_uses_default() {
504        let mut market = load_gamma_market("gamma_market.json");
505        market.order_price_min_tick_size = None;
506
507        let defs = parse_gamma_market(&market).unwrap();
508
509        assert_eq!(defs[0].tick_size, dec!(0.001));
510        assert_eq!(defs[0].price_precision, 3);
511    }
512
513    #[rstest]
514    fn test_parse_gamma_market_closed_is_inactive() {
515        let mut market = load_gamma_market("gamma_market.json");
516        market.closed = Some(true);
517
518        let defs = parse_gamma_market(&market).unwrap();
519
520        assert!(!defs[0].active);
521        assert!(!defs[1].active);
522    }
523
524    #[rstest]
525    fn test_create_instrument_from_def() {
526        let market = load_gamma_market("gamma_market.json");
527        let defs = parse_gamma_market(&market).unwrap();
528        let ts_init = UnixNanos::from(1_000_000_000u64);
529
530        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
531
532        let binary = match &instrument {
533            InstrumentAny::BinaryOption(b) => b,
534            other => panic!("Expected BinaryOption, was {other:?}"),
535        };
536
537        assert_eq!(
538            binary.id.to_string(),
539            "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875.POLYMARKET"
540        );
541        assert_eq!(binary.outcome, Some(Ustr::from("Up")));
542        assert_eq!(binary.asset_class, AssetClass::Alternative);
543        assert_eq!(binary.currency.code.as_str(), "pUSD");
544        assert_eq!(binary.price_precision, 2);
545        assert_eq!(binary.size_precision, 6);
546        assert_eq!(binary.price_increment(), Price::from("0.01"));
547        assert_eq!(binary.size_increment(), Quantity::from("0.000001"));
548    }
549
550    #[rstest]
551    fn test_create_instrument_info_params() {
552        let market = load_gamma_market("gamma_market.json");
553        let defs = parse_gamma_market(&market).unwrap();
554        let ts_init = UnixNanos::from(1_000_000_000u64);
555
556        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
557
558        let binary = match &instrument {
559            InstrumentAny::BinaryOption(b) => b,
560            other => panic!("Expected BinaryOption, was {other:?}"),
561        };
562
563        let info = binary.info.as_ref().expect("info should be Some");
564        assert_eq!(
565            info.get_str("token_id"),
566            Some("104239898038807136052399800151408521467737075933964991162589336683346093173875")
567        );
568        assert_eq!(
569            info.get_str("condition_id"),
570            Some("0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b")
571        );
572        assert_eq!(info.get_str("market_id"), Some("1557558"));
573        assert_eq!(
574            info.get_str("question_id"),
575            Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
576        );
577        assert_eq!(
578            info.get_str("market_slug"),
579            Some("btc-updown-5m-1773307200")
580        );
581        assert_eq!(info.get_u64("game_id"), None);
582        assert_eq!(info.get("fee_schedule"), None);
583    }
584
585    #[rstest]
586    fn test_create_instrument_info_params_includes_game_id_and_fee_schedule() {
587        let market = load_gamma_market("gamma_market_sports_market_money_line.json");
588        let defs = parse_gamma_market(&market).unwrap();
589        let ts_init = UnixNanos::from(1_000_000_000u64);
590
591        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
592
593        let binary = match &instrument {
594            InstrumentAny::BinaryOption(b) => b,
595            other => panic!("Expected BinaryOption, was {other:?}"),
596        };
597
598        let info = binary.info.as_ref().expect("info should be Some");
599        assert_eq!(info.get_u64("game_id"), Some(1_427_074));
600        assert!(info.get("fee_schedule").is_some());
601    }
602
603    #[rstest]
604    fn test_instruments_from_defs_batch() {
605        let market = load_gamma_market("gamma_market.json");
606        let defs = parse_gamma_market(&market).unwrap();
607        let ts_init = UnixNanos::from(1_000_000_000u64);
608
609        let instruments = instruments_from_defs(&defs, ts_init);
610
611        assert_eq!(instruments.len(), 2);
612    }
613
614    #[rstest]
615    fn test_create_instrument_max_min_price() {
616        let market = load_gamma_market("gamma_market.json");
617        let defs = parse_gamma_market(&market).unwrap();
618        let ts_init = UnixNanos::from(1_000_000_000u64);
619
620        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
621
622        let binary = match &instrument {
623            InstrumentAny::BinaryOption(b) => b,
624            other => panic!("Expected BinaryOption, was {other:?}"),
625        };
626
627        assert_eq!(binary.max_price, Some(Price::from("0.999")));
628        assert_eq!(binary.min_price, Some(Price::from("0.001")));
629    }
630
631    #[rstest]
632    fn test_rebuild_instrument_with_tick_size() {
633        let market = load_gamma_market("gamma_market.json");
634        let defs = parse_gamma_market(&market).unwrap();
635        let ts_init = UnixNanos::from(1_000_000_000u64);
636
637        // Original has tick_size 0.01 → price_precision 2
638        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
639        assert_eq!(instrument.price_precision(), 2);
640
641        let ts_event = UnixNanos::from(2_000_000_000u64);
642        let rebuilt =
643            rebuild_instrument_with_tick_size(&instrument, "0.001", ts_event, ts_event).unwrap();
644
645        assert_eq!(rebuilt.price_precision(), 3);
646        assert_eq!(rebuilt.price_increment(), Price::from("0.001"));
647    }
648
649    #[rstest]
650    fn test_rebuild_instrument_preserves_fields() {
651        let market = load_gamma_market("gamma_market.json");
652        let defs = parse_gamma_market(&market).unwrap();
653        let ts_init = UnixNanos::from(1_000_000_000u64);
654
655        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
656        let ts_event = UnixNanos::from(2_000_000_000u64);
657        let rebuilt =
658            rebuild_instrument_with_tick_size(&instrument, "0.01", ts_event, ts_event).unwrap();
659
660        assert_eq!(rebuilt.id(), instrument.id());
661        assert_eq!(rebuilt.raw_symbol(), instrument.raw_symbol());
662        assert_eq!(rebuilt.size_precision(), instrument.size_precision());
663
664        let orig_bo = match &instrument {
665            InstrumentAny::BinaryOption(b) => b,
666            _ => panic!(),
667        };
668        let new_bo = match &rebuilt {
669            InstrumentAny::BinaryOption(b) => b,
670            _ => panic!(),
671        };
672        assert_eq!(new_bo.outcome, orig_bo.outcome);
673        assert_eq!(new_bo.currency, orig_bo.currency);
674    }
675}