Привет Хабр!
Я сейчас пишу локальное приложение на Electron по трекингу и ведению крипто портфеля.
Недавно выпустил MVP и теперь перешел к разработке полноценной версии.

Ранее для получения свежих рыночных данных я использовал CoinGecko API, в результате чего это привело к 20+ минутной синхронизации. Да, можно было что-то придумать, и я даже знаю что, но было решено для MVP не заморачиваться, чтобы быстрее выпустить и протестировать концепцию.

Теперь для полноценной версии приложения я решил использовать более гибкий подход - не тащить все монеты (19078 на момент написания), а просто обновлять имеющиеся подписки у юзера. Для этого я решил использовать CCXT, но тут всплыл нюанс - не все монеты есть на бирже, значит придётся снова обращаться к CoinGecko.

В поиске информации я столкнулся с тем, что никто здесь не описывал подобный кейс использования библиотеки, либо я плохо искал. Большинство найденных мной статей описывали использование CCXT для ботов и арбитража, потому я и решил написать эту статью. Ну и закрепить знания "на бумаге" лишним не будет.

Если кратко - CCXT можно использовать для работы с биржами.
С моего IP я смог проверить доступ к Bitget, OKX, Kraken, Huobi, а вот Bybit и Binance в доступе мне отказали.
Для статьи я приведу примеры на Bitget.

В моем примере я использую npm, Nest.js, SQLite, Prisma.


1. Базовый пример работы

Начать предлагаю с самого начала - установки:

npm install ccxt

Теперь импортируем в модуль:

import * as ccxt from 'ccxt';

Подключаем нужную биржу:

const exchange = new ccxt.bitget();

Загружаем маркет для работы:

await exchange.loadMarkets();

Теперь можем взять первые данные:

const ticker = await exchange.fetchTicker('BTC/USDT');

{
  "symbol": "BTC/USDT",
  "timestamp": 1759827524466,
  "datetime": "2025-10-07T08:58:44.466Z",
  "high": 126200,
  "low": 123319.16,
  "bid": 123882.71,
  "bidVolume": 0.206719,
  "ask": 123882.72,
  "askVolume": 2.956082,
  "vwap": 124756.14789666026,
  "open": 123860.42,
  "close": 123883,
  "last": 123883,
  "previousClose": null,
  "change": 0.00018,
  "percentage": 0.018,
  "average": 123871.71,
  "baseVolume": 11078.445707,
  "quoteVolume": 1382104211.087613,
  "indexPrice": null,
  "markPrice": null,
  "info": {
    "open": "123860.42",
    "symbol": "BTCUSDT",
    "high24h": "126200",
    "low24h": "123319.16",
    "lastPr": "123883",
    "quoteVolume": "1382104211.087613",
    "baseVolume": "11078.445707",
    "usdtVolume": "1382104211.087612972174",
    "ts": "1759827524466",
    "bidPr": "123882.71",
    "askPr": "123882.72",
    "bidSz": "0.206719",
    "askSz": "2.956082",
    "openUtc": "124670.54",
    "changeUtc24h": "-0.00632",
    "change24h": "0.00018"
  }
}

Если мы хотим получить все пары одним куском:

const tickers = await exchange.fetchTickers();

Для простого трекинга этого будет достаточно - вызываем раз-два в минуту, фильтруем и записываем. Но это не всё, что мы можем взять с биржи. Переходим на второй уровень сложности.


2. Работа с ключом API

Чтобы получить личные данные (баланс, ордера, депозиты и т.д.), нужно взять API-ключ с правами на чтение.
На Bitget это бесплатно и занимает пару секунд.

Подключаемся к бирже с использованием ключа:

const bitget = new ccxt.bitget({
  apiKey: 'ключ',
  secret: 'секретный_ключ',
  password: 'пароль_ключа',
  enableRateLimit: true, // обязательно, чтобы не заблокировали IP
});

Не забываем загрузить торговые пары:

await bitget.loadMarkets();

Теперь можно получать данные:

Баланс

const balance = await bitget.fetchBalance();

{
  "info": [
    {
      "coin": "BGB",
      "available": "0.0022339965635126",
      "limitAvailable": "0",
      "frozen": "0.0000000000000000",
      "locked": "0.0000000000000000",
      "uTime": "1759749333740"
    }
  ],
  "BGB": { "free": 0.0022339965635126, "used": 0, "total": 0.0022339965635126 },
  "free": { "BGB": 0.0022339965635126 },
  "used": { "BGB": 0 },
  "total": { "BGB": 0.0022339965635126 }
}

