2025-12-29 03:06:49 +08:00
"""
2026-04-06 16:47:36 +07:00
Cryptocurrency data source
Get data using CCXT (Coinbase)
2025-12-29 03:06:49 +08:00
"""
2026-02-28 20:34:33 +08:00
from typing import Dict , List , Any , Optional , Tuple
2025-12-29 03:06:49 +08:00
from datetime import datetime , timedelta
import ccxt
from app.data_sources.base import BaseDataSource , TIMEFRAME_SECONDS
from app.utils.logger import get_logger
from app.config import CCXTConfig , APIKeys
logger = get_logger ( __name__ )
class CryptoDataSource ( BaseDataSource ):
2026-04-06 16:47:36 +07:00
"""Cryptocurrency data source"""
2025-12-29 03:06:49 +08:00
name = "Crypto/CCXT"
2026-04-06 16:47:36 +07:00
# time period mapping
2025-12-29 03:06:49 +08:00
TIMEFRAME_MAP = CCXTConfig . TIMEFRAME_MAP
2026-04-06 16:47:36 +07:00
# List of common quote currencies (sorted by priority)
2026-02-28 20:34:33 +08:00
COMMON_QUOTES = [ 'USDT' , 'USD' , 'BTC' , 'ETH' , 'BUSD' , 'USDC' , 'BNB' , 'EUR' , 'GBP' ]
2025-12-29 03:06:49 +08:00
def __init__ ( self ):
config = {
'timeout' : CCXTConfig . TIMEOUT ,
'enableRateLimit' : CCXTConfig . ENABLE_RATE_LIMIT
}
2026-04-06 16:47:36 +07:00
# If a proxy is configured
2025-12-29 03:06:49 +08:00
if CCXTConfig . PROXY :
config [ 'proxies' ] = {
'http' : CCXTConfig . PROXY ,
'https' : CCXTConfig . PROXY
}
2025-12-30 17:03:22 +08:00
exchange_id = CCXTConfig . DEFAULT_EXCHANGE
2026-04-06 16:47:36 +07:00
# Dynamically loading exchange classes
2025-12-30 17:03:22 +08:00
if not hasattr ( ccxt , exchange_id ):
logger . warning ( f "CCXT exchange ' { exchange_id } ' not found, falling back to 'coinbase'" )
exchange_id = 'coinbase'
exchange_class = getattr ( ccxt , exchange_id )
self . exchange = exchange_class ( config )
2026-02-28 20:34:33 +08:00
2026-04-06 16:47:36 +07:00
# Lazy loading of markets (loaded on first use)
2026-02-28 20:34:33 +08:00
self . _markets_loaded = False
self . _markets_cache = None
def _ensure_markets_loaded ( self ) -> bool :
2026-04-06 16:47:36 +07:00
"""Make sure markets are loaded (for symbol verification)"""
2026-02-28 20:34:33 +08:00
if self . _markets_loaded and self . _markets_cache is not None :
return True
try :
2026-04-06 16:47:36 +07:00
# Some exchanges require explicit loading of markets
2026-02-28 20:34:33 +08:00
if hasattr ( self . exchange , 'load_markets' ):
self . exchange . load_markets ( reload = False )
self . _markets_cache = getattr ( self . exchange , 'markets' , {})
self . _markets_loaded = True
return True
except Exception as e :
logger . debug ( f "Failed to load markets for { self . exchange . id } : { e } " )
return False
def _normalize_symbol ( self , symbol : str ) -> Tuple [ str , str ]:
"""
2026-04-06 16:47:36 +07:00
Normalized symbol format, returns (normalized_symbol, base_currency)
2026-02-28 20:34:33 +08:00
2026-04-06 16:47:36 +07:00
Handles various input formats:
2026-02-28 20:34:33 +08:00
- BTC/USDT -> BTC/USDT
- BTCUSDT -> BTC/USDT
- BTC/USDT:USDT -> BTC/USDT
2026-04-06 16:47:36 +07:00
- BTC -> BTC/USDT (default)
2026-02-28 20:34:33 +08:00
- PI, TRX -> PI/USDT, TRX/USDT
"""
if not symbol :
return '' , ''
sym = symbol . strip ()
2026-04-06 16:47:36 +07:00
# Remove swap/futures suffix
2026-02-28 20:34:33 +08:00
if ':' in sym :
sym = sym . split ( ':' , 1 )[ 0 ]
sym = sym . upper ()
2026-04-06 16:47:36 +07:00
# If there is already a separator, parse it directly
2026-02-28 20:34:33 +08:00
if '/' in sym :
parts = sym . split ( '/' , 1 )
base = parts [ 0 ] . strip ()
quote = parts [ 1 ] . strip () if len ( parts ) > 1 else ''
if base and quote :
return f " { base } / { quote } " , base
2026-04-06 16:47:36 +07:00
# Try to identify from common quote currencies
2026-02-28 20:34:33 +08:00
for quote in self . COMMON_QUOTES :
if sym . endswith ( quote ) and len ( sym ) > len ( quote ):
base = sym [: - len ( quote )]
if base :
return f " { base } / { quote } " , base
2026-04-06 16:47:36 +07:00
# If not recognized, USDT will be used by default.
2026-02-28 20:34:33 +08:00
return f " { sym } /USDT" , sym
def _find_valid_symbol ( self , base : str , preferred_quote : str = 'USDT' ) -> Optional [ str ]:
"""
2026-04-06 16:47:36 +07:00
Find valid symbols in the exchange's markets
2026-02-28 20:34:33 +08:00
Args:
2026-04-06 16:47:36 +07:00
base: base currency (e.g. 'PI', 'TRX')
preferred_quote: preferred quote currency
2026-02-28 20:34:33 +08:00
Returns:
2026-04-06 16:47:36 +07:00
A valid symbol found, or None if not found
2026-02-28 20:34:33 +08:00
"""
if not self . _ensure_markets_loaded ():
return None
markets = self . _markets_cache or {}
if not markets :
return None
2026-04-06 16:47:36 +07:00
# Try different quote currencies by priority
2026-02-28 20:34:33 +08:00
quotes_to_try = [ preferred_quote ] + [ q for q in self . COMMON_QUOTES if q != preferred_quote ]
for quote in quotes_to_try :
candidate = f " { base } / { quote } "
if candidate in markets :
market = markets [ candidate ]
2026-04-06 16:47:36 +07:00
# Check if the market is active
2026-02-28 20:34:33 +08:00
if market . get ( 'active' , True ):
return candidate
return None
def _normalize_symbol_for_exchange ( self , symbol : str ) -> str :
"""
2026-04-06 16:47:36 +07:00
Standardize symbols based on exchange characteristics
2026-02-28 20:34:33 +08:00
2026-04-06 16:47:36 +07:00
Symbol format requirements for different exchanges:
- Binance: BTC/USDT (standard format)
- OKX: BTC/USDT (standard format, but some currencies may not be supported)
- Coinbase: BTC/USD (usually use USD instead of USDT)
- Kraken: XBT/USD (BTC is mapped to XBT)
- Bitfinex: tBTCUST (special format)
2026-02-28 20:34:33 +08:00
"""
normalized , base = self . _normalize_symbol ( symbol )
if not normalized or not base :
return symbol
exchange_id = getattr ( self . exchange , 'id' , '' ) . lower ()
2026-04-06 16:47:36 +07:00
# Special handling: symbol mapping for certain exchanges
2026-02-28 20:34:33 +08:00
if exchange_id == 'coinbase' :
2026-04-06 16:47:36 +07:00
# Coinbase usually uses USD instead of USDT
2026-02-28 20:34:33 +08:00
if normalized . endswith ( '/USDT' ):
usd_version = normalized . replace ( '/USDT' , '/USD' )
if self . _ensure_markets_loaded ():
markets = self . _markets_cache or {}
if usd_version in markets :
return usd_version
2026-04-06 16:47:36 +07:00
# Try to find a valid symbol on the exchange
2026-02-28 20:34:33 +08:00
if self . _ensure_markets_loaded ():
valid_symbol = self . _find_valid_symbol ( base , normalized . split ( '/' )[ 1 ] if '/' in normalized else 'USDT' )
if valid_symbol :
return valid_symbol
return normalized
2025-12-29 03:06:49 +08:00
def get_ticker ( self , symbol : str ) -> Dict [ str , Any ]:
"""
2025-12-30 17:03:22 +08:00
Get latest ticker for a crypto symbol via CCXT.
2025-12-29 03:06:49 +08:00
Accepts common formats:
2026-02-28 20:34:33 +08:00
- BTC/USDT, BTCUSDT, BTC/USDT:USDT
- PI, TRX (will be normalized and searched across exchanges)
2026-04-06 16:47:36 +07:00
- Automatically adapt to the symbol format requirements of different exchanges
2025-12-29 03:06:49 +08:00
"""
2026-02-28 20:34:33 +08:00
if not symbol or not symbol . strip ():
return { 'last' : 0 , 'symbol' : symbol }
2026-04-06 16:47:36 +07:00
# normalized notation
2026-02-28 20:34:33 +08:00
normalized = self . _normalize_symbol_for_exchange ( symbol )
if not normalized :
logger . warning ( f "Failed to normalize symbol: { symbol } " )
return { 'last' : 0 , 'symbol' : symbol }
2026-04-06 16:47:36 +07:00
# Try to get ticker
2026-02-28 20:34:33 +08:00
try :
ticker = self . exchange . fetch_ticker ( normalized )
if ticker and isinstance ( ticker , dict ):
return ticker
except Exception as e :
error_msg = str ( e ) . lower ()
is_symbol_error = any ( keyword in error_msg for keyword in [
'does not have market symbol' ,
'symbol not found' ,
'invalid symbol' ,
'market does not exist' ,
'trading pair not found'
])
if is_symbol_error :
2026-04-06 16:47:36 +07:00
# Try to find alternative symbols
2026-02-28 20:34:33 +08:00
base = normalized . split ( '/' )[ 0 ] if '/' in normalized else normalized
if self . _ensure_markets_loaded ():
valid_symbol = self . _find_valid_symbol ( base )
if valid_symbol and valid_symbol != normalized :
try :
logger . debug ( f "Trying alternative symbol: { valid_symbol } (original: { symbol } , first attempt: { normalized } )" )
ticker = self . exchange . fetch_ticker ( valid_symbol )
if ticker and isinstance ( ticker , dict ):
return ticker
except Exception as e2 :
logger . debug ( f "Alternative symbol { valid_symbol } also failed: { e2 } " )
2026-04-06 16:47:36 +07:00
# If all attempts fail, log a warning and return a default value
2026-02-28 20:34:33 +08:00
logger . warning (
f "Symbol ' { symbol } ' (normalized: { normalized } ) not found on { self . exchange . id } . "
f "Error: { str ( e )[: 100 ] } "
)
return { 'last' : 0 , 'symbol' : symbol }
2025-12-29 03:06:49 +08:00
def get_kline (
self ,
symbol : str ,
timeframe : str ,
limit : int ,
before_time : Optional [ int ] = None
) -> List [ Dict [ str , Any ]]:
2026-04-06 16:47:36 +07:00
"""Get cryptocurrency K-line data"""
2025-12-29 03:06:49 +08:00
klines = []
try :
ccxt_timeframe = self . TIMEFRAME_MAP . get ( timeframe , '1d' )
2026-04-06 16:47:36 +07:00
# Use a unified symbol normalization method
2026-02-28 20:34:33 +08:00
symbol_pair = self . _normalize_symbol_for_exchange ( symbol )
if not symbol_pair :
logger . warning ( f "Failed to normalize symbol for K-line: { symbol } " )
return []
2025-12-29 03:06:49 +08:00
2026-04-06 16:47:36 +07:00
# logger.info(f"Get cryptocurrency K-line: {symbol_pair}, period: {ccxt_timeframe}, number of bars: {limit}")
2025-12-29 03:06:49 +08:00
ohlcv = self . _fetch_ohlcv ( symbol_pair , ccxt_timeframe , limit , before_time , timeframe )
if not ohlcv :
logger . warning ( f "CCXT returned no K-lines: { symbol_pair } " )
return []
2026-04-06 16:47:36 +07:00
# Convert data format
2025-12-29 03:06:49 +08:00
for candle in ohlcv :
if len ( candle ) < 6 :
continue
klines . append ( self . format_kline (
2026-04-06 16:47:36 +07:00
timestamp = int ( candle [ 0 ] / 1000 ), # Milliseconds to seconds
2025-12-29 03:06:49 +08:00
open_price = candle [ 1 ],
high = candle [ 2 ],
low = candle [ 3 ],
close = candle [ 4 ],
volume = candle [ 5 ]
))
2026-04-06 16:47:36 +07:00
# Filter and restrict
2025-12-29 03:06:49 +08:00
klines = self . filter_and_limit ( klines , limit , before_time )
2026-04-06 16:47:36 +07:00
# Record results
2025-12-29 03:06:49 +08:00
self . log_result ( symbol , klines , timeframe )
except Exception as e :
logger . error ( f "Failed to fetch crypto K-lines { symbol } : { str ( e ) } " )
import traceback
logger . error ( traceback . format_exc ())
return klines
def _fetch_ohlcv (
self ,
symbol_pair : str ,
ccxt_timeframe : str ,
limit : int ,
before_time : Optional [ int ],
timeframe : str
) -> List :
2026-04-06 16:47:36 +07:00
"""Obtain OHLCV data (supports paging to obtain complete data)"""
2025-12-29 03:06:49 +08:00
try :
if before_time :
2026-04-06 16:47:36 +07:00
# Calculation time range
2025-12-29 03:06:49 +08:00
total_seconds = self . calculate_time_range ( timeframe , limit )
end_time = datetime . fromtimestamp ( before_time )
start_time = end_time - timedelta ( seconds = total_seconds )
since = int ( start_time . timestamp () * 1000 )
end_ms = before_time * 1000
2026-04-06 16:47:36 +07:00
# logger.info(f"Historical data request: since={since//1000}, end={before_time}, time span={total_seconds/86400:.1f} days")
2025-12-29 03:06:49 +08:00
2026-04-06 16:47:36 +07:00
# Fetch data in pages until the complete time range is covered
2025-12-29 03:06:49 +08:00
all_ohlcv = []
2025-12-30 17:03:22 +08:00
batch_limit = 300 # Coinbase limit is often 300, safer than 1000
2025-12-29 03:06:49 +08:00
current_since = since
while current_since < end_ms :
batch = self . exchange . fetch_ohlcv (
symbol_pair ,
ccxt_timeframe ,
since = current_since ,
limit = batch_limit
)
if not batch :
break
all_ohlcv . extend ( batch )
2026-04-06 16:47:36 +07:00
# The time when the last piece of data is obtained is used as the starting time of the next request
2025-12-29 03:06:49 +08:00
last_timestamp = batch [ - 1 ][ 0 ]
2026-04-06 16:47:36 +07:00
# If the time of the last data exceeds the end time, or the returned data is less than the requested amount, it means that the acquisition has been completed.
2026-01-17 00:31:44 +08:00
# if last_timestamp >= end_ms or len(batch) < batch_limit:
if last_timestamp >= end_ms :
2025-12-29 03:06:49 +08:00
break
2026-04-06 16:47:36 +07:00
# Next time, start from the next time point of the last item
2025-12-29 03:06:49 +08:00
timeframe_ms = TIMEFRAME_SECONDS . get ( timeframe , 86400 ) * 1000
current_since = last_timestamp + timeframe_ms
2026-04-06 16:47:36 +07:00
# logger.info(f"Getting in paging: {len(all_ohlcv)} items have been obtained, continue from {datetime.fromtimestamp(current_since/1000)}")
2025-12-29 03:06:49 +08:00
ohlcv = all_ohlcv
else :
ohlcv = self . exchange . fetch_ohlcv ( symbol_pair , ccxt_timeframe , limit = limit )
2026-04-06 16:47:36 +07:00
# logger.info(f"CCXT returns {len(ohlcv) if ohlcv else 0} pieces of data")
2025-12-29 03:06:49 +08:00
return ohlcv
except Exception as e :
logger . warning ( f "CCXT fetch_ohlcv failed: { str ( e ) } ; trying fallback" )
return self . _fetch_ohlcv_fallback ( symbol_pair , ccxt_timeframe , limit , before_time , timeframe )
def _fetch_ohlcv_fallback (
self ,
symbol_pair : str ,
ccxt_timeframe : str ,
limit : int ,
before_time : Optional [ int ],
timeframe : str
) -> List :
2026-04-06 16:47:36 +07:00
"""Alternate acquisition method"""
2025-12-29 03:06:49 +08:00
try :
total_seconds = self . calculate_time_range ( timeframe , limit )
if before_time :
end_time = datetime . fromtimestamp ( before_time )
start_time = end_time - timedelta ( seconds = total_seconds )
since = int ( start_time . timestamp () * 1000 )
else :
since = int (( datetime . now () - timedelta ( seconds = total_seconds )) . timestamp () * 1000 )
ohlcv = self . exchange . fetch_ohlcv ( symbol_pair , ccxt_timeframe , since = since , limit = limit )
2026-04-06 16:47:36 +07:00
# logger.info(f"CCXT alternative method returns {len(ohlcv) if ohlcv else 0} pieces of data")
2025-12-29 03:06:49 +08:00
return ohlcv
except Exception as e :
logger . error ( f "CCXT fallback method also failed: { str ( e ) } " )
return []