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