Открытые ордера

const openOrders = await bitget.fetchOpenOrders();

[
  {
    "id": "7901234567890123456",
    "clientOrderId": "cli-my-order-001",
    "datetime": "2025-10-06T11:25:10.000Z",
    "timestamp": 1730819110000,
    "symbol": "BTC/USDT",
    "type": "limit",
    "side": "buy",
    "price": 60000,
    "amount": 0.01,
    "filled": 0.0,
    "remaining": 0.01,
    "status": "open",
    "timeInForce": "GTC",
    "postOnly": false,
    "cost": 0,
    "average": null,
    "fee": null,
    "trades": null,
    "info": {
      "orderId": "7901234567890123456",
      "symbol": "BTCUSDT",
      "orderType": "limit",
      "side": "buy",
      "price": "60000",
      "quantity": "0.01",
      "status": "open",
      "createTime": "1730819110000"
    }
  }
]

Закрытые ордера

const closedOrders = await bitget.fetchClosedOrders(undefined, undefined, 1);

[
  {
    "info": {
      "userId": "768255",
      "symbol": "BTCUSDT",
      "orderId": "13590053751907",
      "clientOid": "45deee99-b466-ac90-0ecf00000000",
      "price": "0",
      "size": "0.0002000000000000",
      "orderType": "market",
      "side": "sell",
      "status": "filled",
      "priceAvg": "123507.0700000000000000",
      "baseVolume": "0.0002000000000000",
      "quoteVolume": "24.7014140000000000",
      "enterPointSource": "ANDROID",
      "feeDetail": "{\"newFees\":{\"c\":0,\"d\":0,\"deduction\":false,\"r\":-0.024701414,\"t\":-0.024701414,\"totalDeductionFee\":0},\"USDT\":{\"deduction\":false,\"feeCoinCode\":\"USDT\",\"totalDeductionFee\":0,\"totalFee\":-0.0247014140000000}}",
      "orderSource": "market",
      "tpslType": "normal",
      "triggerPrice": null,
      "quoteCoin": "USDT",
      "baseCoin": "BTC",
      "cancelReason": "",
      "cTime": "1759738051221",
      "uTime": "1759738051237"
    },
    "id": "135900539",
    "clientOrderId": "45deee99-b466-ac90-0ecf00000000",
    "timestamp": 1759738051221,
    "datetime": "2025-10-06T08:07:31.221Z",
    "lastTradeTimestamp": 1759738051237,
    "lastUpdateTimestamp": 1759738051237,
    "symbol": "BTC/USDT",
    "type": "market",
    "side": "sell",
    "price": 123507.07,
    "amount": 0.0002,
    "cost": 24.701414,
    "average": 123507.07,
    "filled": 0.0002,
    "remaining": 0,
    "timeInForce": "IOC",
    "postOnly": null,
    "reduceOnly": null,
    "triggerPrice": null,
    "takeProfitPrice": null,
    "stopLossPrice": null,
    "status": "closed",
    "fee": { "cost": 0.024701414, "currency": "USDT" },
    "trades": [],
    "fees": [],
    "stopPrice": null
  }
]

Депозиты

const deposits = await bitget.fetchDeposits(undefined, undefined, 1);

[
  {
    "id": "1302740811658176",
    "info": {
      "orderId": "1347027408158176",
      "tradeId": "0x3fedcf08e6a4b2748ebf66ba0e5cdbd8fcb55a15af35c91757f410abcdef01",
      "coin": "BERA",
      "type": "deposit",
      "size": "2.54786153",
      "status": "success",
      "toAddress": "0x0ac7b517bc07dfa1e8b138cd8eda69f2fabcd1234567890abcdef12345678",
      "dest": "on_chain",
      "chain": "BERA(BERA)",
      "fromAddress": "0x305833cad7febc661943a9ed22abcdef9876543210abcdef9876543210",
      "cTime": "1756882281331",
      "uTime": "1756882383842"
    },
    "txid": "0x3fedcf08e6a4b2748ebf6ba0e5cdbd8fcb55a15af35c91757f410abcdef01",
    "timestamp": 1756882281331,
    "datetime": "2025-09-03T06:51:21.331Z",
    "network": "BERA(BERA)",
    "addressFrom": "0x305833cad7febc6619304a6943a9ed22abc9876543210abcdef9876543210",
    "address": "0x0ac7b517bc07dfa1e8b138cdaf9469f2fabcd1234567890abcdef12345678",
    "addressTo": "0x17bc07dfabcce1e8b138943a1ed22cd8edaf94000000000000000000000000",
    "amount": 2.54786153,
    "type": "deposit",
    "currency": "BERA",
    "status": "ok",
    "updated": 1756882383842,
    "tagFrom": null,
    "tag": null,
    "tagTo": null,
    "comment": null,
    "internal": null,
    "fee": null
  }
]

