1use 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};
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#[repr(C)]
42#[derive(Clone, Debug, Serialize, Deserialize)]
43#[cfg_attr(
44 feature = "python",
45 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
46)]
47#[cfg_attr(
48 feature = "python",
49 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
50)]
51pub struct CurrencyPair {
52 pub id: InstrumentId,
54 pub raw_symbol: Symbol,
56 pub base_currency: Currency,
58 pub quote_currency: Currency,
60 pub price_precision: u8,
62 pub size_precision: u8,
64 pub price_increment: Price,
66 pub size_increment: Quantity,
68 pub multiplier: Quantity,
70 pub lot_size: Option<Quantity>,
72 pub margin_init: Decimal,
74 pub margin_maint: Decimal,
76 pub maker_fee: Decimal,
78 pub taker_fee: Decimal,
80 pub max_quantity: Option<Quantity>,
82 pub min_quantity: Option<Quantity>,
84 pub max_notional: Option<Money>,
86 pub min_notional: Option<Money>,
88 pub max_price: Option<Price>,
90 pub min_price: Option<Price>,
92 pub info: Option<Params>,
94 pub ts_event: UnixNanos,
96 pub ts_init: UnixNanos,
98}
99
100impl CurrencyPair {
101 #[expect(clippy::too_many_arguments)]
110 pub fn new_checked(
111 instrument_id: InstrumentId,
112 raw_symbol: Symbol,
113 base_currency: Currency,
114 quote_currency: Currency,
115 price_precision: u8,
116 size_precision: u8,
117 price_increment: Price,
118 size_increment: Quantity,
119 multiplier: Option<Quantity>,
120 lot_size: Option<Quantity>,
121 max_quantity: Option<Quantity>,
122 min_quantity: Option<Quantity>,
123 max_notional: Option<Money>,
124 min_notional: Option<Money>,
125 max_price: Option<Price>,
126 min_price: Option<Price>,
127 margin_init: Option<Decimal>,
128 margin_maint: Option<Decimal>,
129 maker_fee: Option<Decimal>,
130 taker_fee: Option<Decimal>,
131 info: Option<Params>,
132 ts_event: UnixNanos,
133 ts_init: UnixNanos,
134 ) -> CorrectnessResult<Self> {
135 check_equal_u8(
136 price_precision,
137 price_increment.precision,
138 stringify!(price_precision),
139 stringify!(price_increment.precision),
140 )?;
141 check_equal_u8(
142 size_precision,
143 size_increment.precision,
144 stringify!(size_precision),
145 stringify!(size_increment.precision),
146 )?;
147 check_positive_price(price_increment, stringify!(price_increment))?;
148 check_positive_quantity(size_increment, stringify!(size_increment))?;
149
150 Ok(Self {
151 id: instrument_id,
152 raw_symbol,
153 base_currency,
154 quote_currency,
155 price_precision,
156 size_precision,
157 price_increment,
158 size_increment,
159 multiplier: multiplier.unwrap_or(Quantity::from(1)),
160 lot_size,
161 max_quantity,
162 min_quantity,
163 max_notional,
164 min_notional,
165 max_price,
166 min_price,
167 margin_init: margin_init.unwrap_or_default(),
168 margin_maint: margin_maint.unwrap_or_default(),
169 maker_fee: maker_fee.unwrap_or_default(),
170 taker_fee: taker_fee.unwrap_or_default(),
171 info,
172 ts_event,
173 ts_init,
174 })
175 }
176
177 #[expect(clippy::too_many_arguments)]
183 #[must_use]
184 pub fn new(
185 instrument_id: InstrumentId,
186 raw_symbol: Symbol,
187 base_currency: Currency,
188 quote_currency: Currency,
189 price_precision: u8,
190 size_precision: u8,
191 price_increment: Price,
192 size_increment: Quantity,
193 multiplier: Option<Quantity>,
194 lot_size: Option<Quantity>,
195 max_quantity: Option<Quantity>,
196 min_quantity: Option<Quantity>,
197 max_notional: Option<Money>,
198 min_notional: Option<Money>,
199 max_price: Option<Price>,
200 min_price: Option<Price>,
201 margin_init: Option<Decimal>,
202 margin_maint: Option<Decimal>,
203 maker_fee: Option<Decimal>,
204 taker_fee: Option<Decimal>,
205 info: Option<Params>,
206 ts_event: UnixNanos,
207 ts_init: UnixNanos,
208 ) -> Self {
209 Self::new_checked(
210 instrument_id,
211 raw_symbol,
212 base_currency,
213 quote_currency,
214 price_precision,
215 size_precision,
216 price_increment,
217 size_increment,
218 multiplier,
219 lot_size,
220 max_quantity,
221 min_quantity,
222 max_notional,
223 min_notional,
224 max_price,
225 min_price,
226 margin_init,
227 margin_maint,
228 maker_fee,
229 taker_fee,
230 info,
231 ts_event,
232 ts_init,
233 )
234 .expect_display(FAILED)
235 }
236}
237
238impl PartialEq<Self> for CurrencyPair {
239 fn eq(&self, other: &Self) -> bool {
240 self.id == other.id
241 }
242}
243
244impl Eq for CurrencyPair {}
245
246impl Hash for CurrencyPair {
247 fn hash<H: Hasher>(&self, state: &mut H) {
248 self.id.hash(state);
249 }
250}
251
252impl Instrument for CurrencyPair {
253 fn into_any(self) -> InstrumentAny {
254 InstrumentAny::CurrencyPair(self)
255 }
256
257 fn id(&self) -> InstrumentId {
258 self.id
259 }
260
261 fn raw_symbol(&self) -> Symbol {
262 self.raw_symbol
263 }
264
265 fn asset_class(&self) -> AssetClass {
266 AssetClass::FX
267 }
268
269 fn instrument_class(&self) -> InstrumentClass {
270 InstrumentClass::Spot
271 }
272
273 fn underlying(&self) -> Option<Ustr> {
274 None
275 }
276
277 fn base_currency(&self) -> Option<Currency> {
278 Some(self.base_currency)
279 }
280
281 fn quote_currency(&self) -> Currency {
282 self.quote_currency
283 }
284
285 fn settlement_currency(&self) -> Currency {
286 self.quote_currency
287 }
288 fn isin(&self) -> Option<Ustr> {
289 None
290 }
291
292 fn is_inverse(&self) -> bool {
293 false
294 }
295
296 fn price_precision(&self) -> u8 {
297 self.price_precision
298 }
299
300 fn size_precision(&self) -> u8 {
301 self.size_precision
302 }
303
304 fn price_increment(&self) -> Price {
305 self.price_increment
306 }
307
308 fn size_increment(&self) -> Quantity {
309 self.size_increment
310 }
311
312 fn multiplier(&self) -> Quantity {
313 self.multiplier
314 }
315
316 fn lot_size(&self) -> Option<Quantity> {
317 self.lot_size
318 }
319
320 fn max_quantity(&self) -> Option<Quantity> {
321 self.max_quantity
322 }
323
324 fn min_quantity(&self) -> Option<Quantity> {
325 self.min_quantity
326 }
327
328 fn max_price(&self) -> Option<Price> {
329 self.max_price
330 }
331
332 fn min_price(&self) -> Option<Price> {
333 self.min_price
334 }
335
336 fn ts_event(&self) -> UnixNanos {
337 self.ts_event
338 }
339
340 fn ts_init(&self) -> UnixNanos {
341 self.ts_init
342 }
343
344 fn margin_init(&self) -> Decimal {
345 self.margin_init
346 }
347
348 fn margin_maint(&self) -> Decimal {
349 self.margin_maint
350 }
351
352 fn taker_fee(&self) -> Decimal {
353 self.taker_fee
354 }
355
356 fn maker_fee(&self) -> Decimal {
357 self.maker_fee
358 }
359
360 fn option_kind(&self) -> Option<OptionKind> {
361 None
362 }
363
364 fn exchange(&self) -> Option<Ustr> {
365 None
366 }
367
368 fn strike_price(&self) -> Option<Price> {
369 None
370 }
371
372 fn activation_ns(&self) -> Option<UnixNanos> {
373 None
374 }
375
376 fn expiration_ns(&self) -> Option<UnixNanos> {
377 None
378 }
379
380 fn max_notional(&self) -> Option<Money> {
381 self.max_notional
382 }
383
384 fn min_notional(&self) -> Option<Money> {
385 self.min_notional
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use rstest::rstest;
392
393 use crate::{
394 enums::{AssetClass, InstrumentClass},
395 identifiers::{InstrumentId, Symbol},
396 instruments::{CurrencyPair, Instrument, stubs::*},
397 types::{Currency, Price, Quantity},
398 };
399
400 #[rstest]
401 fn test_trait_accessors(currency_pair_btcusdt: CurrencyPair) {
402 assert_eq!(
403 currency_pair_btcusdt.id(),
404 InstrumentId::from("BTCUSDT.BINANCE")
405 );
406 assert_eq!(currency_pair_btcusdt.asset_class(), AssetClass::FX);
407 assert_eq!(
408 currency_pair_btcusdt.instrument_class(),
409 InstrumentClass::Spot
410 );
411 assert_eq!(currency_pair_btcusdt.base_currency(), Some(Currency::BTC()));
412 assert_eq!(currency_pair_btcusdt.quote_currency(), Currency::USDT());
413 assert!(!currency_pair_btcusdt.is_inverse());
414 assert_eq!(currency_pair_btcusdt.price_precision(), 2);
415 assert_eq!(currency_pair_btcusdt.size_precision(), 6);
416 assert_eq!(currency_pair_btcusdt.price_increment(), Price::from("0.01"));
417 assert_eq!(
418 currency_pair_btcusdt.size_increment(),
419 Quantity::from("0.000001")
420 );
421 }
422
423 #[rstest]
424 fn test_new_checked_price_precision_mismatch() {
425 let result = CurrencyPair::new_checked(
426 InstrumentId::from("TEST.BINANCE"),
427 Symbol::from("TEST"),
428 Currency::BTC(),
429 Currency::USDT(),
430 4, 6,
432 Price::from("0.01"),
433 Quantity::from("0.000001"),
434 None,
435 None,
436 None,
437 None,
438 None,
439 None,
440 None,
441 None,
442 None,
443 None,
444 None,
445 None,
446 None,
447 0.into(),
448 0.into(),
449 );
450 assert!(result.is_err());
451 }
452
453 #[rstest]
454 fn test_serialization_roundtrip(currency_pair_btcusdt: CurrencyPair) {
455 let json = serde_json::to_string(¤cy_pair_btcusdt).unwrap();
456 let deserialized: CurrencyPair = serde_json::from_str(&json).unwrap();
457 assert_eq!(currency_pair_btcusdt, deserialized);
458 }
459}