Skip to main content

nautilus_model/instruments/
binary_option.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
16use std::hash::{Hash, Hasher};
17
18use nautilus_core::{
19    Params, UnixNanos,
20    correctness::{CorrectnessResult, check_equal_u8},
21};
22use rust_decimal::Decimal;
23use serde::{Deserialize, Serialize};
24use ustr::Ustr;
25
26use super::{Instrument, any::InstrumentAny, tick_scheme::check_tick_scheme};
27use crate::{
28    enums::{AssetClass, InstrumentClass, OptionKind},
29    identifiers::{InstrumentId, Symbol},
30    types::{
31        currency::Currency,
32        money::Money,
33        price::{Price, check_positive_price},
34        quantity::{Quantity, check_positive_quantity},
35    },
36};
37
38/// Represents a generic binary option instrument.
39#[repr(C)]
40#[derive(Clone, Debug, Serialize, Deserialize)]
41#[cfg_attr(
42    feature = "python",
43    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
44)]
45#[cfg_attr(
46    feature = "python",
47    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
48)]
49pub struct BinaryOption {
50    /// The instrument ID.
51    pub id: InstrumentId,
52    /// The raw/local/native symbol for the instrument, assigned by the venue.
53    pub raw_symbol: Symbol,
54    /// The binary option asset class.
55    pub asset_class: AssetClass,
56    /// The binary option contract currency.
57    pub currency: Currency,
58    /// UNIX timestamp (nanoseconds) for contract activation.
59    pub activation_ns: UnixNanos,
60    /// UNIX timestamp (nanoseconds) for contract expiration.
61    pub expiration_ns: UnixNanos,
62    /// The price decimal precision.
63    pub price_precision: u8,
64    /// The trading size decimal precision.
65    pub size_precision: u8,
66    /// The minimum price increment (tick size).
67    pub price_increment: Price,
68    /// The minimum size increment.
69    pub size_increment: Quantity,
70    /// The initial (order) margin requirement in percentage of order value.
71    pub margin_init: Decimal,
72    /// The maintenance (position) margin in percentage of position value.
73    pub margin_maint: Decimal,
74    /// The venue-assigned identifier of the event containing the instrument's market.
75    #[serde(default)]
76    pub event_id: Option<Ustr>,
77    /// The binary outcome of the market.
78    pub outcome: Option<Ustr>,
79    /// The market description.
80    pub description: Option<Ustr>,
81    /// The maximum allowable order quantity.
82    pub max_quantity: Option<Quantity>,
83    /// The minimum allowable order quantity.
84    pub min_quantity: Option<Quantity>,
85    /// The maximum allowable order notional value.
86    pub max_notional: Option<Money>,
87    /// The minimum allowable order notional value.
88    pub min_notional: Option<Money>,
89    /// The maximum allowable quoted price.
90    pub max_price: Option<Price>,
91    /// The minimum allowable quoted price.
92    pub min_price: Option<Price>,
93    /// The registered variable tick scheme name.
94    pub tick_scheme: Option<Ustr>,
95    /// Additional instrument metadata as a JSON-serializable dictionary.
96    pub info: Option<Params>,
97    /// UNIX timestamp (nanoseconds) when the data event occurred.
98    pub ts_event: UnixNanos,
99    /// UNIX timestamp (nanoseconds) when the data object was initialized.
100    pub ts_init: UnixNanos,
101}
102
103#[bon::bon]
104impl BinaryOption {
105    #[expect(clippy::too_many_arguments)]
106    fn new_checked(
107        instrument_id: InstrumentId,
108        raw_symbol: Symbol,
109        asset_class: AssetClass,
110        currency: Currency,
111        activation_ns: UnixNanos,
112        expiration_ns: UnixNanos,
113        price_precision: u8,
114        size_precision: u8,
115        price_increment: Price,
116        size_increment: Quantity,
117        event_id: Option<Ustr>,
118        outcome: Option<Ustr>,
119        description: Option<Ustr>,
120        max_quantity: Option<Quantity>,
121        min_quantity: Option<Quantity>,
122        max_notional: Option<Money>,
123        min_notional: Option<Money>,
124        max_price: Option<Price>,
125        min_price: Option<Price>,
126        margin_init: Option<Decimal>,
127        margin_maint: Option<Decimal>,
128        tick_scheme: Option<Ustr>,
129        info: Option<Params>,
130        ts_event: UnixNanos,
131        ts_init: UnixNanos,
132    ) -> CorrectnessResult<Self> {
133        check_equal_u8(
134            price_precision,
135            price_increment.precision,
136            stringify!(price_precision),
137            stringify!(price_increment.precision),
138        )?;
139        check_equal_u8(
140            size_precision,
141            size_increment.precision,
142            stringify!(size_precision),
143            stringify!(size_increment.precision),
144        )?;
145        check_positive_price(price_increment, stringify!(price_increment))?;
146        check_positive_quantity(size_increment, stringify!(size_increment))?;
147        check_tick_scheme(tick_scheme)?;
148
149        Ok(Self {
150            id: instrument_id,
151            raw_symbol,
152            asset_class,
153            currency,
154            activation_ns,
155            expiration_ns,
156            price_precision,
157            size_precision,
158            price_increment,
159            size_increment,
160            margin_init: margin_init.unwrap_or_default(),
161            margin_maint: margin_maint.unwrap_or_default(),
162            event_id,
163            outcome,
164            description,
165            max_quantity,
166            min_quantity,
167            max_notional,
168            min_notional,
169            max_price,
170            min_price,
171            tick_scheme,
172            info,
173            ts_event,
174            ts_init,
175        })
176    }
177
178    /// Returns a fluent builder for a [`BinaryOption`] instance.
179    ///
180    /// Required fields are enforced at compile time; optional fields can be omitted and use the
181    /// same defaults as checked construction. The same correctness checks run on `build`.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if any input validation fails.
186    #[builder(start_fn = builder, finish_fn = build)]
187    pub fn build_checked(
188        instrument_id: InstrumentId,
189        raw_symbol: Symbol,
190        asset_class: AssetClass,
191        currency: Currency,
192        activation_ns: UnixNanos,
193        expiration_ns: UnixNanos,
194        price_precision: u8,
195        size_precision: u8,
196        price_increment: Price,
197        size_increment: Quantity,
198        event_id: Option<Ustr>,
199        outcome: Option<Ustr>,
200        description: Option<Ustr>,
201        max_quantity: Option<Quantity>,
202        min_quantity: Option<Quantity>,
203        max_notional: Option<Money>,
204        min_notional: Option<Money>,
205        max_price: Option<Price>,
206        min_price: Option<Price>,
207        margin_init: Option<Decimal>,
208        margin_maint: Option<Decimal>,
209        tick_scheme: Option<Ustr>,
210        info: Option<Params>,
211        ts_event: UnixNanos,
212        ts_init: UnixNanos,
213    ) -> CorrectnessResult<Self> {
214        Self::new_checked(
215            instrument_id,
216            raw_symbol,
217            asset_class,
218            currency,
219            activation_ns,
220            expiration_ns,
221            price_precision,
222            size_precision,
223            price_increment,
224            size_increment,
225            event_id,
226            outcome,
227            description,
228            max_quantity,
229            min_quantity,
230            max_notional,
231            min_notional,
232            max_price,
233            min_price,
234            margin_init,
235            margin_maint,
236            tick_scheme,
237            info,
238            ts_event,
239            ts_init,
240        )
241    }
242}
243
244impl PartialEq<Self> for BinaryOption {
245    fn eq(&self, other: &Self) -> bool {
246        self.id == other.id
247    }
248}
249
250impl Eq for BinaryOption {}
251
252impl Hash for BinaryOption {
253    fn hash<H: Hasher>(&self, state: &mut H) {
254        self.id.hash(state);
255    }
256}
257
258impl Instrument for BinaryOption {
259    fn into_any(self) -> InstrumentAny {
260        InstrumentAny::BinaryOption(self)
261    }
262
263    fn id(&self) -> InstrumentId {
264        self.id
265    }
266
267    fn raw_symbol(&self) -> Symbol {
268        self.raw_symbol
269    }
270
271    fn asset_class(&self) -> AssetClass {
272        self.asset_class
273    }
274
275    fn instrument_class(&self) -> InstrumentClass {
276        InstrumentClass::BinaryOption
277    }
278
279    fn underlying(&self) -> Option<Ustr> {
280        None
281    }
282
283    fn base_currency(&self) -> Option<Currency> {
284        None
285    }
286
287    fn quote_currency(&self) -> Currency {
288        self.currency
289    }
290
291    fn settlement_currency(&self) -> Currency {
292        self.currency
293    }
294
295    fn isin(&self) -> Option<Ustr> {
296        None
297    }
298
299    fn exchange(&self) -> Option<Ustr> {
300        None
301    }
302
303    fn option_kind(&self) -> Option<OptionKind> {
304        None
305    }
306
307    fn is_inverse(&self) -> bool {
308        false
309    }
310
311    fn price_precision(&self) -> u8 {
312        self.price_precision
313    }
314
315    fn size_precision(&self) -> u8 {
316        self.size_precision
317    }
318
319    fn price_increment(&self) -> Price {
320        self.price_increment
321    }
322
323    fn size_increment(&self) -> Quantity {
324        self.size_increment
325    }
326
327    fn multiplier(&self) -> Quantity {
328        Quantity::from(1)
329    }
330
331    fn lot_size(&self) -> Option<Quantity> {
332        Some(Quantity::from(1))
333    }
334
335    fn max_quantity(&self) -> Option<Quantity> {
336        self.max_quantity
337    }
338
339    fn min_quantity(&self) -> Option<Quantity> {
340        self.min_quantity
341    }
342
343    fn max_price(&self) -> Option<Price> {
344        self.max_price
345    }
346
347    fn min_price(&self) -> Option<Price> {
348        self.min_price
349    }
350
351    fn tick_scheme(&self) -> Option<Ustr> {
352        self.tick_scheme
353    }
354
355    fn info(&self) -> Option<&Params> {
356        self.info.as_ref()
357    }
358
359    fn ts_event(&self) -> UnixNanos {
360        self.ts_event
361    }
362
363    fn ts_init(&self) -> UnixNanos {
364        self.ts_init
365    }
366
367    fn margin_init(&self) -> Decimal {
368        self.margin_init
369    }
370
371    fn margin_maint(&self) -> Decimal {
372        self.margin_maint
373    }
374
375    fn strike_price(&self) -> Option<Price> {
376        None
377    }
378
379    fn activation_ns(&self) -> Option<UnixNanos> {
380        Some(self.activation_ns)
381    }
382
383    fn expiration_ns(&self) -> Option<UnixNanos> {
384        Some(self.expiration_ns)
385    }
386
387    fn max_notional(&self) -> Option<Money> {
388        self.max_notional
389    }
390
391    fn min_notional(&self) -> Option<Money> {
392        self.min_notional
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use rstest::rstest;
399    use rust_decimal_macros::dec;
400
401    use crate::{
402        enums::{AssetClass, InstrumentClass},
403        identifiers::{InstrumentId, Symbol},
404        instruments::{BinaryOption, Instrument, stubs::*},
405        types::{Currency, Money, Price, Quantity},
406    };
407
408    #[rstest]
409    fn test_trait_accessors(binary_option: BinaryOption) {
410        assert_eq!(binary_option.asset_class(), AssetClass::Alternative);
411        assert_eq!(
412            binary_option.instrument_class(),
413            InstrumentClass::BinaryOption
414        );
415        assert_eq!(binary_option.quote_currency(), Currency::USDC());
416        assert!(!binary_option.is_inverse());
417        assert_eq!(binary_option.price_precision(), 3);
418        assert_eq!(binary_option.size_precision(), 2);
419        assert!(binary_option.activation_ns().is_some());
420        assert!(binary_option.expiration_ns().is_some());
421    }
422
423    #[rstest]
424    fn test_new_checked_price_precision_mismatch() {
425        let result = BinaryOption::new_checked(
426            InstrumentId::from("TEST.POLYMARKET"),
427            Symbol::from("TEST"),
428            AssetClass::Alternative,
429            Currency::USDC(),
430            0.into(),
431            0.into(),
432            4, // mismatch
433            2,
434            Price::from("0.001"),
435            Quantity::from("0.01"),
436            None,
437            None,
438            None,
439            None,
440            None,
441            None,
442            None,
443            None,
444            None,
445            None,
446            None,
447            None,
448            None,
449            0.into(),
450            0.into(),
451        );
452        assert!(result.is_err());
453    }
454
455    #[rstest]
456    fn test_event_id_serialization_and_legacy_default(mut binary_option: BinaryOption) {
457        binary_option.event_id = Some("event-123".into());
458        let mut value = serde_json::to_value(&binary_option).unwrap();
459        let restored: BinaryOption = serde_json::from_value(value.clone()).unwrap();
460        assert_eq!(restored.event_id, binary_option.event_id);
461        value.as_object_mut().unwrap().remove("event_id");
462        let restored: BinaryOption = serde_json::from_value(value).unwrap();
463        assert_eq!(restored.event_id, None);
464    }
465
466    #[rstest]
467    fn test_serialization_roundtrip(binary_option: BinaryOption) {
468        let json = serde_json::to_string(&binary_option).unwrap();
469        let deserialized: BinaryOption = serde_json::from_str(&json).unwrap();
470        assert_eq!(json, serde_json::to_string(&deserialized).unwrap());
471    }
472
473    #[rstest]
474    fn test_builder_matches_new_checked() {
475        let positional = BinaryOption::new_checked(
476            InstrumentId::from("TEST.POLYMARKET"),
477            Symbol::from("TEST"),
478            AssetClass::Alternative,
479            Currency::USDC(),
480            1.into(),
481            2.into(),
482            3,
483            2,
484            Price::from("0.001"),
485            Quantity::from("0.01"),
486            Some("event-123".into()),
487            Some("Yes".into()),
488            Some("Will it happen?".into()),
489            Some(Quantity::from("10000.00")),
490            Some(Quantity::from("5.00")),
491            Some(Money::from("100000 USDC")),
492            Some(Money::from("10 USDC")),
493            Some(Price::from("0.999")),
494            Some(Price::from("0.001")),
495            Some(dec!(0.01)),
496            Some(dec!(0.02)),
497            None,
498            None,
499            3.into(),
500            4.into(),
501        )
502        .unwrap();
503
504        let built = BinaryOption::builder()
505            .instrument_id(InstrumentId::from("TEST.POLYMARKET"))
506            .raw_symbol(Symbol::from("TEST"))
507            .asset_class(AssetClass::Alternative)
508            .currency(Currency::USDC())
509            .activation_ns(1.into())
510            .expiration_ns(2.into())
511            .price_precision(3)
512            .size_precision(2)
513            .price_increment(Price::from("0.001"))
514            .size_increment(Quantity::from("0.01"))
515            .event_id("event-123".into())
516            .outcome("Yes".into())
517            .description("Will it happen?".into())
518            .max_quantity(Quantity::from("10000.00"))
519            .min_quantity(Quantity::from("5.00"))
520            .max_notional(Money::from("100000 USDC"))
521            .min_notional(Money::from("10 USDC"))
522            .max_price(Price::from("0.999"))
523            .min_price(Price::from("0.001"))
524            .margin_init(dec!(0.01))
525            .margin_maint(dec!(0.02))
526            .ts_event(3.into())
527            .ts_init(4.into())
528            .build()
529            .unwrap();
530
531        assert_eq!(
532            serde_json::to_value(&positional).unwrap(),
533            serde_json::to_value(&built).unwrap(),
534        );
535    }
536}