Skip to main content

nautilus_coinbase/
factories.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//! Factory functions for creating Coinbase clients and components.
17
18use std::{any::Any, cell::RefCell, rc::Rc};
19
20use nautilus_common::{
21    cache::CacheView,
22    clients::{DataClient, ExecutionClient},
23    clock::Clock,
24    factories::{ClientConfig, DataClientFactory, ExecutionClientFactory},
25};
26use nautilus_live::ExecutionClientCore;
27#[cfg(test)]
28use nautilus_model::identifiers::AccountId;
29use nautilus_model::{
30    enums::{AccountType, OmsType},
31    identifiers::{ClientId, TraderId},
32};
33
34use crate::{
35    common::consts::{COINBASE, COINBASE_VENUE},
36    config::{CoinbaseDataClientConfig, CoinbaseExecutionClientConfig},
37    data::CoinbaseDataClient,
38    execution::CoinbaseExecutionClient,
39};
40
41impl ClientConfig for CoinbaseDataClientConfig {
42    fn as_any(&self) -> &dyn Any {
43        self
44    }
45}
46
47impl ClientConfig for CoinbaseExecutionClientConfig {
48    fn as_any(&self) -> &dyn Any {
49        self
50    }
51}
52
53/// Factory for creating Coinbase data clients.
54#[derive(Debug, Clone)]
55#[cfg_attr(
56    feature = "python",
57    pyo3::pyclass(module = "nautilus_trader.adapters.coinbase", from_py_object)
58)]
59#[cfg_attr(
60    feature = "python",
61    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.coinbase")
62)]
63pub struct CoinbaseDataClientFactory;
64
65impl CoinbaseDataClientFactory {
66    /// Creates a new [`CoinbaseDataClientFactory`] instance.
67    #[must_use]
68    pub const fn new() -> Self {
69        Self
70    }
71}
72
73impl Default for CoinbaseDataClientFactory {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl DataClientFactory for CoinbaseDataClientFactory {
80    fn create(
81        &self,
82        name: &str,
83        config: &dyn ClientConfig,
84        _cache: CacheView,
85        _clock: Rc<RefCell<dyn Clock>>,
86    ) -> anyhow::Result<Box<dyn DataClient>> {
87        let coinbase_config = config
88            .as_any()
89            .downcast_ref::<CoinbaseDataClientConfig>()
90            .ok_or_else(|| {
91                anyhow::anyhow!(
92                    "Invalid config type for CoinbaseDataClientFactory. Expected CoinbaseDataClientConfig, was {config:?}",
93                )
94            })?
95            .clone();
96
97        let client_id = ClientId::from(name);
98        let client = CoinbaseDataClient::new(client_id, coinbase_config)?;
99        Ok(Box::new(client))
100    }
101
102    fn name(&self) -> &'static str {
103        COINBASE
104    }
105
106    fn config_type(&self) -> &'static str {
107        "CoinbaseDataClientConfig"
108    }
109}
110
111/// Factory for creating Coinbase execution clients.
112///
113/// Dispatches the spot vs derivatives (CFM) scope from the config's
114/// [`AccountType`]: `Cash` bootstraps spot products and uses the
115/// `/accounts` endpoint; `Margin` bootstraps perpetual and dated futures,
116/// subscribes to the `futures_balance_summary` WebSocket channel, and
117/// produces position reports from the CFM endpoints. Other account types
118/// are rejected. Hedge mode is not exposed by the venue, so OMS is always
119/// `Netting`.
120#[derive(Debug, Default, Clone)]
121#[cfg_attr(
122    feature = "python",
123    pyo3::pyclass(module = "nautilus_trader.adapters.coinbase", from_py_object)
124)]
125#[cfg_attr(
126    feature = "python",
127    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.coinbase")
128)]
129pub struct CoinbaseExecutionClientFactory;
130
131impl CoinbaseExecutionClientFactory {
132    /// Creates a new [`CoinbaseExecutionClientFactory`] instance.
133    #[must_use]
134    pub const fn new() -> Self {
135        Self
136    }
137}
138
139impl ExecutionClientFactory for CoinbaseExecutionClientFactory {
140    fn create(
141        &self,
142        trader_id: TraderId,
143        name: &str,
144        config: &dyn ClientConfig,
145        cache: CacheView,
146        _clock: Rc<RefCell<dyn Clock>>,
147    ) -> anyhow::Result<Box<dyn ExecutionClient>> {
148        let coinbase_config = config
149            .as_any()
150            .downcast_ref::<CoinbaseExecutionClientConfig>()
151            .ok_or_else(|| {
152                anyhow::anyhow!(
153                    "Invalid config type for CoinbaseExecutionClientFactory. Expected CoinbaseExecutionClientConfig, was {config:?}",
154                )
155            })?
156            .clone();
157
158        let account_type = coinbase_config.account_type;
159        if !matches!(account_type, AccountType::Cash | AccountType::Margin) {
160            anyhow::bail!(
161                "Unsupported account_type {account_type:?} for Coinbase; expected Cash (spot) or Margin (CFM derivatives)"
162            );
163        }
164
165        let core = ExecutionClientCore::new(
166            trader_id,
167            ClientId::from(name),
168            *COINBASE_VENUE,
169            OmsType::Netting,
170            coinbase_config.account_id,
171            account_type,
172            None,
173            cache,
174        );
175
176        let client = CoinbaseExecutionClient::new(core, coinbase_config)?;
177
178        Ok(Box::new(client))
179    }
180
181    fn name(&self) -> &'static str {
182        COINBASE
183    }
184
185    fn config_type(&self) -> &'static str {
186        "CoinbaseExecutionClientConfig"
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use std::{cell::RefCell, rc::Rc};
193
194    use nautilus_common::{
195        cache::Cache,
196        clock::VirtualClock,
197        factories::{ClientConfig, DataClientFactory},
198        live::runner::set_data_event_sender,
199        messages::DataEvent,
200    };
201    use rstest::rstest;
202
203    use super::*;
204
205    fn setup_test_env() {
206        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
207        set_data_event_sender(sender);
208    }
209
210    #[rstest]
211    fn test_coinbase_data_client_factory_creation() {
212        let factory = CoinbaseDataClientFactory::new();
213        assert_eq!(factory.name(), COINBASE);
214        assert_eq!(factory.config_type(), "CoinbaseDataClientConfig");
215    }
216
217    #[rstest]
218    fn test_coinbase_exec_client_config_implements_client_config() {
219        let config = CoinbaseExecutionClientConfig::default();
220        let boxed_config: Box<dyn ClientConfig> = Box::new(config);
221        let downcasted = boxed_config
222            .as_any()
223            .downcast_ref::<CoinbaseExecutionClientConfig>();
224        assert!(downcasted.is_some());
225    }
226
227    #[rstest]
228    fn test_coinbase_data_client_config_implements_client_config() {
229        let config = CoinbaseDataClientConfig::default();
230        let boxed_config: Box<dyn ClientConfig> = Box::new(config);
231        let downcasted = boxed_config
232            .as_any()
233            .downcast_ref::<CoinbaseDataClientConfig>();
234        assert!(downcasted.is_some());
235    }
236
237    #[rstest]
238    fn test_coinbase_data_client_factory_creates_client() {
239        setup_test_env();
240
241        let factory = CoinbaseDataClientFactory::new();
242        let config = CoinbaseDataClientConfig::default();
243        let cache = Rc::new(RefCell::new(Cache::default()));
244        let clock = Rc::new(RefCell::new(VirtualClock::new()));
245
246        let result = factory.create("COINBASE-TEST", &config, cache.into(), clock);
247        assert!(result.is_ok());
248
249        let client = result.unwrap();
250        assert_eq!(client.client_id(), ClientId::from("COINBASE-TEST"));
251    }
252
253    #[rstest]
254    fn test_coinbase_data_client_factory_rejects_wrong_config_type() {
255        #[derive(Debug)]
256        struct WrongConfig;
257
258        impl ClientConfig for WrongConfig {
259            fn as_any(&self) -> &dyn std::any::Any {
260                self
261            }
262        }
263
264        let factory = CoinbaseDataClientFactory::new();
265        let cache = Rc::new(RefCell::new(Cache::default()));
266        let clock = Rc::new(RefCell::new(VirtualClock::new()));
267
268        let result = factory.create("COINBASE-TEST", &WrongConfig, cache.into(), clock);
269        let err = match result {
270            Ok(_) => panic!("wrong config type should be rejected"),
271            Err(e) => e,
272        };
273        let msg = err.to_string();
274        assert!(
275            msg.contains("CoinbaseDataClientFactory"),
276            "error should name the factory, was: {msg}"
277        );
278        assert!(
279            msg.contains("CoinbaseDataClientConfig"),
280            "error should name the expected config type, was: {msg}"
281        );
282    }
283
284    fn make_test_exec_config() -> CoinbaseExecutionClientConfig {
285        CoinbaseExecutionClientConfig {
286            api_key: Some("organizations/test-org/apiKeys/test-key".into()),
287            api_secret: Some("test-pem-placeholder".into()),
288            ..CoinbaseExecutionClientConfig::default()
289        }
290    }
291
292    fn setup_exec_test_env() {
293        use nautilus_common::{live::runner::replace_exec_event_sender, messages::ExecutionEvent};
294        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel::<ExecutionEvent>();
295        replace_exec_event_sender(sender);
296    }
297
298    #[rstest]
299    fn test_coinbase_execution_client_factory_creation() {
300        let factory = CoinbaseExecutionClientFactory::new();
301        assert_eq!(factory.name(), COINBASE);
302        assert_eq!(factory.config_type(), "CoinbaseExecutionClientConfig");
303    }
304
305    #[rstest]
306    fn test_coinbase_execution_client_factory_creates_cash_client() {
307        setup_exec_test_env();
308
309        let factory = CoinbaseExecutionClientFactory::new();
310        let config = make_test_exec_config();
311        let cache = Rc::new(RefCell::new(Cache::default()));
312
313        let client = factory
314            .create(
315                TraderId::from("TRADER-001"),
316                "COINBASE-TEST",
317                &config,
318                cache.into(),
319                Rc::new(RefCell::new(VirtualClock::new())),
320            )
321            .expect("factory should create exec client with valid config");
322
323        assert_eq!(client.client_id(), ClientId::from("COINBASE-TEST"));
324        assert_eq!(client.account_id(), AccountId::from("COINBASE-001"));
325        assert_eq!(client.venue(), *COINBASE_VENUE);
326        assert_eq!(client.oms_type(), OmsType::Netting);
327    }
328
329    #[rstest]
330    fn test_coinbase_execution_client_factory_creates_margin_client() {
331        setup_exec_test_env();
332
333        let factory = CoinbaseExecutionClientFactory::new();
334        let config = CoinbaseExecutionClientConfig {
335            account_type: AccountType::Margin,
336            ..make_test_exec_config()
337        };
338        let cache = Rc::new(RefCell::new(Cache::default()));
339
340        let client = factory
341            .create(
342                TraderId::from("TRADER-001"),
343                "COINBASE-DERIV",
344                &config,
345                cache.into(),
346                Rc::new(RefCell::new(VirtualClock::new())),
347            )
348            .expect("factory should create margin exec client when configured for derivatives");
349
350        assert_eq!(client.client_id(), ClientId::from("COINBASE-DERIV"));
351        assert_eq!(client.account_id(), AccountId::from("COINBASE-001"));
352        assert_eq!(client.venue(), *COINBASE_VENUE);
353        assert_eq!(client.oms_type(), OmsType::Netting);
354    }
355
356    #[rstest]
357    fn test_coinbase_execution_client_factory_rejects_unsupported_account_type() {
358        setup_exec_test_env();
359
360        let factory = CoinbaseExecutionClientFactory::new();
361        let config = CoinbaseExecutionClientConfig {
362            account_type: AccountType::Betting,
363            ..make_test_exec_config()
364        };
365        let cache = Rc::new(RefCell::new(Cache::default()));
366
367        let err = factory
368            .create(
369                TraderId::from("TRADER-001"),
370                "COINBASE-TEST",
371                &config,
372                cache.into(),
373                Rc::new(RefCell::new(VirtualClock::new())),
374            )
375            .err()
376            .expect("unsupported account type must be rejected");
377        let msg = err.to_string();
378        assert!(
379            msg.contains("Unsupported account_type"),
380            "error should mention unsupported account type, was: {msg}"
381        );
382    }
383
384    #[rstest]
385    fn test_coinbase_execution_client_factory_rejects_wrong_config_type() {
386        setup_exec_test_env();
387
388        let factory = CoinbaseExecutionClientFactory::new();
389        let wrong_config = CoinbaseDataClientConfig::default();
390        let cache = Rc::new(RefCell::new(Cache::default()));
391
392        let result = factory.create(
393            TraderId::from("TRADER-001"),
394            "COINBASE-TEST",
395            &wrong_config,
396            cache.into(),
397            Rc::new(RefCell::new(VirtualClock::new())),
398        );
399        let err = match result {
400            Ok(_) => panic!("wrong config type should be rejected"),
401            Err(e) => e,
402        };
403        let msg = err.to_string();
404        assert!(
405            msg.contains("CoinbaseExecutionClientFactory"),
406            "error should name the factory, was: {msg}"
407        );
408        assert!(
409            msg.contains("CoinbaseExecutionClientConfig"),
410            "error should name the expected config type, was: {msg}"
411        );
412    }
413}