Привет Хабр!
Я сейчас пишу локальное приложение на 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 и других фичах, которые сейчас в разработке.
