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, CorrectnessResultExt, FAILED, 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.core.nautilus_pyo3.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 fee rate for liquidity makers as a percentage of order value.
75    pub maker_fee: Decimal,
76    /// The fee rate for liquidity takers as a percentage of order value.
77    pub taker_fee: Decimal,
78    /// The binary outcome of the market.
79    pub outcome: Option<Ustr>,
80    /// The market description.
81    pub description: Option<Ustr>,
82    /// The maximum allowable order quantity.
83    pub max_quantity: Option<Quantity>,
84    /// The minimum allowable order quantity.
85    pub min_quantity: Option<Quantity>,
86    /// The maximum allowable order notional value.
87    pub max_notional: Option<Money>,
88    /// The minimum allowable order notional value.
89    pub min_notional: Option<Money>,
90    /// The maximum allowable quoted price.
91    pub max_price: Option<Price>,
92    /// The minimum allowable quoted price.
93    pub min_price: Option<Price>,
94    /// The registered variable tick scheme name.
95    pub tick_scheme: Option<Ustr>,
96    /// Additional instrument metadata as a JSON-serializable dictionary.
97    pub info: Option<Params>,
98    /// UNIX timestamp (nanoseconds) when the data event occurred.
99    pub ts_event: UnixNanos,
100    /// UNIX timestamp (nanoseconds) when the data object was initialized.
101    pub ts_init: UnixNanos,
102}
103
104#[bon::bon]
105impl BinaryOption {
106    /// Creates a new [`BinaryOption`] instance with correctness checking.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error if any input validation fails (e.g., invalid precision or increments).
111    ///
112    /// # Notes
113    ///
114    /// PyO3 requires a `Result` type for proper error handling and stacktrace printing in Python.
115    #[expect(clippy::too_many_arguments)]
116    pub fn new_checked(
117        instrument_id: InstrumentId,
118        raw_symbol: Symbol,
119        asset_class: AssetClass,
120        currency: Currency,
121        activation_ns: UnixNanos,
122        expiration_ns: UnixNanos,
123        price_precision: u8,
124        size_precision: u8,
125        price_increment: Price,
126        size_increment: Quantity,
127        outcome: Option<Ustr>,
128        description: Option<Ustr>,
129        max_quantity: Option<Quantity>,
130        min_quantity: Option<Quantity>,
131        max_notional: Option<Money>,
132        min_notional: Option<Money>,
133        max_price: Option<Price>,
134        min_price: Option<Price>,
135        margin_init: Option<Decimal>,
136        margin_maint: Option<Decimal>,
137        maker_fee: Option<Decimal>,
138        taker_fee: Option<Decimal>,
139        tick_scheme: Option<Ustr>,
140        info: Option<Params>,
141        ts_event: UnixNanos,
142        ts_init: UnixNanos,
143    ) -> CorrectnessResult<Self> {
144        check_equal_u8(
145            price_precision,
146            price_increment.precision,
147            stringify!(price_precision),
148            stringify!(price_increment.precision),
149        )?;
150        check_equal_u8(
151            size_precision,
152            size_increment.precision,
153            stringify!(size_precision),
154            stringify!(size_increment.precision),
155        )?;
156        check_positive_price(price_increment, stringify!(price_increment))?;
157        check_positive_quantity(size_increment, stringify!(size_increment))?;
158        check_tick_scheme(tick_scheme)?;
159
160        Ok(Self {
161            id: instrument_id,
162            raw_symbol,
163            asset_class,
164            currency,
165            activation_ns,
166            expiration_ns,
167            price_precision,
168            size_precision,
169            price_increment,
170            size_increment,
171            margin_init: margin_init.unwrap_or_default(),
172            margin_maint: margin_maint.unwrap_or_default(),
173            maker_fee: maker_fee.unwrap_or_default(),
174            taker_fee: taker_fee.unwrap_or_default(),
175            outcome,
176            description,
177            max_quantity,
178            min_quantity,
179            max_notional,
180            min_notional,
181            max_price,
182            min_price,
183            tick_scheme,
184            info,
185            ts_event,
186            ts_init,
187        })
188    }
189
190    /// Creates a new [`BinaryOption`] instance by validating parameters.
191    ///
192    /// # Panics
193    ///
194    /// Panics if parameter validation fails during `new_checked`.
195    #[expect(clippy::too_many_arguments)]
196    #[must_use]
197    pub fn new(
198        instrument_id: InstrumentId,
199        raw_symbol: Symbol,
200        asset_class: AssetClass,
201        currency: Currency,
202        activation_ns: UnixNanos,
203        expiration_ns: UnixNanos,
204        price_precision: u8,
205        size_precision: u8,
206        price_increment: Price,
207        size_increment: Quantity,
208        outcome: Option<Ustr>,
209        description: Option<Ustr>,
210        max_quantity: Option<Quantity>,
211        min_quantity: Option<Quantity>,
212        max_notional: Option<Money>,
213        min_notional: Option<Money>,
214        max_price: Option<Price>,
215        min_price: Option<Price>,
216        margin_init: Option<Decimal>,
217        margin_maint: Option<Decimal>,
218        maker_fee: Option<Decimal>,
219        taker_fee: Option<Decimal>,
220        tick_scheme: Option<Ustr>,
221        info: Option<Params>,
222        ts_event: UnixNanos,
223        ts_init: UnixNanos,
224    ) -> Self {
225        Self::new_checked(
226            instrument_id,
227            raw_symbol,
228            asset_class,
229            currency,
230            activation_ns,
231            expiration_ns,
232            price_precision,
233            size_precision,
234            price_increment,
235            size_increment,
236            outcome,
237            description,
238            max_quantity,
239            min_quantity,
240            max_notional,
241            min_notional,
242            max_price,
243            min_price,
244            margin_init,
245            margin_maint,
246            maker_fee,
247            taker_fee,
248            tick_scheme,
249            info,
250            ts_event,
251            ts_init,
252        )
253        .expect_display(FAILED)
254    }
255
256    /// Returns a fluent builder for a [`BinaryOption`] instance.
257    ///
258    /// Required fields are enforced at compile time; optional fields can be omitted and default
259    /// the same way they do in [`BinaryOption::new_checked`], which the builder calls so the
260    /// same correctness checks run on `build`.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if any input validation fails (see [`BinaryOption::new_checked`]).
265    #[builder(start_fn = builder, finish_fn = build)]
266    pub fn build_checked(
267        instrument_id: InstrumentId,
268        raw_symbol: Symbol,
269        asset_class: AssetClass,
270        currency: Currency,
271        activation_ns: UnixNanos,
272        expiration_ns: UnixNanos,
273        price_precision: u8,
274        size_precision: u8,
275        price_increment: Price,
276        size_increment: Quantity,
277        outcome: Option<Ustr>,
278        description: Option<Ustr>,
279        max_quantity: Option<Quantity>,
280        min_quantity: Option<Quantity>,
281        max_notional: Option<Money>,
282        min_notional: Option<Money>,
283        max_price: Option<Price>,
284        min_price: Option<Price>,
285        margin_init: Option<Decimal>,
286        margin_maint: Option<Decimal>,
287        maker_fee: Option<Decimal>,
288        taker_fee: Option<Decimal>,
289        tick_scheme: Option<Ustr>,
290        info: Option<Params>,
291        ts_event: UnixNanos,
292        ts_init: UnixNanos,
293    ) -> CorrectnessResult<Self> {
294        Self::new_checked(
295            instrument_id,
296            raw_symbol,
297            asset_class,
298            currency,
299            activation_ns,
300            expiration_ns,
301            price_precision,
302            size_precision,
303            price_increment,
304            size_increment,
305            outcome,
306            description,
307            max_quantity,
308            min_quantity,
309            max_notional,
310            min_notional,
311            max_price,
312            min_price,
313            margin_init,
314            margin_maint,
315            maker_fee,
316            taker_fee,
317            tick_scheme,
318            info,
319            ts_event,
320            ts_init,
321        )
322    }
323}
324
325impl PartialEq<Self> for BinaryOption {
326    fn eq(&self, other: &Self) -> bool {
327        self.id == other.id
328    }
329}
330
331impl Eq for BinaryOption {}
332
333impl Hash for BinaryOption {
334    fn hash<H: Hasher>(&self, state: &mut H) {
335        self.id.hash(state);
336    }
337}
338
339impl Instrument for BinaryOption {
340    fn tick_scheme(&self) -> Option<Ustr> {
341        self.tick_scheme
342    }
343    fn into_any(self) -> InstrumentAny {
344        InstrumentAny::BinaryOption(self)
345    }
346
347    fn id(&self) -> InstrumentId {
348        self.id
349    }
350
351    fn raw_symbol(&self) -> Symbol {
352        self.raw_symbol
353    }
354
355    fn asset_class(&self) -> AssetClass {
356        self.asset_class
357    }
358
359    fn instrument_class(&self) -> InstrumentClass {
360        InstrumentClass::BinaryOption
361    }
362
363    fn underlying(&self) -> Option<Ustr> {
364        None
365    }
366
367    fn base_currency(&self) -> Option<Currency> {
368        None
369    }
370
371    fn quote_currency(&self) -> Currency {
372        self.currency
373    }
374
375    fn settlement_currency(&self) -> Currency {
376        self.currency
377    }
378
379    fn isin(&self) -> Option<Ustr> {
380        None
381    }
382
383    fn exchange(&self) -> Option<Ustr> {
384        None
385    }
386
387    fn option_kind(&self) -> Option<OptionKind> {
388        None
389    }
390
391    fn is_inverse(&self) -> bool {
392        false
393    }
394
395    fn price_precision(&self) -> u8 {
396        self.price_precision
397    }
398
399    fn size_precision(&self) -> u8 {
400        self.size_precision
401    }
402
403    fn price_increment(&self) -> Price {
404        self.price_increment
405    }
406
407    fn size_increment(&self) -> Quantity {
408        self.size_increment
409    }
410
411    fn multiplier(&self) -> Quantity {
412        Quantity::from(1)
413    }
414
415    fn lot_size(&self) -> Option<Quantity> {
416        Some(Quantity::from(1))
417    }
418
419    fn max_quantity(&self) -> Option<Quantity> {
420        self.max_quantity
421    }
422
423    fn min_quantity(&self) -> Option<Quantity> {
424        self.min_quantity
425    }
426
427    fn max_price(&self) -> Option<Price> {
428        self.max_price
429    }
430
431    fn min_price(&self) -> Option<Price> {
432        self.min_price
433    }
434
435    fn ts_event(&self) -> UnixNanos {
436        self.ts_event
437    }
438
439    fn ts_init(&self) -> UnixNanos {
440        self.ts_init
441    }
442
443    fn margin_init(&self) -> Decimal {
444        self.margin_init
445    }
446
447    fn margin_maint(&self) -> Decimal {
448        self.margin_maint
449    }
450
451    fn maker_fee(&self) -> Decimal {
452        self.maker_fee
453    }
454
455    fn taker_fee(&self) -> Decimal {
456        self.taker_fee
457    }
458
459    fn strike_price(&self) -> Option<Price> {
460        None
461    }
462
463    fn activation_ns(&self) -> Option<UnixNanos> {
464        Some(self.activation_ns)
465    }
466
467    fn expiration_ns(&self) -> Option<UnixNanos> {
468        Some(self.expiration_ns)
469    }
470
471    fn max_notional(&self) -> Option<Money> {
472        self.max_notional
473    }
474
475    fn min_notional(&self) -> Option<Money> {
476        self.min_notional
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use rstest::rstest;
483    use rust_decimal_macros::dec;
484
485    use crate::{
486        enums::{AssetClass, InstrumentClass},
487        identifiers::{InstrumentId, Symbol},
488        instruments::{BinaryOption, Instrument, stubs::*},
489        types::{Currency, Money, Price, Quantity},
490    };
491
492    #[rstest]
493    fn test_trait_accessors(binary_option: BinaryOption) {
494        assert_eq!(binary_option.asset_class(), AssetClass::Alternative);
495        assert_eq!(
496            binary_option.instrument_class(),
497            InstrumentClass::BinaryOption
498        );
499        assert_eq!(binary_option.quote_currency(), Currency::USDC());
500        assert!(!binary_option.is_inverse());
501        assert_eq!(binary_option.price_precision(), 3);
502        assert_eq!(binary_option.size_precision(), 2);
503        assert!(binary_option.activation_ns().is_some());
504        assert!(binary_option.expiration_ns().is_some());
505    }
506
507    #[rstest]
508    fn test_new_checked_price_precision_mismatch() {
509        let result = BinaryOption::new_checked(
510            InstrumentId::from("TEST.POLYMARKET"),
511            Symbol::from("TEST"),
512            AssetClass::Alternative,
513            Currency::USDC(),
514            0.into(),
515            0.into(),
516            4, // mismatch
517            2,
518            Price::from("0.001"),
519            Quantity::from("0.01"),
520            None,
521            None,
522            None,
523            None,
524            None,
525            None,
526            None,
527            None,
528            None,
529            None,
530            None,
531            None,
532            None,
533            None,
534            0.into(),
535            0.into(),
536        );
537        assert!(result.is_err());
538    }
539
540    #[rstest]
541    fn test_serialization_roundtrip(binary_option: BinaryOption) {
542        let json = serde_json::to_string(&binary_option).unwrap();
543        let deserialized: BinaryOption = serde_json::from_str(&json).unwrap();
544        assert_eq!(binary_option, deserialized);
545    }
546
547    #[rstest]
548    fn test_builder_matches_new_checked() {
549        let positional = BinaryOption::new_checked(
550            InstrumentId::from("TEST.POLYMARKET"),
551            Symbol::from("TEST"),
552            AssetClass::Alternative,
553            Currency::USDC(),
554            1.into(),
555            2.into(),
556            3,
557            2,
558            Price::from("0.001"),
559            Quantity::from("0.01"),
560            Some("Yes".into()),
561            Some("Will it happen?".into()),
562            Some(Quantity::from("10000.00")),
563            Some(Quantity::from("5.00")),
564            Some(Money::from("100000 USDC")),
565            Some(Money::from("10 USDC")),
566            Some(Price::from("0.999")),
567            Some(Price::from("0.001")),
568            Some(dec!(0.01)),
569            Some(dec!(0.02)),
570            Some(dec!(0.0002)),
571            Some(dec!(0.0004)),
572            None,
573            None,
574            3.into(),
575            4.into(),
576        )
577        .unwrap();
578
579        let built = BinaryOption::builder()
580            .instrument_id(InstrumentId::from("TEST.POLYMARKET"))
581            .raw_symbol(Symbol::from("TEST"))
582            .asset_class(AssetClass::Alternative)
583            .currency(Currency::USDC())
584            .activation_ns(1.into())
585            .expiration_ns(2.into())
586            .price_precision(3)
587            .size_precision(2)
588            .price_increment(Price::from("0.001"))
589            .size_increment(Quantity::from("0.01"))
590            .outcome("Yes".into())
591            .description("Will it happen?".into())
592            .max_quantity(Quantity::from("10000.00"))
593            .min_quantity(Quantity::from("5.00"))
594            .max_notional(Money::from("100000 USDC"))
595            .min_notional(Money::from("10 USDC"))
596            .max_price(Price::from("0.999"))
597            .min_price(Price::from("0.001"))
598            .margin_init(dec!(0.01))
599            .margin_maint(dec!(0.02))
600            .maker_fee(dec!(0.0002))
601            .taker_fee(dec!(0.0004))
602            .ts_event(3.into())
603            .ts_init(4.into())
604            .build()
605            .unwrap();
606
607        assert_eq!(
608            serde_json::to_value(&positional).unwrap(),
609            serde_json::to_value(&built).unwrap(),
610        );
611    }
612}