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 [];
    }
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc