Skip to main content

nautilus_coinbase/common/
enums.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 serde::{Deserialize, Serialize};
17use strum::{AsRefStr, Display, EnumIter, EnumString};
18
19/// Coinbase environment type.
20#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[cfg_attr(
22    feature = "python",
23    pyo3::pyclass(
24        module = "nautilus_trader.core.nautilus_pyo3.coinbase",
25        eq,
26        from_py_object,
27        rename_all = "SCREAMING_SNAKE_CASE"
28    )
29)]
30#[cfg_attr(
31    feature = "python",
32    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.coinbase")
33)]
34pub enum CoinbaseEnvironment {
35    /// Production environment.
36    #[default]
37    Live,
38    /// Sandbox/testing environment.
39    Sandbox,
40}
41
42impl CoinbaseEnvironment {
43    /// Returns true if this is the sandbox environment.
44    #[must_use]
45    pub const fn is_sandbox(self) -> bool {
46        matches!(self, Self::Sandbox)
47    }
48}
49
50/// Coinbase product type.
51#[derive(
52    Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString, EnumIter,
53)]
54#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
55#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
56pub enum CoinbaseProductType {
57    Spot,
58    Future,
59    #[serde(rename = "UNKNOWN_PRODUCT_TYPE")]
60    #[strum(serialize = "UNKNOWN_PRODUCT_TYPE")]
61    Unknown,
62}
63
64/// Coinbase order side.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
66#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
67#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
68pub enum CoinbaseOrderSide {
69    Buy,
70    Sell,
71    #[serde(rename = "UNKNOWN_ORDER_SIDE")]
72    #[strum(serialize = "UNKNOWN_ORDER_SIDE")]
73    Unknown,
74}
75
76/// Coinbase REST order type values.
77#[derive(
78    Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString, AsRefStr,
79)]
80#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
81#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
82pub enum CoinbaseOrderType {
83    #[serde(rename = "UNKNOWN_ORDER_TYPE")]
84    #[strum(serialize = "UNKNOWN_ORDER_TYPE")]
85    Unknown,
86    #[serde(alias = "Market")]
87    Market,
88    #[serde(alias = "Limit")]
89    Limit,
90    #[serde(alias = "Stop")]
91    Stop,
92    #[serde(alias = "StopLimit", alias = "Stop Limit")]
93    StopLimit,
94    #[serde(alias = "Bracket")]
95    Bracket,
96    Twap,
97    #[serde(alias = "Roll Open")]
98    RollOpen,
99    #[serde(alias = "Roll Close")]
100    RollClose,
101    Liquidation,
102    Scaled,
103}
104
105/// Coinbase order status.
106#[derive(
107    Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString, AsRefStr,
108)]
109#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
110#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
111pub enum CoinbaseOrderStatus {
112    Pending,
113    Open,
114    Filled,
115    Cancelled,
116    Expired,
117    Failed,
118    #[serde(rename = "UNKNOWN_ORDER_STATUS")]
119    #[strum(serialize = "UNKNOWN_ORDER_STATUS")]
120    Unknown,
121    Queued,
122    CancelQueued,
123    EditQueued,
124}
125
126impl CoinbaseOrderStatus {
127    /// Returns true when the status represents a terminal lifecycle state
128    /// (no further updates expected from the venue).
129    #[must_use]
130    pub const fn is_terminal(self) -> bool {
131        matches!(
132            self,
133            Self::Filled | Self::Cancelled | Self::Expired | Self::Failed
134        )
135    }
136}
137
138/// Coinbase time in force.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
140#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
141#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
142pub enum CoinbaseTimeInForce {
143    #[serde(rename = "UNKNOWN_TIME_IN_FORCE")]
144    #[strum(serialize = "UNKNOWN_TIME_IN_FORCE")]
145    Unknown,
146    GoodUntilDateTime,
147    GoodUntilCancelled,
148    ImmediateOrCancel,
149    FillOrKill,
150}
151
152/// Coinbase trigger status.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
154#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
155#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
156pub enum CoinbaseTriggerStatus {
157    #[serde(rename = "UNKNOWN_TRIGGER_STATUS")]
158    #[strum(serialize = "UNKNOWN_TRIGGER_STATUS")]
159    Unknown,
160    InvalidOrderType,
161    StopPending,
162    StopTriggered,
163}
164
165/// Coinbase order placement source.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
167#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
168#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
169pub enum CoinbaseOrderPlacementSource {
170    #[serde(rename = "UNKNOWN_PLACEMENT_SOURCE")]
171    #[strum(serialize = "UNKNOWN_PLACEMENT_SOURCE")]
172    Unknown,
173    RetailSimple,
174    RetailAdvanced,
175}
176
177/// Coinbase order margin type.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
179#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
180#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
181#[cfg_attr(
182    feature = "python",
183    pyo3::pyclass(
184        module = "nautilus_trader.core.nautilus_pyo3.coinbase",
185        eq,
186        eq_int,
187        frozen,
188        from_py_object,
189        rename_all = "SCREAMING_SNAKE_CASE"
190    )
191)]
192#[cfg_attr(
193    feature = "python",
194    pyo3_stub_gen::derive::gen_stub_pyclass_enum(module = "nautilus_trader.coinbase")
195)]
196pub enum CoinbaseMarginType {
197    #[serde(alias = "Cross")]
198    Cross,
199    #[serde(alias = "Isolated")]
200    Isolated,
201}
202
203/// Coinbase product status.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
205#[serde(rename_all = "snake_case")]
206#[strum(serialize_all = "snake_case")]
207pub enum CoinbaseProductStatus {
208    Online,
209    Offline,
210    Delisted,
211    /// Futures products return an empty status string.
212    #[serde(rename = "")]
213    #[strum(serialize = "")]
214    Unset,
215}
216
217/// Coinbase product venue.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
219#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
220#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
221pub enum CoinbaseProductVenue {
222    /// Coinbase Exchange (spot).
223    Cbe,
224    /// Futures Commission Merchant (futures/perpetuals).
225    Fcm,
226}
227
228/// Coinbase FCM trading session state.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
230pub enum CoinbaseFcmTradingSessionState {
231    #[serde(rename = "FCM_TRADING_SESSION_STATE_UNDEFINED")]
232    #[strum(serialize = "FCM_TRADING_SESSION_STATE_UNDEFINED")]
233    Undefined,
234    #[serde(rename = "FCM_TRADING_SESSION_STATE_OPEN")]
235    #[strum(serialize = "FCM_TRADING_SESSION_STATE_OPEN")]
236    Open,
237}
238
239/// Coinbase FCM trading session closed reason.
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
241pub enum CoinbaseFcmTradingSessionClosedReason {
242    #[serde(rename = "FCM_TRADING_SESSION_CLOSED_REASON_UNDEFINED")]
243    #[strum(serialize = "FCM_TRADING_SESSION_CLOSED_REASON_UNDEFINED")]
244    Undefined,
245    #[serde(rename = "FCM_TRADING_SESSION_CLOSED_REASON_EXCHANGE_MAINTENANCE")]
246    #[strum(serialize = "FCM_TRADING_SESSION_CLOSED_REASON_EXCHANGE_MAINTENANCE")]
247    ExchangeMaintenance,
248}
249
250/// Coinbase risk management owner.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
252pub enum CoinbaseRiskManagedBy {
253    #[serde(rename = "UNKNOWN_RISK_MANAGEMENT_TYPE")]
254    #[strum(serialize = "UNKNOWN_RISK_MANAGEMENT_TYPE")]
255    Unknown,
256    #[serde(rename = "MANAGED_BY_FCM")]
257    #[strum(serialize = "MANAGED_BY_FCM")]
258    ManagedByFcm,
259    #[serde(rename = "MANAGED_BY_VENUE")]
260    #[strum(serialize = "MANAGED_BY_VENUE")]
261    ManagedByVenue,
262}
263
264/// Coinbase account type.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
266#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
267#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
268pub enum CoinbaseAccountType {
269    // Production currently returns the fully qualified wire names
270    // (`ACCOUNT_TYPE_CRYPTO`, `ACCOUNT_TYPE_FIAT`); older documented
271    // examples use the short forms. Accept both on deserialize / parse
272    // but keep the short form as the canonical Display value.
273    #[serde(alias = "ACCOUNT_TYPE_CRYPTO")]
274    #[strum(to_string = "CRYPTO", serialize = "ACCOUNT_TYPE_CRYPTO")]
275    Crypto,
276    #[serde(alias = "ACCOUNT_TYPE_FIAT")]
277    #[strum(to_string = "FIAT", serialize = "ACCOUNT_TYPE_FIAT")]
278    Fiat,
279}
280
281/// Coinbase fill trade type.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
283pub enum CoinbaseFillTradeType {
284    #[serde(rename = "FILL")]
285    #[strum(serialize = "FILL")]
286    Fill,
287}
288
289/// Coinbase FCM position side.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
291pub enum CoinbaseFcmPositionSide {
292    #[serde(rename = "FUTURES_POSITION_SIDE_UNSPECIFIED")]
293    #[strum(serialize = "FUTURES_POSITION_SIDE_UNSPECIFIED")]
294    Unspecified,
295    #[serde(rename = "LONG")]
296    #[strum(serialize = "LONG")]
297    Long,
298    #[serde(rename = "SHORT")]
299    #[strum(serialize = "SHORT")]
300    Short,
301}
302
303/// Coinbase futures margin window type.
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
305pub enum CoinbaseMarginWindowType {
306    #[serde(rename = "FCM_MARGIN_WINDOW_TYPE_INTRADAY")]
307    #[strum(serialize = "FCM_MARGIN_WINDOW_TYPE_INTRADAY")]
308    Intraday,
309    #[serde(rename = "FCM_MARGIN_WINDOW_TYPE_OVERNIGHT")]
310    #[strum(serialize = "FCM_MARGIN_WINDOW_TYPE_OVERNIGHT")]
311    Overnight,
312}
313
314/// Coinbase margin level.
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
316pub enum CoinbaseMarginLevel {
317    #[serde(rename = "MARGIN_LEVEL_TYPE_BASE")]
318    #[strum(serialize = "MARGIN_LEVEL_TYPE_BASE")]
319    Base,
320}
321
322/// Coinbase contract expiry type for futures products.
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
324#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
325#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
326pub enum CoinbaseContractExpiryType {
327    #[serde(
328        rename = "UNKNOWN_CONTRACT_EXPIRY_TYPE",
329        alias = "UNKNOWN_CONTRACT_EXPIRY"
330    )]
331    #[strum(
332        serialize = "UNKNOWN_CONTRACT_EXPIRY_TYPE",
333        serialize = "UNKNOWN_CONTRACT_EXPIRY"
334    )]
335    Unknown,
336    Expiring,
337    /// Non-expiring (perpetual)
338    #[serde(rename = "PERPETUAL")]
339    #[strum(serialize = "PERPETUAL")]
340    Perpetual,
341}
342
343/// Coinbase futures asset type.
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
345pub enum CoinbaseFuturesAssetType {
346    #[serde(rename = "FUTURES_ASSET_TYPE_CRYPTO")]
347    #[strum(serialize = "FUTURES_ASSET_TYPE_CRYPTO")]
348    Crypto,
349    #[serde(rename = "FUTURES_ASSET_TYPE_ENERGY")]
350    #[strum(serialize = "FUTURES_ASSET_TYPE_ENERGY")]
351    Energy,
352    #[serde(rename = "FUTURES_ASSET_TYPE_METALS")]
353    #[strum(serialize = "FUTURES_ASSET_TYPE_METALS")]
354    Metals,
355    #[serde(rename = "FUTURES_ASSET_TYPE_STOCKS")]
356    #[strum(serialize = "FUTURES_ASSET_TYPE_STOCKS")]
357    Stocks,
358}
359
360/// Coinbase fill liquidity indicator.
361#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
362#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
363#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
364pub enum CoinbaseLiquidityIndicator {
365    Maker,
366    Taker,
367    Unknown,
368}
369
370/// Coinbase stop order direction.
371#[derive(
372    Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString, AsRefStr,
373)]
374pub enum CoinbaseStopDirection {
375    #[serde(rename = "STOP_DIRECTION_STOP_DOWN")]
376    #[strum(serialize = "STOP_DIRECTION_STOP_DOWN")]
377    StopDown,
378    #[serde(rename = "STOP_DIRECTION_STOP_UP")]
379    #[strum(serialize = "STOP_DIRECTION_STOP_UP")]
380    StopUp,
381}
382
383/// Coinbase candle granularity for historical data.
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
385#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
386#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
387pub enum CoinbaseGranularity {
388    OneMinute,
389    FiveMinute,
390    FifteenMinute,
391    ThirtyMinute,
392    OneHour,
393    TwoHour,
394    SixHour,
395    OneDay,
396}
397
398/// Coinbase WebSocket channel type.
399#[derive(
400    Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString, AsRefStr,
401)]
402#[serde(rename_all = "snake_case")]
403#[strum(serialize_all = "snake_case")]
404pub enum CoinbaseWsChannel {
405    Level2,
406    MarketTrades,
407    Ticker,
408    TickerBatch,
409    Candles,
410    User,
411    Heartbeats,
412    FuturesBalanceSummary,
413    Status,
414}
415
416impl CoinbaseWsChannel {
417    /// Returns true if this channel requires authentication.
418    #[must_use]
419    pub fn requires_auth(&self) -> bool {
420        matches!(self, Self::User | Self::FuturesBalanceSummary)
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use std::str::FromStr;
427
428    use rstest::rstest;
429
430    use super::*;
431
432    #[rstest]
433    #[case(CoinbaseProductType::Spot, "SPOT")]
434    #[case(CoinbaseProductType::Future, "FUTURE")]
435    fn test_product_type_display(#[case] variant: CoinbaseProductType, #[case] expected: &str) {
436        assert_eq!(variant.to_string(), expected);
437    }
438
439    #[rstest]
440    #[case("BUY", CoinbaseOrderSide::Buy)]
441    #[case("SELL", CoinbaseOrderSide::Sell)]
442    fn test_order_side_from_str(#[case] input: &str, #[case] expected: CoinbaseOrderSide) {
443        assert_eq!(CoinbaseOrderSide::from_str(input).unwrap(), expected);
444    }
445
446    #[rstest]
447    #[case(CoinbaseOrderStatus::Filled, true)]
448    #[case(CoinbaseOrderStatus::Cancelled, true)]
449    #[case(CoinbaseOrderStatus::Expired, true)]
450    #[case(CoinbaseOrderStatus::Failed, true)]
451    #[case(CoinbaseOrderStatus::Open, false)]
452    #[case(CoinbaseOrderStatus::Pending, false)]
453    #[case(CoinbaseOrderStatus::Queued, false)]
454    #[case(CoinbaseOrderStatus::CancelQueued, false)]
455    #[case(CoinbaseOrderStatus::EditQueued, false)]
456    #[case(CoinbaseOrderStatus::Unknown, false)]
457    fn test_order_status_is_terminal(#[case] status: CoinbaseOrderStatus, #[case] expected: bool) {
458        assert_eq!(status.is_terminal(), expected);
459    }
460
461    #[rstest]
462    fn test_ws_channel_requires_auth() {
463        assert!(CoinbaseWsChannel::User.requires_auth());
464        assert!(CoinbaseWsChannel::FuturesBalanceSummary.requires_auth());
465        assert!(!CoinbaseWsChannel::Level2.requires_auth());
466        assert!(!CoinbaseWsChannel::MarketTrades.requires_auth());
467        assert!(!CoinbaseWsChannel::Ticker.requires_auth());
468    }
469
470    #[rstest]
471    fn test_order_status_serialization() {
472        let status = CoinbaseOrderStatus::Open;
473        let json = serde_json::to_string(&status).unwrap();
474        assert_eq!(json, "\"OPEN\"");
475
476        let deserialized: CoinbaseOrderStatus = serde_json::from_str("\"CANCELLED\"").unwrap();
477        assert_eq!(deserialized, CoinbaseOrderStatus::Cancelled);
478    }
479
480    #[rstest]
481    fn test_screaming_snake_case_multi_word() {
482        let json = serde_json::to_string(&CoinbaseOrderType::StopLimit).unwrap();
483        assert_eq!(json, "\"STOP_LIMIT\"");
484
485        let json = serde_json::to_string(&CoinbaseOrderStatus::CancelQueued).unwrap();
486        assert_eq!(json, "\"CANCEL_QUEUED\"");
487
488        let json = serde_json::to_string(&CoinbaseTimeInForce::GoodUntilDateTime).unwrap();
489        assert_eq!(json, "\"GOOD_UNTIL_DATE_TIME\"");
490
491        let json = serde_json::to_string(&CoinbaseGranularity::FifteenMinute).unwrap();
492        assert_eq!(json, "\"FIFTEEN_MINUTE\"");
493    }
494
495    #[rstest]
496    fn test_order_type_accepts_title_case_aliases() {
497        let order_type: CoinbaseOrderType = serde_json::from_str("\"Limit\"").unwrap();
498        assert_eq!(order_type, CoinbaseOrderType::Limit);
499
500        let order_type: CoinbaseOrderType = serde_json::from_str("\"StopLimit\"").unwrap();
501        assert_eq!(order_type, CoinbaseOrderType::StopLimit);
502
503        let order_type: CoinbaseOrderType = serde_json::from_str("\"Stop Limit\"").unwrap();
504        assert_eq!(order_type, CoinbaseOrderType::StopLimit);
505
506        let order_type = CoinbaseOrderType::from_str("STOP_LIMIT").unwrap();
507        assert_eq!(order_type, CoinbaseOrderType::StopLimit);
508
509        let order_type = CoinbaseOrderType::from_str("TWAP").unwrap();
510        assert_eq!(order_type, CoinbaseOrderType::Twap);
511
512        let order_type = CoinbaseOrderType::from_str("LIQUIDATION").unwrap();
513        assert_eq!(order_type, CoinbaseOrderType::Liquidation);
514    }
515
516    #[rstest]
517    fn test_account_type_accepts_current_wire_values() {
518        let account_type: CoinbaseAccountType = serde_json::from_str("\"FIAT\"").unwrap();
519        assert_eq!(account_type, CoinbaseAccountType::Fiat);
520
521        let account_type = CoinbaseAccountType::from_str("CRYPTO").unwrap();
522        assert_eq!(account_type, CoinbaseAccountType::Crypto);
523    }
524
525    // Production `/accounts` currently returns the fully qualified wire
526    // names (`ACCOUNT_TYPE_CRYPTO` / `ACCOUNT_TYPE_FIAT`). Both shapes
527    // must parse so account-state bootstrap doesn't fail with
528    // "unknown variant".
529    #[rstest]
530    fn test_account_type_accepts_qualified_wire_values() {
531        let account_type: CoinbaseAccountType =
532            serde_json::from_str("\"ACCOUNT_TYPE_CRYPTO\"").unwrap();
533        assert_eq!(account_type, CoinbaseAccountType::Crypto);
534
535        let account_type: CoinbaseAccountType =
536            serde_json::from_str("\"ACCOUNT_TYPE_FIAT\"").unwrap();
537        assert_eq!(account_type, CoinbaseAccountType::Fiat);
538
539        let account_type = CoinbaseAccountType::from_str("ACCOUNT_TYPE_CRYPTO").unwrap();
540        assert_eq!(account_type, CoinbaseAccountType::Crypto);
541    }
542
543    // Display must keep emitting the short form regardless of input
544    // wire shape; the qualified form is an input-only alias.
545    #[rstest]
546    fn test_account_type_display_uses_short_form() {
547        assert_eq!(CoinbaseAccountType::Crypto.to_string(), "CRYPTO");
548        assert_eq!(CoinbaseAccountType::Fiat.to_string(), "FIAT");
549        assert_eq!(
550            serde_json::to_string(&CoinbaseAccountType::Crypto).unwrap(),
551            "\"CRYPTO\""
552        );
553    }
554
555    #[rstest]
556    fn test_margin_type_accepts_request_and_ws_spellings() {
557        let margin_type: CoinbaseMarginType = serde_json::from_str("\"CROSS\"").unwrap();
558        assert_eq!(margin_type, CoinbaseMarginType::Cross);
559
560        let margin_type: CoinbaseMarginType = serde_json::from_str("\"Cross\"").unwrap();
561        assert_eq!(margin_type, CoinbaseMarginType::Cross);
562    }
563
564    #[rstest]
565    fn test_contract_expiry_type_accepts_websocket_alias() {
566        let expiry_type: CoinbaseContractExpiryType =
567            serde_json::from_str("\"UNKNOWN_CONTRACT_EXPIRY\"").unwrap();
568        assert_eq!(expiry_type, CoinbaseContractExpiryType::Unknown);
569    }
570
571    #[rstest]
572    fn test_ws_channel_snake_case() {
573        let json = serde_json::to_string(&CoinbaseWsChannel::Level2).unwrap();
574        assert_eq!(json, "\"level2\"");
575
576        let json = serde_json::to_string(&CoinbaseWsChannel::MarketTrades).unwrap();
577        assert_eq!(json, "\"market_trades\"");
578
579        let json = serde_json::to_string(&CoinbaseWsChannel::FuturesBalanceSummary).unwrap();
580        assert_eq!(json, "\"futures_balance_summary\"");
581    }
582
583    #[rstest]
584    fn test_unknown_variants_have_qualified_names() {
585        let json = serde_json::to_string(&CoinbaseProductType::Unknown).unwrap();
586        assert_eq!(json, "\"UNKNOWN_PRODUCT_TYPE\"");
587
588        let json = serde_json::to_string(&CoinbaseOrderSide::Unknown).unwrap();
589        assert_eq!(json, "\"UNKNOWN_ORDER_SIDE\"");
590
591        let json = serde_json::to_string(&CoinbaseOrderType::Unknown).unwrap();
592        assert_eq!(json, "\"UNKNOWN_ORDER_TYPE\"");
593
594        let json = serde_json::to_string(&CoinbaseOrderStatus::Unknown).unwrap();
595        assert_eq!(json, "\"UNKNOWN_ORDER_STATUS\"");
596
597        let json = serde_json::to_string(&CoinbaseTimeInForce::Unknown).unwrap();
598        assert_eq!(json, "\"UNKNOWN_TIME_IN_FORCE\"");
599
600        let json = serde_json::to_string(&CoinbaseTriggerStatus::Unknown).unwrap();
601        assert_eq!(json, "\"UNKNOWN_TRIGGER_STATUS\"");
602
603        let json = serde_json::to_string(&CoinbaseOrderPlacementSource::Unknown).unwrap();
604        assert_eq!(json, "\"UNKNOWN_PLACEMENT_SOURCE\"");
605
606        let json = serde_json::to_string(&CoinbaseContractExpiryType::Unknown).unwrap();
607        assert_eq!(json, "\"UNKNOWN_CONTRACT_EXPIRY_TYPE\"");
608
609        let json = serde_json::to_string(&CoinbaseRiskManagedBy::Unknown).unwrap();
610        assert_eq!(json, "\"UNKNOWN_RISK_MANAGEMENT_TYPE\"");
611    }
612}