Выводы

const withdrawals = await bitget.fetchWithdrawals(undefined, undefined, 1);

[
  {
    "id": "13590071708193920",
    "info": {
      "orderId": "3590071708419390",
      "tradeId": "0x34681b8d0b85ec81b5a1e8e812665b71986dfe4d41e10630f568abcdef12",
      "coin": "USDT",
      "type": "withdraw",
      "dest": "on_chain",
      "size": "55",
      "fee": "0",
      "status": "success",
      "toAddress": "0x732307bbdd9cf48822298cb6ff2118fdabcdef1234590abcdef1234567890",
      "chain": "BSC(BEP20)",
      "confirm": "16",
      "clientOid": null,
      "tag": null,
      "fromAddress": "0x864a7fa57ede4892e925f1272ee3faabcdef6543287abcdef6543210987",
      "cTime": "1759738479338",
      "uTime": "1759738672006"
    },
    "txid": "0x34681b8d0b85ec81b5a1e8e812665b719dfe4d2bbe41e10630f568abcdef12",
    "timestamp": 1759738479338,
    "datetime": "2025-10-06T08:14:39.338Z",
    "network": "BSC(BEP20)",
    "addressFrom": "0x864a7fa57ede42e925f1272ee3faabcdef6543210987abcdef6543210987",
    "address": "0x732307bbf388dd9cf48822298cb6ff1abcdef1234567890abcdef12345678",
    "addressTo": "0x732307bbf3588dd9f48898cb6ff2abcdef1234567890abef12345678",
    "amount": 55,
    "type": "withdraw",
    "currency": "USDT",
    "status": "ok",
    "updated": 1759738672006,
    "tagFrom": null,
    "tag": null,
    "tagTo": null,
    "comment": null,
    "internal": null,
    "fee": { "currency": "USDT", "cost": 0 }
  }
]

Примечание:

В запросах по ключу (депозиты, выводы, ордера и т.д.) можно дополнительно указать параметры, конкретизирующие запрос:

.fetchDeposits(symbol, since, limit)

где:

  • symbol - тикер монеты,

  • since - с какой временной точки берем,

  • limit - максимальное количество записей в ответе.

OHLCV (свечные данные)

Интересный кейс использования - можно запросить у биржи данные в формате OHLCV (Open, High, Low, Close, Volume), которые нужны для отрисовки японских свечей. Эти данные доступны без ключа. В примере получены данные за последние 5 минут:

const candles = await bitget.fetchOHLCV(symbol, timeframe, since, limit);

[
  [1759750560000, 124234.62, 124234.63, 124148, 124148, 7.40903996448],
  [1759750620000, 124148, 124200.25, 124096, 124200.25, 18.64864231992],
  [1759750680000, 124200.25, 124232.91, 124126.34, 124154.51, 17.77835288988],
  [1759750740000, 124154.51, 124279.76, 124154.5, 124264.69, 12.1301566641],
  [1759750800000, 124264.69, 124279.06, 124225.77, 124254.43, 6.72337309886]
]

Более удобный вид в таблице:

(index)

time

open

high

low

close

volume

0

'14:36:00'

124234.62

124234.63

124148

124148

7.40903996448

1

'14:37:00'

124148

124200.25

124096

124200.25

18.64864231992

2

'14:38:00'

124200.25

124232.91

124126.34

124154.51

17.77835288988

3

'14:39:00'

124154.51

124279.76

124154.5

124264.69

12.1301566641

4

'14:40:00'

124264.69

124279.06

124225.77

124254.43

6.72337309886


Практическое применение из моего проекта

1. Получение и обновление цены с биржи Bitget и CoinGecko

