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        None,
236        Some(info),
237        ts_init,
238        ts_init,
239    )?;
240
241    Ok(InstrumentAny::BinaryOption(binary_option))
242}
243
244/// Converts a collection of definitions into Nautilus instruments.
245#[must_use]
246pub fn instruments_from_defs(
247    defs: &[PolymarketInstrumentDef],
248    ts_init: UnixNanos,
249) -> Vec<InstrumentAny> {
250    defs.iter()
251        .filter_map(|def| {
252            create_instrument_from_def(def, ts_init)
253                .map_err(|e| log::warn!("Failed to create instrument {}: {e}", def.symbol))
254                .ok()
255        })
256        .collect()
257}
258
259/// Rebuilds an instrument with a new tick size (price precision + price increment).
260///
261/// All other fields are preserved from `existing`. Returns a new `InstrumentAny`.
262pub fn rebuild_instrument_with_tick_size(
263    existing: &InstrumentAny,
264    new_tick_size: &str,
265    ts_event: UnixNanos,
266    ts_init: UnixNanos,
267) -> anyhow::Result<InstrumentAny> {
268    let bo = match existing {
269        InstrumentAny::BinaryOption(b) => b,
270        other => anyhow::bail!("Expected BinaryOption, was {other:?}"),
271    };
272
273    let tick_size: Decimal = new_tick_size
274        .parse()
275        .map_err(|e| anyhow::anyhow!("Failed to parse tick size '{new_tick_size}': {e}"))?;
276    let price_precision = tick_size.scale() as u8;
277    let price_increment = Price::from(tick_size.to_string());
278
279    let rebuilt = BinaryOption::new_checked(
280        bo.id,
281        bo.raw_symbol,
282        bo.asset_class,
283        bo.currency,
284        bo.activation_ns,
285        bo.expiration_ns,
286        price_precision,
287        bo.size_precision,
288        price_increment,
289        bo.size_increment,
290        bo.outcome,
291        bo.description,
292        bo.max_quantity,
293        None, // min_quantity: see `create_instrument_from_def`
294        bo.max_notional,
295        bo.min_notional,
296        bo.max_price,
297        bo.min_price,
298        Some(bo.margin_init),
299        Some(bo.margin_maint),
300        Some(bo.maker_fee),
301        Some(bo.taker_fee),
302        None,
303        bo.info.clone(),
304        ts_event,
305        ts_init,
306    )?;
307
308    Ok(InstrumentAny::BinaryOption(rebuilt))
309}
310
311fn build_info_json(def: &PolymarketInstrumentDef) -> serde_json::Value {
312    let mut map = serde_json::Map::new();
313    map.insert(
314        "token_id".to_string(),
315        serde_json::Value::String(def.token_id.to_string()),
316    );
317    map.insert(
318        "condition_id".to_string(),
319        serde_json::Value::String(def.condition_id.to_string()),
320    );
321    map.insert(
322        "market_id".to_string(),
323        serde_json::Value::String(def.market_id.clone()),
324    );
325
326    if let Some(qid) = &def.question_id {
327        map.insert(
328            "question_id".to_string(),
329            serde_json::Value::String(qid.clone()),
330        );
331    }
332
333    if let Some(slug) = &def.market_slug {
334        map.insert(
335            "market_slug".to_string(),
336            serde_json::Value::String(slug.clone()),
337        );
338    }
339
340    map.insert(
341        "neg_risk".to_string(),
342        serde_json::Value::Bool(def.neg_risk),
343    );
344
345    if let Some(fee_schedule) = &def.fee_schedule
346        && let Ok(value) = serde_json::to_value(fee_schedule)
347    {
348        map.insert("fee_schedule".to_string(), value);
349    }
350
351    if let Some(game_id) = def.game_id {
352        map.insert("game_id".to_string(), serde_json::Value::from(game_id));
353    }
354
355    serde_json::Value::Object(map)
356}
357
358fn get_currency(code: &str) -> Currency {
359    Currency::try_from_str(code).unwrap_or_else(|| {
360        let currency = Currency::new(code, 6, 0, code, CurrencyType::Crypto);
361        if let Err(e) = Currency::register(currency, false) {
362            log::error!("Failed to register currency '{code}': {e}");
363        }
364        currency
365    })
366}
367
368fn parse_datetime_to_nanos(s: &str) -> Option<UnixNanos> {
369    chrono::DateTime::parse_from_rfc3339(s)
370        .ok()
371        .and_then(|dt| dt.timestamp_nanos_opt())
372        .map(|ns| UnixNanos::from(ns as u64))
373}
374
375#[cfg(test)]
376mod tests {
377    use nautilus_model::instruments::Instrument;
378    use rstest::rstest;
379    use rust_decimal_macros::dec;
380
381    use super::*;
382
383    fn load_gamma_market(filename: &str) -> GammaMarket {
384        let path = format!("test_data/{filename}");
385        let content = std::fs::read_to_string(path).expect("Failed to read test data");
386        serde_json::from_str(&content).expect("Failed to parse test data")
387    }
388
389    #[rstest]
390    fn test_parse_gamma_market_produces_two_defs() {
391        let market = load_gamma_market("gamma_market.json");
392        let defs = parse_gamma_market(&market).unwrap();
393
394        assert_eq!(defs.len(), 2);
395        assert_eq!(defs[0].outcome, PolymarketOutcome::from("Up"));
396        assert_eq!(defs[1].outcome, PolymarketOutcome::from("Down"));
397    }
398
399    #[rstest]
400    fn test_parse_gamma_market_fields() {
401        let market = load_gamma_market("gamma_market.json");
402        let defs = parse_gamma_market(&market).unwrap();
403        let yes_def = &defs[0];
404
405        assert_eq!(
406            yes_def.condition_id.as_str(),
407            "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b"
408        );
409        assert_eq!(yes_def.market_id, "1557558");
410        assert_eq!(
411            yes_def.question_id.as_deref(),
412            Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
413        );
414        assert_eq!(
415            yes_def.question,
416            "Bitcoin Up or Down - March 12, 5:20AM-5:25AM ET"
417        );
418        assert_eq!(yes_def.tick_size, dec!(0.01));
419        assert_eq!(yes_def.price_precision, 2);
420        assert_eq!(yes_def.min_size, Some(dec!(5.0)));
421        assert!(yes_def.maker_fee.is_none());
422        assert!(yes_def.taker_fee.is_none());
423        assert!(yes_def.active);
424        assert_eq!(
425            yes_def.market_slug.as_deref(),
426            Some("btc-updown-5m-1773307200")
427        );
428        assert_eq!(yes_def.game_id, None);
429    }
430
431    #[rstest]
432    fn test_parse_gamma_market_sports_game_id_and_fee_schedule() {
433        let money_line = load_gamma_market("gamma_market_sports_market_money_line.json");
434        let map_handicap = load_gamma_market("gamma_market_sports_market_map_handicap.json");
435
436        let money_line_defs = parse_gamma_market(&money_line).unwrap();
437        let map_handicap_defs = parse_gamma_market(&map_handicap).unwrap();
438
439        assert_eq!(money_line_defs[0].game_id, Some(1_427_074));
440        assert_eq!(map_handicap_defs[0].game_id, Some(1_427_074));
441        assert_eq!(money_line_defs[0].fee_schedule, money_line.fee_schedule);
442        assert_eq!(map_handicap_defs[0].fee_schedule, map_handicap.fee_schedule);
443
444        // Maker fee is always zero for feeSchedule-backed markets
445        assert_eq!(money_line_defs[0].maker_fee, Some(Decimal::ZERO));
446        // Taker fee comes from feeSchedule.rate (sports rate = 0.03)
447        assert_eq!(money_line_defs[0].taker_fee, Some(dec!(0.03)));
448    }
449
450    #[rstest]
451    fn test_parse_gamma_market_symbol_format() {
452        let market = load_gamma_market("gamma_market.json");
453        let defs = parse_gamma_market(&market).unwrap();
454
455        assert_eq!(
456            defs[0].symbol.as_str(),
457            "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875"
458        );
459        assert_eq!(
460            defs[1].symbol.as_str(),
461            "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-71183960810705820955071415844881728181970340514894896943812046065452395013351"
462        );
463    }
464
465    #[rstest]
466    fn test_parse_gamma_market_token_ids() {
467        let market = load_gamma_market("gamma_market.json");
468        let defs = parse_gamma_market(&market).unwrap();
469
470        assert_eq!(
471            defs[0].token_id.as_str(),
472            "104239898038807136052399800151408521467737075933964991162589336683346093173875"
473        );
474        assert_eq!(
475            defs[1].token_id.as_str(),
476            "71183960810705820955071415844881728181970340514894896943812046065452395013351"
477        );
478    }
479
480    #[rstest]
481    fn test_parse_gamma_market_derives_outcome_from_label() {
482        let mut market = load_gamma_market("gamma_market.json");
483
484        // Reverse the outcomes order so No comes first
485        market.outcomes = r#"["No", "Yes"]"#.to_string();
486
487        let defs = parse_gamma_market(&market).unwrap();
488
489        assert_eq!(defs[0].outcome, PolymarketOutcome::no());
490        assert_eq!(defs[1].outcome, PolymarketOutcome::yes());
491    }
492
493    #[rstest]
494    fn test_parse_gamma_market_accepts_arbitrary_outcome_label() {
495        let mut market = load_gamma_market("gamma_market.json");
496        market.outcomes = r#"["Maybe", "No"]"#.to_string();
497
498        let defs = parse_gamma_market(&market).unwrap();
499
500        assert_eq!(defs[0].outcome, PolymarketOutcome::from("Maybe"));
501        assert_eq!(defs[1].outcome, PolymarketOutcome::no());
502    }
503
504    #[rstest]
505    fn test_parse_gamma_market_null_tick_size_uses_default() {
506        let mut market = load_gamma_market("gamma_market.json");
507        market.order_price_min_tick_size = None;
508
509        let defs = parse_gamma_market(&market).unwrap();
510
511        assert_eq!(defs[0].tick_size, dec!(0.001));
512        assert_eq!(defs[0].price_precision, 3);
513    }
514
515    #[rstest]
516    fn test_parse_gamma_market_closed_is_inactive() {
517        let mut market = load_gamma_market("gamma_market.json");
518        market.closed = Some(true);
519
520        let defs = parse_gamma_market(&market).unwrap();
521
522        assert!(!defs[0].active);
523        assert!(!defs[1].active);
524    }
525
526    #[rstest]
527    fn test_create_instrument_from_def() {
528        let market = load_gamma_market("gamma_market.json");
529        let defs = parse_gamma_market(&market).unwrap();
530        let ts_init = UnixNanos::from(1_000_000_000u64);
531
532        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
533
534        let binary = match &instrument {
535            InstrumentAny::BinaryOption(b) => b,
536            other => panic!("Expected BinaryOption, was {other:?}"),
537        };
538
539        assert_eq!(
540            binary.id.to_string(),
541            "0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b-104239898038807136052399800151408521467737075933964991162589336683346093173875.POLYMARKET"
542        );
543        assert_eq!(binary.outcome, Some(Ustr::from("Up")));
544        assert_eq!(binary.asset_class, AssetClass::Alternative);
545        assert_eq!(binary.currency.code.as_str(), "pUSD");
546        assert_eq!(binary.price_precision, 2);
547        assert_eq!(binary.size_precision, 6);
548        assert_eq!(binary.price_increment(), Price::from("0.01"));
549        assert_eq!(binary.size_increment(), Quantity::from("0.000001"));
550    }
551
552    #[rstest]
553    fn test_create_instrument_info_params() {
554        let market = load_gamma_market("gamma_market.json");
555        let defs = parse_gamma_market(&market).unwrap();
556        let ts_init = UnixNanos::from(1_000_000_000u64);
557
558        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
559
560        let binary = match &instrument {
561            InstrumentAny::BinaryOption(b) => b,
562            other => panic!("Expected BinaryOption, was {other:?}"),
563        };
564
565        let info = binary.info.as_ref().expect("info should be Some");
566        assert_eq!(
567            info.get_str("token_id"),
568            Some("104239898038807136052399800151408521467737075933964991162589336683346093173875")
569        );
570        assert_eq!(
571            info.get_str("condition_id"),
572            Some("0x78443f961b9a65869dcb39359de9960165c7e5cbad0904eac7f29cd77872a63b")
573        );
574        assert_eq!(info.get_str("market_id"), Some("1557558"));
575        assert_eq!(
576            info.get_str("question_id"),
577            Some("0x15813764bba41cfb5f99e2e649cfbae7a121a9f8f91ed47ca261aab95e9729de")
578        );
579        assert_eq!(
580            info.get_str("market_slug"),
581            Some("btc-updown-5m-1773307200")
582        );
583        assert_eq!(info.get_u64("game_id"), None);
584        assert_eq!(info.get("fee_schedule"), None);
585    }
586
587    #[rstest]
588    fn test_create_instrument_info_params_includes_game_id_and_fee_schedule() {
589        let market = load_gamma_market("gamma_market_sports_market_money_line.json");
590        let defs = parse_gamma_market(&market).unwrap();
591        let ts_init = UnixNanos::from(1_000_000_000u64);
592
593        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
594
595        let binary = match &instrument {
596            InstrumentAny::BinaryOption(b) => b,
597            other => panic!("Expected BinaryOption, was {other:?}"),
598        };
599
600        let info = binary.info.as_ref().expect("info should be Some");
601        assert_eq!(info.get_u64("game_id"), Some(1_427_074));
602        assert!(info.get("fee_schedule").is_some());
603    }
604
605    #[rstest]
606    fn test_instruments_from_defs_batch() {
607        let market = load_gamma_market("gamma_market.json");
608        let defs = parse_gamma_market(&market).unwrap();
609        let ts_init = UnixNanos::from(1_000_000_000u64);
610
611        let instruments = instruments_from_defs(&defs, ts_init);
612
613        assert_eq!(instruments.len(), 2);
614    }
615
616    #[rstest]
617    fn test_create_instrument_max_min_price() {
618        let market = load_gamma_market("gamma_market.json");
619        let defs = parse_gamma_market(&market).unwrap();
620        let ts_init = UnixNanos::from(1_000_000_000u64);
621
622        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
623
624        let binary = match &instrument {
625            InstrumentAny::BinaryOption(b) => b,
626            other => panic!("Expected BinaryOption, was {other:?}"),
627        };
628
629        assert_eq!(binary.max_price, Some(Price::from("0.999")));
630        assert_eq!(binary.min_price, Some(Price::from("0.001")));
631    }
632
633    #[rstest]
634    fn test_rebuild_instrument_with_tick_size() {
635        let market = load_gamma_market("gamma_market.json");
636        let defs = parse_gamma_market(&market).unwrap();
637        let ts_init = UnixNanos::from(1_000_000_000u64);
638
639        // Original has tick_size 0.01 → price_precision 2
640        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
641        assert_eq!(instrument.price_precision(), 2);
642
643        let ts_event = UnixNanos::from(2_000_000_000u64);
644        let rebuilt =
645            rebuild_instrument_with_tick_size(&instrument, "0.001", ts_event, ts_event).unwrap();
646
647        assert_eq!(rebuilt.price_precision(), 3);
648        assert_eq!(rebuilt.price_increment(), Price::from("0.001"));
649    }
650
651    #[rstest]
652    fn test_rebuild_instrument_preserves_fields() {
653        let market = load_gamma_market("gamma_market.json");
654        let defs = parse_gamma_market(&market).unwrap();
655        let ts_init = UnixNanos::from(1_000_000_000u64);
656
657        let instrument = create_instrument_from_def(&defs[0], ts_init).unwrap();
658        let ts_event = UnixNanos::from(2_000_000_000u64);
659        let rebuilt =
660            rebuild_instrument_with_tick_size(&instrument, "0.01", ts_event, ts_event).unwrap();
661
662        assert_eq!(rebuilt.id(), instrument.id());
663        assert_eq!(rebuilt.raw_symbol(), instrument.raw_symbol());
664        assert_eq!(rebuilt.size_precision(), instrument.size_precision());
665
666        let orig_bo = match &instrument {
667            InstrumentAny::BinaryOption(b) => b,
668            _ => panic!(),
669        };
670        let new_bo = match &rebuilt {
671            InstrumentAny::BinaryOption(b) => b,
672            _ => panic!(),
673        };
674        assert_eq!(new_bo.outcome, orig_bo.outcome);
675        assert_eq!(new_bo.currency, orig_bo.currency);
676    }
677}