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    ) -> anyhow::Result<Box<dyn ExecutionClient>> {
147        let coinbase_config = config
148            .as_any()
149            .downcast_ref::<CoinbaseExecutionClientConfig>()
150            .ok_or_else(|| {
151                anyhow::anyhow!(
152                    "Invalid config type for CoinbaseExecutionClientFactory. Expected CoinbaseExecutionClientConfig, was {config:?}",
153                )
154            })?
155            .clone();
156
157        let account_type = coinbase_config.account_type;
158        if !matches!(account_type, AccountType::Cash | AccountType::Margin) {
159            anyhow::bail!(
160                "Unsupported account_type {account_type:?} for Coinbase; expected Cash (spot) or Margin (CFM derivatives)"
161            );
162        }
163
164        let core = ExecutionClientCore::new(
165            trader_id,
166            ClientId::from(name),
167            *COINBASE_VENUE,
168            OmsType::Netting,
169            coinbase_config.account_id,
170            account_type,
171            None,
172            cache,
173        );
174
175        let client = CoinbaseExecutionClient::new(core, coinbase_config)?;
176
177        Ok(Box::new(client))
178    }
179
180    fn name(&self) -> &'static str {
181        COINBASE
182    }
183
184    fn config_type(&self) -> &'static str {
185        "CoinbaseExecutionClientConfig"
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use std::{cell::RefCell, rc::Rc};
192
193    use nautilus_common::{
194        cache::Cache,
195        clock::TestClock,
196        factories::{ClientConfig, DataClientFactory},
197        live::runner::set_data_event_sender,
198        messages::DataEvent,
199    };
200    use rstest::rstest;
201
202    use super::*;
203
204    fn setup_test_env() {
205        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
206        set_data_event_sender(sender);
207    }
208
209    #[rstest]
210    fn test_coinbase_data_client_factory_creation() {
211        let factory = CoinbaseDataClientFactory::new();
212        assert_eq!(factory.name(), COINBASE);
213        assert_eq!(factory.config_type(), "CoinbaseDataClientConfig");
214    }
215
216    #[rstest]
217    fn test_coinbase_exec_client_config_implements_client_config() {
218        let config = CoinbaseExecutionClientConfig::default();
219        let boxed_config: Box<dyn ClientConfig> = Box::new(config);
220        let downcasted = boxed_config
221            .as_any()
222            .downcast_ref::<CoinbaseExecutionClientConfig>();
223        assert!(downcasted.is_some());
224    }
225
226    #[rstest]
227    fn test_coinbase_data_client_config_implements_client_config() {
228        let config = CoinbaseDataClientConfig::default();
229        let boxed_config: Box<dyn ClientConfig> = Box::new(config);
230        let downcasted = boxed_config
231            .as_any()
232            .downcast_ref::<CoinbaseDataClientConfig>();
233        assert!(downcasted.is_some());
234    }
235
236    #[rstest]
237    fn test_coinbase_data_client_factory_creates_client() {
238        setup_test_env();
239
240        let factory = CoinbaseDataClientFactory::new();
241        let config = CoinbaseDataClientConfig::default();
242        let cache = Rc::new(RefCell::new(Cache::default()));
243        let clock = Rc::new(RefCell::new(TestClock::new()));
244
245        let result = factory.create("COINBASE-TEST", &config, cache.into(), clock);
246        assert!(result.is_ok());
247
248        let client = result.unwrap();
249        assert_eq!(client.client_id(), ClientId::from("COINBASE-TEST"));
250    }
251
252    #[rstest]
253    fn test_coinbase_data_client_factory_rejects_wrong_config_type() {
254        #[derive(Debug)]
255        struct WrongConfig;
256
257        impl ClientConfig for WrongConfig {
258            fn as_any(&self) -> &dyn std::any::Any {
259                self
260            }
261        }
262
263        let factory = CoinbaseDataClientFactory::new();
264        let cache = Rc::new(RefCell::new(Cache::default()));
265        let clock = Rc::new(RefCell::new(TestClock::new()));
266
267        let result = factory.create("COINBASE-TEST", &WrongConfig, cache.into(), clock);
268        let err = match result {
269            Ok(_) => panic!("wrong config type should be rejected"),
270            Err(e) => e,
271        };
272        let msg = err.to_string();
273        assert!(
274            msg.contains("CoinbaseDataClientFactory"),
275            "error should name the factory, was: {msg}"
276        );
277        assert!(
278            msg.contains("CoinbaseDataClientConfig"),
279            "error should name the expected config type, was: {msg}"
280        );
281    }
282
283    fn make_test_exec_config() -> CoinbaseExecutionClientConfig {
284        CoinbaseExecutionClientConfig {
285            api_key: Some("organizations/test-org/apiKeys/test-key".to_string()),
286            api_secret: Some("test-pem-placeholder".to_string()),
287            ..CoinbaseExecutionClientConfig::default()
288        }
289    }
290
291    fn setup_exec_test_env() {
292        use nautilus_common::{live::runner::replace_exec_event_sender, messages::ExecutionEvent};
293        let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel::<ExecutionEvent>();
294        replace_exec_event_sender(sender);
295    }
296
297    #[rstest]
298    fn test_coinbase_execution_client_factory_creation() {
299        let factory = CoinbaseExecutionClientFactory::new();
300        assert_eq!(factory.name(), COINBASE);
301        assert_eq!(factory.config_type(), "CoinbaseExecutionClientConfig");
302    }
303
304    #[rstest]
305    fn test_coinbase_execution_client_factory_creates_cash_client() {
306        setup_exec_test_env();
307
308        let factory = CoinbaseExecutionClientFactory::new();
309        let config = make_test_exec_config();
310        let cache = Rc::new(RefCell::new(Cache::default()));
311
312        let client = factory
313            .create(
314                TraderId::from("TRADER-001"),
315                "COINBASE-TEST",
316                &config,
317                cache.into(),
318            )
319            .expect("factory should create exec client with valid config");
320
321        assert_eq!(client.client_id(), ClientId::from("COINBASE-TEST"));
322        assert_eq!(client.account_id(), AccountId::from("COINBASE-001"));
323        assert_eq!(client.venue(), *COINBASE_VENUE);
324        assert_eq!(client.oms_type(), OmsType::Netting);
325    }
326
327    #[rstest]
328    fn test_coinbase_execution_client_factory_creates_margin_client() {
329        setup_exec_test_env();
330
331        let factory = CoinbaseExecutionClientFactory::new();
332        let config = CoinbaseExecutionClientConfig {
333            account_type: AccountType::Margin,
334            ..make_test_exec_config()
335        };
336        let cache = Rc::new(RefCell::new(Cache::default()));
337
338        let client = factory
339            .create(
340                TraderId::from("TRADER-001"),
341                "COINBASE-DERIV",
342                &config,
343                cache.into(),
344            )
345            .expect("factory should create margin exec client when configured for derivatives");
346
347        assert_eq!(client.client_id(), ClientId::from("COINBASE-DERIV"));
348        assert_eq!(client.account_id(), AccountId::from("COINBASE-001"));
349        assert_eq!(client.venue(), *COINBASE_VENUE);
350        assert_eq!(client.oms_type(), OmsType::Netting);
351    }
352
353    #[rstest]
354    fn test_coinbase_execution_client_factory_rejects_unsupported_account_type() {
355        setup_exec_test_env();
356
357        let factory = CoinbaseExecutionClientFactory::new();
358        let config = CoinbaseExecutionClientConfig {
359            account_type: AccountType::Betting,
360            ..make_test_exec_config()
361        };
362        let cache = Rc::new(RefCell::new(Cache::default()));
363
364        let err = factory
365            .create(
366                TraderId::from("TRADER-001"),
367                "COINBASE-TEST",
368                &config,
369                cache.into(),
370            )
371            .err()
372            .expect("unsupported account type must be rejected");
373        let msg = err.to_string();
374        assert!(
375            msg.contains("Unsupported account_type"),
376            "error should mention unsupported account type, was: {msg}"
377        );
378    }
379
380    #[rstest]
381    fn test_coinbase_execution_client_factory_rejects_wrong_config_type() {
382        setup_exec_test_env();
383
384        let factory = CoinbaseExecutionClientFactory::new();
385        let wrong_config = CoinbaseDataClientConfig::default();
386        let cache = Rc::new(RefCell::new(Cache::default()));
387
388        let result = factory.create(
389            TraderId::from("TRADER-001"),
390            "COINBASE-TEST",
391            &wrong_config,
392            cache.into(),
393        );
394        let err = match result {
395            Ok(_) => panic!("wrong config type should be rejected"),
396            Err(e) => e,
397        };
398        let msg = err.to_string();
399        assert!(
400            msg.contains("CoinbaseExecutionClientFactory"),
401            "error should name the factory, was: {msg}"
402        );
403        assert!(
404            msg.contains("CoinbaseExecutionClientConfig"),
405            "error should name the expected config type, was: {msg}"
406        );
407    }
408}