async updatePrices(data: UpdateUserAssetsPriceDto) {
  const exchange = new ccxt.bitget(); // cex data source
  await exchange.loadMarkets(); // est connection

  const assets = await this.prisma.asset.findMany({
    where: { userId: data.userId },
  });

  const filteredAssets = assets.map(item => ({
    symbol: `${item.symbol}/USDT`,
    geckoId: item.marketId,
    id: item.id,
  }));

  const tickers = await exchange.fetchTickers(); // get all tickers from exchange
  const ccxtPrices = {}; // prices from ccxt
  const missingAssets: string[] = []; // asset for Gecko req

  for (const { symbol, geckoId } of filteredAssets) {
    if (tickers[symbol]) {
      ccxtPrices[geckoId] = tickers[symbol].last; // last price from cex
    } else {
      ccxtPrices[geckoId] = null; // if there no pair on cex
      missingAssets.push(geckoId);
    }
  }

  let geckoData: Record<string, { usd: number }> = {};

  if (missingAssets.length > 0) {
    missingAssets.push('tether');
    const url = `https://api.coingecko.com/api/v3/simple/price?ids=${missingAssets.join(',')}&vs_currencies=usd`;
    const res = await fetch(url);
    geckoData = await res.json();

    const tetherUsd = geckoData.tether?.usd ?? 1;

    for (const [key, value] of Object.entries(geckoData)) {
      geckoData[key].usd = value.usd / tetherUsd;
    } // convert usd prices to usdt
  }// get data from gecko

  const result = filteredAssets.map(asset => {
    let price = ccxtPrices[asset.geckoId];
    if (price == null) {
      price = geckoData[asset.geckoId]?.usd ?? null;
    }
    return { ...asset, price };
  }); // create final data array

  const batchSize = 50; // optimal for sqlite
  for (let i = 0; i < result.length; i += batchSize) {
    const batch = result
      .slice(i, i + batchSize)
      .filter(asset => asset.price != null)
      .map(asset =>
        this.prisma.asset.update({
          where: { id: asset.id },
          data: { price: asset.price },
        }),
      );

    if (batch.length > 0) {
      await this.prisma.$transaction(batch);
    }
  } // batch price updater
}

В этом коде я беру из БД ассеты по юзеру, фильтрую их и модифицирую для запроса к бирже.
Получая ответ, я отдельно собираю список монет, которых нет на бирже, и запрашиваю эти данные у CoinGecko.
Важный момент - биржа отдаёт данные торговых пар в USDT, а CoinGecko - в USD, поэтому нужно конвертировать одно в другое.
Я решил конвертировать всё в USDT.
После этого собираю итоговые данные в один массив и обновляю их в БД.

2. Получение данных для отрисовки графиков

async getCandleData() {
  const bitget = new ccxt.bitget({
    apiKey: process.env.BG_API_ACCESS,
    secret: process.env.BG_API_SECRET_KEY,
    password: process.env.BG_API_PASSWORD,
    enableRateLimit: true,
  });

  await bitget.loadMarkets();

  const symbol = 'BTC/USDT';
  const timeframe = '1m'; // 1 candle = 1 min
  const limit = 5; // last 5 candles
  const since = Date.now() - 5 * 60 * 1000; // last 5 min

  const candles = await bitget.fetchOHLCV(symbol, timeframe, since, limit) as Array<[number, number, number, number, number, number]>;

  console.log('--- BTC/USDT 1m candles ---');
  console.table(
    candles.map(([time, open, high, low, close, volume]) => ({
      time: new Date(time).toLocaleTimeString(),
      open,
      high,
      low,
      close,
      volume,
    })),
  );
}

Здесь я запрашиваю с биржи данные за последние 5 минут.
К сожалению, CoinGecko не даёт подобных данных бесплатно.
В дальнейшем я планирую использовать эти данные в своём приложении, хранить их в DuckDB, добавить туда взаимодействие с остальными данными и аналитику.


Резюме

  • CCXT - отличная библиотека для сбора данных по рынку.

  • Позволяет удобно работать с несколькими биржами одновременно.

  • Прекрасно подходит для pet-проектов и персональных аналитических инструментов.

Возможно, этой статьёй я ничего нового и не открыл - всё это есть в официальной документации или доступно через нейронку, но для меня существование этой библиотеки стало приятным открытием уже в конце разработки MVP.

Исходный код можно посмотреть в моём репозитории: Гитхаб

И, конечно, не стоит верить мне на слово - ознакомьтесь с официальной документацией: Документация

Надеюсь, эта статья будет кому-то полезна.
В будущем я планирую написать ещё несколько статей о работе с DuckDB, обновлении данных, заметки в .md и других фичах, которые сейчас в разработке.

Комментарии (0)