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-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
from datetime import datetime , timedelta
2026-04-09 14:30:51 +07:00
from typing import Any , Dict , List , Optional , Tuple
2025-12-29 03:06:49 +08:00
import ccxt
2026-04-09 14:30:51 +07:00
from app.config import CCXTConfig
from app.data_sources.base import TIMEFRAME_SECONDS , BaseDataSource
2025-12-29 03:06:49 +08:00
from app.utils.logger import get_logger
logger = get_logger ( __name__ )
class CryptoDataSource ( BaseDataSource ):
2026-04-06 16:47:36 +07:00
"""Cryptocurrency data source"""
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
name = "Crypto/CCXT"
2026-04-09 14:30:51 +07:00
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-09 14:30:51 +07:00
2026-04-06 16:47:36 +07:00
# List of common quote currencies (sorted by priority)
2026-04-09 14:30:51 +07:00
COMMON_QUOTES = [ "USDT" , "USD" , "BTC" , "ETH" , "BUSD" , "USDC" , "BNB" , "EUR" , "GBP" ]
2025-12-29 03:06:49 +08:00
def __init__ ( self ):
2026-04-09 14:30:51 +07:00
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 :
2026-04-09 14:30:51 +07:00
config [ "proxies" ] = { "http" : CCXTConfig . PROXY , "https" : CCXTConfig . PROXY }
2025-12-30 17:03:22 +08:00
exchange_id = CCXTConfig . DEFAULT_EXCHANGE
2026-04-09 14:30:51 +07:00
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'" )
2026-04-09 14:30:51 +07:00
exchange_id = "coinbase"
2025-12-30 17:03:22 +08:00
exchange_class = getattr ( ccxt , exchange_id )
self . exchange = exchange_class ( config )
2026-04-09 14:30:51 +07: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
2026-04-09 14:30:51 +07:00
2026-02-28 20:34:33 +08:00
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
2026-04-09 14:30:51 +07:00
2026-02-28 20:34:33 +08:00
try :
2026-04-06 16:47:36 +07:00
# Some exchanges require explicit loading of markets
2026-04-09 14:30:51 +07:00
if hasattr ( self . exchange , "load_markets" ):
2026-02-28 20:34:33 +08:00
self . exchange . load_markets ( reload = False )
2026-04-09 14:30:51 +07:00
self . _markets_cache = getattr ( self . exchange , "markets" , {})
2026-02-28 20:34:33 +08:00
self . _markets_loaded = True
return True
except Exception as e :
logger . debug ( f "Failed to load markets for { self . exchange . id } : { e } " )
return False
2026-04-09 14:30:51 +07:00
2026-02-28 20:34:33 +08:00
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-04-09 14:30:51 +07: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 :
2026-04-09 14:30:51 +07:00
return "" , ""
2026-02-28 20:34:33 +08:00
sym = symbol . strip ()
2026-04-09 14:30:51 +07:00
2026-04-06 16:47:36 +07:00
# Remove swap/futures suffix
2026-04-09 14:30:51 +07:00
if ":" in sym :
sym = sym . split ( ":" , 1 )[ 0 ]
2026-02-28 20:34:33 +08:00
sym = sym . upper ()
2026-04-09 14:30:51 +07:00
2026-04-06 16:47:36 +07:00
# If there is already a separator, parse it directly
2026-04-09 14:30:51 +07:00
if "/" in sym :
parts = sym . split ( "/" , 1 )
2026-02-28 20:34:33 +08:00
base = parts [ 0 ] . strip ()
2026-04-09 14:30:51 +07:00
quote = parts [ 1 ] . strip () if len ( parts ) > 1 else ""
2026-02-28 20:34:33 +08:00
if base and quote :
return f " { base } / { quote } " , base
2026-04-09 14:30:51 +07:00
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 ):
2026-04-09 14:30:51 +07:00
base = sym [: - len ( quote )]
2026-02-28 20:34:33 +08:00
if base :
return f " { base } / { quote } " , base
2026-04-09 14:30:51 +07:00
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
2026-04-09 14:30:51 +07:00
def _find_valid_symbol ( self , base : str , preferred_quote : str = "USDT" ) -> Optional [ str ]:
2026-02-28 20:34:33 +08:00
"""
2026-04-06 16:47:36 +07:00
Find valid symbols in the exchange's markets
2026-04-09 14:30:51 +07:00
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-04-09 14:30:51 +07:00
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
2026-04-09 14:30:51 +07:00
2026-02-28 20:34:33 +08:00
markets = self . _markets_cache or {}
if not markets :
return None
2026-04-09 14:30:51 +07:00
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 ]
2026-04-09 14:30:51 +07:00
2026-02-28 20:34:33 +08:00
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-04-09 14:30:51 +07:00
if market . get ( "active" , True ):
2026-02-28 20:34:33 +08:00
return candidate
2026-04-09 14:30:51 +07:00
2026-02-28 20:34:33 +08:00
return None
2026-04-09 14:30:51 +07:00
2026-02-28 20:34:33 +08:00
def _normalize_symbol_for_exchange ( self , symbol : str ) -> str :
"""
2026-04-06 16:47:36 +07:00
Standardize symbols based on exchange characteristics
2026-04-09 14:30:51 +07: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 )
2026-04-09 14:30:51 +07:00
2026-02-28 20:34:33 +08:00
if not normalized or not base :
return symbol
2026-04-09 14:30:51 +07:00
exchange_id = getattr ( self . exchange , "id" , "" ) . lower ()
2026-04-06 16:47:36 +07:00
# Special handling: symbol mapping for certain exchanges
2026-04-09 14:30:51 +07:00
if exchange_id == "coinbase" :
2026-04-06 16:47:36 +07:00
# Coinbase usually uses USD instead of USDT
2026-04-09 14:30:51 +07:00
if normalized . endswith ( "/USDT" ):
usd_version = normalized . replace ( "/USDT" , "/USD" )
2026-02-28 20:34:33 +08:00
if self . _ensure_markets_loaded ():
markets = self . _markets_cache or {}
if usd_version in markets :
return usd_version
2026-04-09 14:30:51 +07:00
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 ():
2026-04-09 14:30:51 +07:00
valid_symbol = self . _find_valid_symbol ( base , normalized . split ( "/" )[ 1 ] if "/" in normalized else "USDT" )
2026-02-28 20:34:33 +08:00
if valid_symbol :
return valid_symbol
2026-04-09 14:30:51 +07:00
2026-02-28 20:34:33 +08:00
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 ():
2026-04-09 14:30:51 +07:00
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 )
2026-04-09 14:30:51 +07:00
2026-02-28 20:34:33 +08:00
if not normalized :
logger . warning ( f "Failed to normalize symbol: { symbol } " )
2026-04-09 14:30:51 +07:00
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 ()
2026-04-09 14:30:51 +07:00
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" ,
]
)
2026-02-28 20:34:33 +08:00
if is_symbol_error :
2026-04-06 16:47:36 +07:00
# Try to find alternative symbols
2026-04-09 14:30:51 +07:00
base = normalized . split ( "/" )[ 0 ] if "/" in normalized else normalized
2026-02-28 20:34:33 +08:00
if self . _ensure_markets_loaded ():
valid_symbol = self . _find_valid_symbol ( base )
if valid_symbol and valid_symbol != normalized :
try :
2026-04-09 14:30:51 +07:00
logger . debug (
f "Trying alternative symbol: { valid_symbol } (original: { symbol } , first attempt: { normalized } )"
)
2026-02-28 20:34:33 +08:00
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-09 14:30:51 +07:00
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 (
2026-04-09 14:30:51 +07:00
f "Symbol ' { symbol } ' (normalized: { normalized } ) not found on { self . exchange . id } . Error: { str ( e )[: 100 ] } "
2026-02-28 20:34:33 +08:00
)
2026-04-09 14:30:51 +07:00
return { "last" : 0 , "symbol" : symbol }
2025-12-29 03:06:49 +08:00
def get_kline (
2026-04-09 14:30:51 +07:00
self , symbol : str , timeframe : str , limit : int , before_time : Optional [ int ] = None
2025-12-29 03:06:49 +08:00
) -> 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 = []
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
try :
2026-04-09 14:30:51 +07:00
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 )
2026-04-09 14:30:51 +07:00
2026-02-28 20:34:33 +08:00
if not symbol_pair :
logger . warning ( f "Failed to normalize symbol for K-line: { symbol } " )
return []
2026-04-09 14:30:51 +07: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}")
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
ohlcv = self . _fetch_ohlcv ( symbol_pair , ccxt_timeframe , limit , before_time , timeframe )
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
if not ohlcv :
logger . warning ( f "CCXT returned no K-lines: { symbol_pair } " )
return []
2026-04-09 14:30:51 +07:00
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
2026-04-09 14:30:51 +07:00
klines . append (
self . format_kline (
timestamp = int ( candle [ 0 ] / 1000 ), # Milliseconds to seconds
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-09 14:30:51 +07:00
2026-04-06 16:47:36 +07:00
# Record results
2025-12-29 03:06:49 +08:00
self . log_result ( symbol , klines , timeframe )
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
except Exception as e :
logger . error ( f "Failed to fetch crypto K-lines { symbol } : { str ( e ) } " )
import traceback
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
return klines
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
def _fetch_ohlcv (
2026-04-09 14:30:51 +07:00
self , symbol_pair : str , ccxt_timeframe : str , limit : int , before_time : Optional [ int ], timeframe : str
2025-12-29 03:06:49 +08:00
) -> 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-09 14:30:51 +07:00
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")
2026-04-09 14:30:51 +07: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
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
while current_since < end_ms :
batch = self . exchange . fetch_ohlcv (
2026-04-09 14:30:51 +07:00
symbol_pair , ccxt_timeframe , since = current_since , limit = batch_limit
2025-12-29 03:06:49 +08:00
)
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
if not batch :
break
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
all_ohlcv . extend ( batch )
2026-04-09 14:30:51 +07:00
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-09 14:30:51 +07:00
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-09 14:30:51 +07:00
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-09 14:30:51 +07:00
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)}")
2026-04-09 14:30:51 +07:00
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-09 14:30:51 +07:00
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
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
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 )
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
def _fetch_ohlcv_fallback (
2026-04-09 14:30:51 +07:00
self , symbol_pair : str , ccxt_timeframe : str , limit : int , before_time : Optional [ int ], timeframe : str
2025-12-29 03:06:49 +08:00
) -> 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 )
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
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 )
2026-04-09 14:30:51 +07:00
2025-12-29 03:06:49 +08:00
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 []