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