Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions cryptofeed/exchanges/coinbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,13 @@ def _parse_symbol_data(cls, data: list) -> Tuple[Dict, Dict]:
ret[sym.normalized] = entry['product_id']
return ret, info

@classmethod
def _symbol_endpoint_prepare(cls, ep: RestEndpoint):
return f"{ep.address}/market/products"

@classmethod
def symbols(cls, config: dict = None, refresh=False) -> list:
config = Config(config)
if 'coinbase' not in config or 'key_id' not in config['coinbase'] or 'key_secret' not in config['coinbase']:
raise ValueError('You must provide key_id and key_secret in config to retrieve symbols from Coinbase.')
headers = get_private_parameters(config, rest_api=True, endpoint='products')
return list(cls.symbol_mapping(refresh=refresh, headers=headers).keys())
return list(cls.symbol_mapping(refresh=refresh).keys())

def __init__(self, callbacks=None, **kwargs):
super().__init__(callbacks=callbacks, **kwargs)
Expand Down Expand Up @@ -165,6 +165,8 @@ async def message_handler(self, msg: str, conn: AsyncConnection, timestamp: floa
await self._pair_level2_snapshot(event, timestamp)
elif msg['channel'] == 'subscriptions':
pass
elif msg['channel'] == 'heartbeats':
pass
else:
LOG.warning("%s: Invalid message type %s", self.id, msg)
# PERF perf_end(self.id, 'msg')
Expand All @@ -179,7 +181,7 @@ async def _subscribe(chan: str, product_ids: list):
"product_ids": product_ids,
"channel": chan
}
private_params = get_private_parameters(self.config, chan, product_ids)
private_params = get_private_parameters(self.config, chan, product_ids) if self.key_id and self.key_secret else {}
if private_params:
params = {**params, **private_params}
await conn.write(json.dumps(params))
Expand All @@ -188,5 +190,5 @@ async def _subscribe(chan: str, product_ids: list):
all_pairs += self.subscription[channel]
await _subscribe(channel, self.subscription[channel])
all_pairs = list(dict.fromkeys(all_pairs))
await _subscribe('heartbeat', all_pairs)
await _subscribe('heartbeats', all_pairs)
# Implementing heartbeat as per Best Practices doc: https://docs.cloud.coinbase.com/advanced-trade-api/docs/ws-best-practices
104 changes: 104 additions & 0 deletions tests/unit/test_coinbase.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
'''
Copyright (C) 2017-2026 Bryant Moscon - bmoscon@gmail.com

Please see the LICENSE file for the terms and conditions
associated with this software.
'''
import asyncio
import logging

import pytest
from yapic import json

from cryptofeed.defines import TRADES
from cryptofeed.exchanges import Coinbase
from cryptofeed.symbols import Symbols


class HTTPSyncStub:
def __init__(self):
self.calls = []

def read(self, address: str, params=None, headers=None, json=False, text=True, uuid=None):
self.calls.append({'address': address, 'headers': headers})
return {
'products': [
{
'base_currency_id': 'BTC',
'quote_currency_id': 'USD',
'quote_increment': '0.01',
'product_id': 'BTC-USD'
}
]
}


class RecordingConnection:
def __init__(self):
self.messages = []

async def write(self, msg: str):
self.messages.append(json.loads(msg))


@pytest.fixture(autouse=True)
def clear_symbols():
Symbols.clear()
yield
Symbols.clear()


def _set_coinbase_symbols():
Symbols.set(Coinbase.id, {'BTC-USD': 'BTC-USD'}, {'tick_size': {'BTC-USD': '0.01'}, 'instrument_type': {'BTC-USD': 'spot'}})


def test_symbols_use_public_market_products_without_credentials(monkeypatch):
http_sync = HTTPSyncStub()
monkeypatch.setattr(Coinbase, 'http_sync', http_sync)

assert Coinbase.symbols(config={}, refresh=True) == ['BTC-USD']
assert http_sync.calls == [{'address': 'https://api.coinbase.com/api/v3/brokerage/market/products', 'headers': None}]


def test_subscribe_omits_auth_fields_without_credentials():
_set_coinbase_symbols()
feed = Coinbase(symbols=['BTC-USD'], channels=[TRADES], config={})
conn = RecordingConnection()

asyncio.run(feed.subscribe(conn))

assert conn.messages == [
{'type': 'subscribe', 'product_ids': ['BTC-USD'], 'channel': 'market_trades'},
{'type': 'subscribe', 'product_ids': ['BTC-USD'], 'channel': 'heartbeats'}
]


def test_subscribe_keeps_auth_fields_when_credentials_are_configured():
_set_coinbase_symbols()
feed = Coinbase(
symbols=['BTC-USD'],
channels=[TRADES],
config={'coinbase': {'key_id': 'test-key', 'key_secret': 'test-secret'}}
)
conn = RecordingConnection()

asyncio.run(feed.subscribe(conn))

for message in conn.messages:
assert message['api_key'] == 'test-key'
assert 'timestamp' in message
assert 'signature' in message


def test_heartbeats_are_ignored_without_warning(caplog):
_set_coinbase_symbols()
feed = Coinbase(symbols=['BTC-USD'], channels=[TRADES], config={})
msg = {
'channel': 'heartbeats',
'events': [{'current_time': '2026-06-18T00:00:00Z', 'heartbeat_counter': '1'}]
}

caplog.set_level(logging.WARNING, logger='feedhandler')
asyncio.run(feed.message_handler(json.dumps(msg), None, 0.0))

assert caplog.records == []