cc-1.0.x-dev/modules/cc_cex/src/Service/CcxtService.php
modules/cc_cex/src/Service/CcxtService.php
<?php
namespace Drupal\cc_cex\Service;
use ccxt\Exchange;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\key\KeyRepositoryInterface;
/**
* Service for interacting with cryptocurrency exchanges via CCXT.
*/
class CcxtService {
/**
* The cache backend.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $cache;
/**
* The key repository.
*
* @var \Drupal\key\KeyRepositoryInterface
*/
protected $keyRepository;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* Cache key for markets data.
*/
const MARKETS_CACHE_KEY = 'cc:markets:';
/**
* Cache key for symbols data.
*/
const SYMBOLS_CACHE_KEY = 'cc:symbols:';
/**
* Cache key for prices data.
*/
const PRICES_CACHE_KEY = 'cc:prices:';
/**
* Cache key for balances data.
*/
const BALANCES_CACHE_KEY = 'cc:balances:';
/**
* Cache lifetime for markets data.
*/
const MARKETS_CACHE_LIFETIME = Cache::PERMANENT;
/**
* Cache lifetime for balances data.
*/
const BALANCES_CACHE_LIFETIME = Cache::PERMANENT;
/**
* Constructs a new CcxtService object.
*
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* The cache backend.
* @param \Drupal\key\KeyRepositoryInterface $key_repository
* The key repository.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
*/
public function __construct(
CacheBackendInterface $cache,
KeyRepositoryInterface $key_repository,
ConfigFactoryInterface $config_factory
) {
$this->cache = $cache;
$this->keyRepository = $key_repository;
$this->configFactory = $config_factory;
}
/**
* Gets the markets for a specific exchange.
*
* @param string $exchange_id
* The exchange ID.
*
* @return array
* Array of markets for the exchange.
*/
protected function getExchangeMarkets(string $exchange_id): array {
try {
// Check cache first
if ($cached = $this->getCachedMarkets($exchange_id)) {
return $cached;
}
$exchange_class = "\\ccxt\\$exchange_id";
$exchange = new $exchange_class([
'enableRateLimit' => true,
'timeout' => 10000,
]);
// Load markets
$markets = $exchange->load_markets();
if (empty($markets)) {
throw new \Exception('No markets found');
}
$this->setCachedMarkets($exchange_id, $markets);
return $markets;
}
catch (\Exception $e) {
\Drupal::logger('cc')->error('Error loading markets for @exchange: @error', [
'@exchange' => $exchange_id,
'@error' => $e->getMessage(),
]);
return [];
}
}
/**
* Gets an initialized exchange instance.
*/
public function getExchange(string $exchange_id) {
try {
$exchange_class = "\\ccxt\\$exchange_id";
// Get exchange configuration including API keys
$config = $this->configFactory->get('cc.settings');
$exchange_keys = $config->get('exchange_keys.' . $exchange_id) ?? [];
// Initialize exchange
$exchange = new $exchange_class();
// Set up API credentials if configured
if (!empty($exchange_keys['api_key_id']) && !empty($exchange_keys['api_secret_id'])) {
$api_key = $this->keyRepository->getKey($exchange_keys['api_key_id']);
$api_secret = $this->keyRepository->getKey($exchange_keys['api_secret_id']);
if ($api_key && $api_secret) {
$exchange->apiKey = $api_key->getKeyValue();
$exchange->secret = $api_secret->getKeyValue();
// Set any additional parameters
if (!empty($exchange_keys['additional_params'])) {
foreach ($exchange_keys['additional_params'] as $param) {
if (!empty($param['key_id'])) {
$key = $this->keyRepository->getKey($param['key_id']);
if ($key && !empty($param['param_name'])) {
$exchange->{$param['param_name']} = $key->getKeyValue();
}
}
}
}
}
}
return $exchange;
}
catch (\Exception $e) {
throw new \Exception("Failed to initialize exchange $exchange_id: " . $e->getMessage());
}
}
/**
* Fetches price for a specific market pair.
*/
protected function fetchPrice($exchange_id, $symbol) {
$cache_key = "cc:price:{$exchange_id}:{$symbol}";
// Check cache first
if ($cache = $this->cache->get($cache_key)) {
return $cache->data;
}
try {
$exchange = $this->getExchange($exchange_id);
if (!$exchange) {
return null;
}
// Get ticker data
$ticker = $exchange->fetch_ticker($symbol);
// Only use current time if timestamp is missing from the exchange response
$timestamp = $ticker['timestamp'];
if (!$timestamp) {
$timestamp = time() * 1000;
}
$price_data = [
'price' => $ticker['last'] ?? null,
'timestamp' => $timestamp,
];
// Cache indefinitely - prices will only be updated via manual refresh
$this->cache->set($cache_key, $price_data);
return $price_data;
}
catch (\Exception $e) {
\Drupal::logger('cc')->error('Failed to fetch price for @exchange @symbol: @error', [
'@exchange' => $exchange_id,
'@symbol' => $symbol,
'@error' => $e->getMessage(),
]);
return null;
}
}
/**
* Get last price for a market pair.
*
* @param string $exchange_id
* The exchange ID.
* @param string $symbol
* The market symbol (e.g., 'BTC/USD').
*
* @return array
* The last price and timestamp.
*/
protected function getLastPrice(string $exchange_id, string $symbol): array {
// Try to get from cache first
$cache_key = 'cc:price:' . $exchange_id . ':' . str_replace('/', '_', $symbol);
if ($cache = $this->cache->get($cache_key)) {
return $cache->data;
}
try {
$exchange_class = "\\ccxt\\$exchange_id";
$exchange = new $exchange_class();
// Get ticker data
$ticker = $exchange->fetch_ticker($symbol);
// Only use current time if timestamp is missing from the exchange response
$timestamp = $ticker['timestamp'];
if (!$timestamp) {
$timestamp = time() * 1000;
}
$price_data = [
'price' => $ticker['last'] ?? null,
'timestamp' => $timestamp,
];
// Cache indefinitely - prices will only be updated via manual refresh
$this->cache->set($cache_key, $price_data);
return $price_data;
}
catch (\Exception $e) {
\Drupal::logger('cc')->error('Error fetching price for @exchange @symbol: @error', [
'@exchange' => $exchange_id,
'@symbol' => $symbol,
'@error' => $e->getMessage(),
]);
return [
'price' => null,
'timestamp' => time() * 1000, // Use current time for errors too
];
}
}
/**
* Gets cached markets data for an exchange.
*
* @param string $exchange_id
* The exchange ID.
*
* @return array|null
* The markets data or null if not cached.
*/
protected function getCachedMarkets(string $exchange_id): ?array {
$cache = \Drupal::cache();
$cached = $cache->get(self::MARKETS_CACHE_KEY . $exchange_id);
return $cached ? $cached->data : null;
}
/**
* Sets cached markets data for an exchange.
*
* @param string $exchange_id
* The exchange ID.
* @param array $markets
* The markets data to cache.
*/
protected function setCachedMarkets(string $exchange_id, array $markets): void {
$cache = \Drupal::cache();
$cache->set(
self::MARKETS_CACHE_KEY . $exchange_id,
$markets,
Cache::PERMANENT,
['cc:markets']
);
}
/**
* Gets cached valid symbols for an exchange and currency configuration.
*
* @param string $exchange_id
* The exchange ID.
* @param array $needed_currencies
* Array of needed currency IDs.
* @param array $accepted_currencies
* Array of accepted currency IDs.
*
* @return array|null
* Array of valid symbols or null if not cached.
*/
protected function getCachedSymbols(string $exchange_id, array $needed_currencies, array $accepted_currencies): ?array {
sort($needed_currencies);
sort($accepted_currencies);
$cache_key = self::SYMBOLS_CACHE_KEY . $exchange_id . ':' . md5(serialize($needed_currencies) . serialize($accepted_currencies));
$cached = $this->cache->get($cache_key);
return $cached ? $cached->data : null;
}
/**
* Sets cached valid symbols for an exchange and currency configuration.
*
* @param string $exchange_id
* The exchange ID.
* @param array $needed_currencies
* Array of needed currency IDs.
* @param array $accepted_currencies
* Array of accepted currency IDs.
* @param array $symbols
* Array of valid symbols to cache.
*/
protected function setCachedSymbols(string $exchange_id, array $needed_currencies, array $accepted_currencies, array $symbols): void {
sort($needed_currencies);
sort($accepted_currencies);
$cache_key = self::SYMBOLS_CACHE_KEY . $exchange_id . ':' . md5(serialize($needed_currencies) . serialize($accepted_currencies));
$this->cache->set(
$cache_key,
$symbols,
Cache::PERMANENT,
['cc:symbols']
);
}
/**
* Gets available markets from exchanges.
*/
public function getAvailableMarkets(array $enabled_exchanges, array $needed_currencies, array $accepted_currencies, bool $force_refresh = false): array {
$start_time = microtime(true);
$market_pairs = [];
$promises = [];
foreach ($enabled_exchanges as $exchange_id) {
try {
$exchange_start = microtime(true);
// Initialize exchange
$exchange_class = "\\ccxt\\$exchange_id";
if (!class_exists($exchange_class)) {
\Drupal::logger('cc')->warning('Exchange class not found: @exchange', ['@exchange' => $exchange_class]);
continue;
}
/** @var \ccxt\Exchange $exchange */
$exchange = new $exchange_class([
'enableRateLimit' => true,
'timeout' => 10000,
]);
$markets_start = microtime(true);
// Load markets to get supported pairs
$cached = $this->cache->get(self::MARKETS_CACHE_KEY . $exchange_id);
if (!$force_refresh && $cached) {
$exchange->markets = $cached->data;
}
else {
$exchange->load_markets();
$this->cache->set(
self::MARKETS_CACHE_KEY . $exchange_id,
$exchange->markets,
Cache::PERMANENT,
['cc:markets']
);
}
// Try to get valid symbols from cache
$symbols = !$force_refresh ? $this->getCachedSymbols($exchange_id, $needed_currencies, $accepted_currencies) : null;
if ($symbols === null) {
// Get all possible symbol combinations
$symbols = [];
foreach ($needed_currencies as $needed) {
if (empty($needed)) continue;
foreach ($accepted_currencies as $accepted) {
if (empty($accepted)) continue;
$buy_symbol = strtoupper("$accepted/$needed");
$sell_symbol = strtoupper("$needed/$accepted");
// Only add symbols that exist in the exchange
if (!empty($buy_symbol) && $this->isMarketSupported($exchange, $buy_symbol)) {
$symbols[] = $buy_symbol;
}
if (!empty($sell_symbol) && $this->isMarketSupported($exchange, $sell_symbol)) {
$symbols[] = $sell_symbol;
}
}
}
// Cache the valid symbols
$this->setCachedSymbols($exchange_id, $needed_currencies, $accepted_currencies, $symbols);
}
if (empty($symbols)) {
continue;
}
// Add valid symbols without prices
foreach ($symbols as $symbol) {
$market_pairs[] = [
'exchange' => $exchange_id,
'symbol' => $symbol,
];
}
}
catch (\Exception $e) {
\Drupal::logger('cc')->warning('Error processing @exchange: @error', [
'@exchange' => $exchange_id,
'@error' => $e->getMessage(),
]);
continue;
}
}
return $market_pairs;
}
/**
* Checks if a market symbol is supported by an exchange.
*/
protected function isMarketSupported($exchange, string $symbol): bool {
try {
if (empty($symbol) || !str_contains($symbol, '/')) {
return false;
}
if (!isset($exchange->markets[$symbol])) {
return false;
}
// Try to get the market info to validate it's a valid symbol
$market = $exchange->market($symbol);
return $market !== null && isset($market['symbol']);
}
catch (\Exception $e) {
\Drupal::logger('cc')->warning('Error checking market support for @symbol: @error', [
'@symbol' => $symbol,
'@error' => $e->getMessage(),
]);
return false;
}
}
/**
* Clears the price cache for all pairs.
*/
public function clearPriceCache(): void {
// Clear all caches related to markets and symbols
$this->cache->invalidateTags(['cc:markets', 'cc:symbols', 'cc:prices']);
}
/**
* Gets prices for specific market pairs.
*
* @param array $enabled_exchanges
* Array of exchange IDs.
* @param array $symbols
* Array of market symbols to fetch prices for.
* @param bool $force_refresh
* Whether to force a refresh of the price data.
*
* @return array
* Array of market pairs with prices.
*/
public function getPrices(array $enabled_exchanges, array $symbols, bool $force_refresh = false): array {
if (empty($symbols)) {
return [];
}
$market_pairs = [];
foreach ($enabled_exchanges as $exchange_id) {
try {
$exchange = $this->getExchange($exchange_id);
if (!$exchange) {
continue;
}
// Get cached markets to validate symbols first
$cached = $this->cache->get(self::MARKETS_CACHE_KEY . $exchange_id);
if (!$cached) {
continue;
}
$exchange->markets = $cached->data;
// Only try to fetch prices for symbols that exist and are active in this exchange
$valid_symbols = array_filter($symbols, function($symbol) use ($exchange) {
return isset($exchange->markets[$symbol]) &&
($exchange->markets[$symbol]['active'] ?? true); // Some exchanges don't specify active status
});
if (empty($valid_symbols)) {
continue;
}
// Some exchanges don't support fetch_tickers, so we'll fetch them one by one
foreach ($valid_symbols as $symbol) {
$cache_key = self::PRICES_CACHE_KEY . $exchange_id . ':' . str_replace('/', '_', $symbol);
// Try to get from cache first if not forcing refresh
if (!$force_refresh) {
$cached = $this->cache->get($cache_key);
if ($cached) {
$market_pairs[] = [
'exchange' => $exchange_id,
'symbol' => $symbol,
'price' => $cached->data['price'],
'timestamp' => $cached->data['timestamp'],
];
continue;
}
}
try {
$ticker = $exchange->fetch_ticker($symbol);
if (!$ticker || empty($ticker['last'])) {
continue;
}
$price_data = [
'price' => $ticker['last'],
'timestamp' => $ticker['timestamp'] ?? time() * 1000,
];
// Cache the price data permanently
$this->cache->set(
$cache_key,
$price_data,
Cache::PERMANENT,
['cc:prices']
);
$market_pairs[] = [
'exchange' => $exchange_id,
'symbol' => $symbol,
'price' => $price_data['price'],
'timestamp' => $price_data['timestamp'],
];
}
catch (\Exception $e) {
\Drupal::logger('cc')->warning('Error fetching ticker for @exchange @symbol: @error', [
'@exchange' => $exchange_id,
'@symbol' => $symbol,
'@error' => $e->getMessage(),
]);
continue;
}
}
}
catch (\Exception $e) {
\Drupal::logger('cc')->error('Error initializing exchange @exchange: @error', [
'@exchange' => $exchange_id,
'@error' => $e->getMessage(),
]);
return [];
}
}
return $market_pairs;
}
/**
* Gets available symbols from cached data without fetching prices.
*
* @param array $enabled_exchanges
* Array of exchange IDs.
* @param array $needed_currencies
* Array of needed currency IDs.
* @param array $accepted_currencies
* Array of accepted currency IDs.
*
* @return array
* Array of valid symbols for each exchange.
*/
public function getAvailableSymbols(array $enabled_exchanges, array $needed_currencies, array $accepted_currencies): array {
// Ensure arrays are not associative
$needed_currencies = array_values($needed_currencies);
$accepted_currencies = array_values($accepted_currencies);
$exchange_symbols = [];
foreach ($enabled_exchanges as $exchange_id) {
try {
// Initialize exchange
$exchange_class = "\\ccxt\\$exchange_id";
if (!class_exists($exchange_class)) {
\Drupal::logger('cc')->warning('Exchange class not found: @exchange', ['@exchange' => $exchange_class]);
continue;
}
/** @var \ccxt\Exchange $exchange */
$exchange = new $exchange_class([
'enableRateLimit' => true,
'timeout' => 10000,
]);
// Load markets to get supported pairs
$cached = $this->cache->get(self::MARKETS_CACHE_KEY . $exchange_id);
if ($cached) {
$exchange->markets = $cached->data;
}
else {
$exchange->load_markets();
$this->cache->set(
self::MARKETS_CACHE_KEY . $exchange_id,
$exchange->markets,
Cache::PERMANENT,
['cc:markets']
);
}
// Try to get valid symbols from cache
$symbols = $this->getCachedSymbols($exchange_id, $needed_currencies, $accepted_currencies);
if ($symbols === null) {
// Get all possible symbol combinations
$symbols = [];
foreach ($needed_currencies as $needed) {
if (empty($needed)) continue;
foreach ($accepted_currencies as $accepted) {
if (empty($accepted)) continue;
$buy_symbol = strtoupper("$accepted/$needed");
$sell_symbol = strtoupper("$needed/$accepted");
// Only add symbols that exist in the exchange
if (!empty($buy_symbol) && isset($exchange->markets[$buy_symbol])) {
$symbols[] = $buy_symbol;
}
if (!empty($sell_symbol) && isset($exchange->markets[$sell_symbol])) {
$symbols[] = $sell_symbol;
}
}
}
// Cache the valid symbols
$this->setCachedSymbols($exchange_id, $needed_currencies, $accepted_currencies, $symbols);
}
$exchange_symbols[$exchange_id] = $symbols;
}
catch (\Exception $e) {
\Drupal::logger('cc')->error('Error getting symbols for @exchange: @error', [
'@exchange' => $exchange_id,
'@error' => $e->getMessage(),
]);
return [];
}
}
return $exchange_symbols;
}
/**
* Gets a list of all available exchanges from CCXT.
*
* @return array
* Array of exchange IDs.
*/
public function getAvailableExchanges(): array {
$exchanges = \ccxt\Exchange::$exchanges;
sort($exchanges);
return $exchanges;
}
/**
* Gets cached balances for an exchange.
*
* @param string $exchange_id
* The exchange ID.
*
* @return array|null
* The cached balances or NULL if not cached.
*/
protected function getCachedBalances(string $exchange_id): ?array {
$cache_key = self::BALANCES_CACHE_KEY . $exchange_id;
if ($cached = $this->cache->get($cache_key)) {
return $cached->data;
}
return NULL;
}
/**
* Sets cached balances for an exchange.
*
* @param string $exchange_id
* The exchange ID.
* @param array $balances
* The balances to cache.
*/
protected function setCachedBalances(string $exchange_id, array $balances) {
$cache_key = self::BALANCES_CACHE_KEY . $exchange_id;
$this->cache->set(
$cache_key,
$balances,
self::BALANCES_CACHE_LIFETIME,
['cc:balances', 'cc:balances:' . $exchange_id]
);
}
/**
* Get balances for an exchange.
*
* @param string $exchange_id
* The exchange ID.
* @param bool $force_refresh
* Whether to force a refresh of the cached data.
*
* @return array
* Array of balances.
*/
public function getBalances(string $exchange_id, bool $force_refresh = false): array {
try {
// Check cache first unless forcing refresh
if (!$force_refresh) {
if ($cached = $this->getCachedBalances($exchange_id)) {
return $cached;
}
}
$exchange = $this->getExchange($exchange_id);
$balances = $exchange->fetch_balance();
// Cache the balances
$this->setCachedBalances($exchange_id, $balances);
return $balances;
}
catch (\Exception $e) {
\Drupal::logger('cc')->error('Error fetching balances for @exchange: @error', [
'@exchange' => $exchange_id,
'@error' => $e->getMessage(),
]);
return [];
}
}
}
