@drift-labs/common
Version:
Common functions for Drift
364 lines • 18.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CandleClient = void 0;
const sdk_1 = require("@drift-labs/sdk");
const Candle_1 = require("../utils/candles/Candle");
const StrictEventEmitter_1 = require("../utils/StrictEventEmitter");
const EnvironmentConstants_1 = require("../EnvironmentConstants");
const assert_1 = require("../utils/assert");
const marketDataFeed_1 = require("./marketDataFeed");
const getMarketSymbolForMarketId = (marketId, uiEnv) => {
const isPerp = marketId.isPerp;
const sdkEnv = uiEnv.sdkEnv;
if (isPerp) {
const marketConfigs = sdkEnv === 'mainnet-beta' ? sdk_1.MainnetPerpMarkets : sdk_1.DevnetPerpMarkets;
const targetMarketConfig = marketConfigs.find((config) => config.marketIndex === marketId.marketIndex);
return targetMarketConfig.symbol;
}
else {
const marketConfigs = sdkEnv === 'mainnet-beta' ? sdk_1.MainnetSpotMarkets : sdk_1.DevnetSpotMarkets;
const targetMarketConfig = marketConfigs.find((config) => config.marketIndex === marketId.marketIndex);
return targetMarketConfig.symbol;
}
};
// This is the maximum number of candles that can be fetched in a single GET request
const CANDLE_FETCH_LIMIT = 1000;
const getBaseDataApiUrl = (env) => {
const constantEnv = env.isStaging ? 'staging' : env.isDevnet ? 'dev' : 'mainnet';
const dataApiUrl = EnvironmentConstants_1.EnvironmentConstants.dataServerUrl[constantEnv];
return dataApiUrl.replace('https://', '');
};
const getCandleFetchUrl = ({ env, marketId, resolution, startTs, countToFetch, }) => {
const baseDataApiUrl = getBaseDataApiUrl(env);
// Base URL without startTs parameter
let fetchUrl = `https://${baseDataApiUrl}/market/${getMarketSymbolForMarketId(marketId, env)}/candles/${resolution}?limit=${Math.min(countToFetch, CANDLE_FETCH_LIMIT)}`;
// Only add startTs parameter if it's provided
if (startTs !== undefined) {
fetchUrl += `&startTs=${startTs}`;
}
return fetchUrl;
};
// Separate event bus for candle events
class CandleEventBus extends StrictEventEmitter_1.StrictEventEmitter {
constructor() {
super();
}
}
// This class is reponsible for fetching candles from the data API's GET endpoint
class CandleFetcher {
// Helper method to generate a cache key from market ID and resolution
static getCacheKey(marketId, resolution) {
return `${marketId.key}-${resolution}`;
}
// Public method to clear the entire cache
static clearWholeCache() {
CandleFetcher.recentCandlesCache.clear();
}
static clearCacheForSubscription(marketId, resolution) {
CandleFetcher.recentCandlesCache.delete(CandleFetcher.getCacheKey(marketId, resolution));
}
constructor(config) {
/**
* Candles are fetched in ascending order of time (index 0 -> oldest to index n -> newest)
*/
this.fetchCandlesFromApi = async (fetchUrl) => {
const response = await fetch(fetchUrl);
const parsedResponse = (await response.json());
if (!parsedResponse.success) {
throw new Error('Failed to fetch candles from data API');
}
return parsedResponse.records;
};
this.getCountOfCandlesBetweenStartAndEndTs = (startTs, endTs) => {
const diffInSeconds = endTs - startTs;
const resolutionInSeconds = Candle_1.Candle.resolutionStringToCandleLengthMs(this.config.resolution) / 1000;
const diffInCandles = diffInSeconds / resolutionInSeconds;
return Math.ceil(diffInCandles);
};
/**
* Try to get candles from the cache if they're available.
* Returns null if no cached candles are available for the requested range.
*/
this.getFromCache = () => {
// Generate cache key for the current request
const cacheKey = CandleFetcher.getCacheKey(this.config.marketId, this.config.resolution);
const cachedCandles = CandleFetcher.recentCandlesCache.get(cacheKey);
// Check if we have cached candles for this market and resolution
if (cachedCandles) {
// Check if the requested time range is within the bounds of cached candles
if (this.config.fromTs >= cachedCandles.earliestTs &&
this.config.toTs <= cachedCandles.latestTs) {
// Filter cached candles to the requested time range
const filteredCandles = cachedCandles.candles.filter((candle) => candle.ts >= this.config.fromTs && candle.ts <= this.config.toTs);
return filteredCandles;
}
}
return null;
};
/**
* Determines if we should use the recent candles approach (without startTs for better caching).
*/
this.isRequestingRecentCandles = (nowSeconds, candleLengthSeconds) => {
// Calculate cutoff time for "recent" candles (now - 1000 candles worth of time)
const recentCandlesCutoffTs = nowSeconds - candleLengthSeconds * CANDLE_FETCH_LIMIT;
// Check if we're fetching recent candles based on the fromTs
return this.config.fromTs >= recentCandlesCutoffTs;
};
/**
* Fetch recent candles without using startTs for better caching.
*/
this.fetchRecentCandles = async (nowSeconds) => {
// Fetch recent candles without specifying startTs
const fetchUrl = getCandleFetchUrl({
env: this.config.env,
marketId: this.config.marketId,
resolution: this.config.resolution,
countToFetch: CANDLE_FETCH_LIMIT, // Ask for max candles to ensure we get enough
});
// Get the candles and reverse them (into ascending order)
const fetchedCandles = await this.fetchCandlesFromApi(fetchUrl);
fetchedCandles.reverse();
if (fetchedCandles.length === 0) {
return [];
}
// Store the full fetchedCandles in cache before filtering
this.updateCandleCache(fetchedCandles, nowSeconds);
// Filter to only include candles in the requested time range
const filteredCandles = this.filterCandlesByTimeRange(fetchedCandles);
return filteredCandles;
};
/**
* Filter candles to only include those in the requested time range.
*/
this.filterCandlesByTimeRange = (candles) => {
return candles.filter((candle) => candle.ts >= this.config.fromTs && candle.ts <= this.config.toTs);
};
/**
* Update the candle cache with the latest fetched candles.
*/
this.updateCandleCache = (fetchedCandles, nowSeconds) => {
if (fetchedCandles.length > 0) {
// Generate cache key
const cacheKey = CandleFetcher.getCacheKey(this.config.marketId, this.config.resolution);
// Sort candles by timestamp to find earliest and latest
const sortedCandles = [...fetchedCandles].sort((a, b) => a.ts - b.ts);
const earliestTs = sortedCandles[0].ts;
const latestTs = sortedCandles[sortedCandles.length - 1].ts;
// Update or add to cache
CandleFetcher.recentCandlesCache.set(cacheKey, {
candles: sortedCandles,
earliestTs,
latestTs,
fetchTime: nowSeconds,
});
}
};
/**
* Fetch historical candles with pagination using startTs.
*/
this.fetchHistoricalCandles = async () => {
let candlesRemainingToFetch = this.getCountOfCandlesBetweenStartAndEndTs(this.config.fromTs, this.config.toTs);
let currentStartTs = this.config.toTs; // The data API takes "startTs" as the "first timestamp you want going backwards in time" e.g. all candles will be returned with descending time backwards from the startTs
let hitEndTsCutoff = false;
let candles = [];
while (candlesRemainingToFetch > 0) {
const result = await this.fetchHistoricalCandlesBatch(candlesRemainingToFetch, currentStartTs);
if (result.fetchedCandles.length === 0) {
candlesRemainingToFetch = 0;
break;
}
// the deeper the loop, the older the result.candlesToAdd will be
candles = [...result.candlesToAdd, ...candles];
candlesRemainingToFetch -= result.candlesToAdd.length;
hitEndTsCutoff = result.hitEndTsCutoff;
if (result.requiresAnotherFetch) {
currentStartTs = result.nextStartTs;
}
else if (candlesRemainingToFetch > 0) {
// This means we have fetched all the candles available for this time range and we can stop fetching
candlesRemainingToFetch = 0;
}
}
if (hitEndTsCutoff) {
return this.filterCandlesByTimeRange(candles);
}
return candles;
};
/**
* Fetch historical candles with pagination, backwards from the toTs value given in the config.
*
* This method works by looping backwards from the LATEST (toTs) timestamp to the OLDEST (fromTs) timestamp.
*
* Things to note:
* - There is a limit to how many candles can be fetched in a single request (see CANDLE_FETCH_LIMIT)
* - We have implemented this to minimise the cardinality in the API request because that helps with caching
*/
this.fetchHistoricalCandlesBatch = async (candlesRemainingToFetch, currentStartTs) => {
const candlesToFetch = Math.min(candlesRemainingToFetch, CANDLE_FETCH_LIMIT);
const fetchUrl = getCandleFetchUrl({
env: this.config.env,
marketId: this.config.marketId,
resolution: this.config.resolution,
startTs: currentStartTs, // Include startTs for historical candles
countToFetch: candlesToFetch,
});
const fetchedCandles = await this.fetchCandlesFromApi(fetchUrl);
// Reverse candles into ascending order
fetchedCandles.reverse();
if (fetchedCandles.length === 0) {
return {
fetchedCandles,
candlesToAdd: [],
hitEndTsCutoff: false,
requiresAnotherFetch: false,
nextStartTs: currentStartTs,
};
}
const lastCandle = fetchedCandles[fetchedCandles.length - 1]; // This is the LATEST candle .. (they are sorted ascending by time right now)
const hitPageSizeCutoff = fetchedCandles.length === CANDLE_FETCH_LIMIT;
const hitEndTsCutoff = lastCandle.ts < this.config.fromTs;
const requiresAnotherFetch = hitPageSizeCutoff && !hitEndTsCutoff; // If the number of candles returned is equal to the maximum number of candles that can be fetched in a single GET request, then we need to fetch more candles
let candlesToAdd = fetchedCandles;
let nextStartTs = currentStartTs;
if (requiresAnotherFetch) {
// If we need to do another fetch, trim any candles with the same timestamp as the last candle in the previous fetch, because that is the pointer for our next fetch and we don't want to duplicate candles
candlesToAdd = candlesToAdd.filter((candle) => {
return candle.ts < lastCandle.ts;
});
const oldestCandle = fetchedCandles[0]; // first candle is the oldest
nextStartTs = oldestCandle.ts; // If we are doing another loop, then the trimmed candles have all the candles except for ones with the last candle's timestamp. For the next loop we want to fetch from that timestamp;
}
return {
fetchedCandles,
candlesToAdd,
hitEndTsCutoff,
requiresAnotherFetch,
nextStartTs,
};
};
/**
* This class needs to fetch candles based on the config.
*
* If the number of candles requested exceeds the maximum number of candles that can be fetched in a single GET request, then it needs to loop multiple get requests, using the last candle's timestamp as the offset startTs for each subsequent request. If the number of candles returned is less than the requested number of candles, then we have fetched all the candles available.
*
* For recent candles (ones where fromTs > now - candleLength*1000), we avoid using startTs in the URL to improve caching,
* and instead fetch the most recent 1000 candles and then trim the result.
*/
this.fetchCandles = async () => {
// Check cache first
const cachedCandles = this.getFromCache();
if (cachedCandles) {
return cachedCandles;
}
// Calculate the candle length in seconds for the current resolution
const candleLengthMs = Candle_1.Candle.resolutionStringToCandleLengthMs(this.config.resolution);
const candleLengthSeconds = candleLengthMs / 1000;
// Get current time in seconds
const nowSeconds = Math.floor(Date.now() / 1000);
// Check if we're fetching recent candles
if (this.isRequestingRecentCandles(nowSeconds, candleLengthSeconds)) {
return this.fetchRecentCandles(nowSeconds);
}
// For historical candles (older than the last 1000 candles), use the previous approach
// with startTs for pagination
return this.fetchHistoricalCandles();
};
this.config = config;
}
}
// Cache for storing recent candles by market ID and resolution
CandleFetcher.recentCandlesCache = new Map();
class CandleSubscriber {
constructor(config, eventBus) {
this.config = config;
this.eventBus = eventBus;
this.subscribeToCandles = async () => {
this.subscription = marketDataFeed_1.MarketDataFeed.subscribe({
type: 'candles',
resolution: this.config.resolution,
env: this.config.env,
marketSymbol: getMarketSymbolForMarketId(this.config.marketId, this.config.env),
});
this.subscription.observable.subscribe((candle) => {
this.eventBus.emit('candle-update', candle);
});
};
this.unsubscribe = () => {
marketDataFeed_1.MarketDataFeed.unsubscribe(this.subscription.id);
};
}
}
/**
* This class will subscribe to candles from the Drift Data API.
*
* Note: If you are using TradingView you probably want to just use the DriftTvFeed class instead.
*/
class CandleClient {
constructor() {
this.activeSubscriptions = new Map();
this.subscribe = async (config, subscriptionKey) => {
// Kill any existing subscription with the same key before creating a new one
if (this.activeSubscriptions.has(subscriptionKey)) {
this.unsubscribe(subscriptionKey);
}
const eventBus = new CandleEventBus();
const subscriber = new CandleSubscriber(config, eventBus);
await subscriber.subscribeToCandles();
this.activeSubscriptions.set(subscriptionKey, {
subscriber,
eventBus,
});
return;
};
/**
*
* @param config {
*
* env: UIEnv;
*
* marketId: MarketId;
*
* resolution: CandleResolution;
*
* fromTs: number; // Seconds :: This should be the START (oldest) timestamp of the candles to fetch
*
* toTs: number; // Seconds :: This should be the END (newest) timestamp of the candles to fetch
*
* }
* @returns
*/
this.fetch = async (config) => {
(0, assert_1.assert)(config.fromTs < config.toTs, 'fromTs must be less than toTs');
const nowSeconds = Math.floor(Date.now() / 1000);
(0, assert_1.assert)(config.fromTs <= nowSeconds && config.toTs <= nowSeconds, `fromTs and toTs cannot be in the future (Requested fromTs: ${new Date(config.fromTs * 1000).toISOString()} and toTs: ${new Date(config.toTs * 1000).toISOString()}, Current time: ${new Date(nowSeconds * 1000).toISOString()})`);
const candleFetcher = new CandleFetcher(config);
const candles = await candleFetcher.fetchCandles();
return candles;
};
this.unsubscribe = (subscriptionKey) => {
const subscription = this.activeSubscriptions.get(subscriptionKey);
if (subscription) {
CandleFetcher.clearCacheForSubscription(subscription.subscriber.config.marketId, subscription.subscriber.config.resolution);
subscription.subscriber.unsubscribe();
subscription.eventBus.removeAllListeners();
this.activeSubscriptions.delete(subscriptionKey);
}
};
this.unsubscribeAll = () => {
for (const subscriptionKey of this.activeSubscriptions.keys()) {
this.unsubscribe(subscriptionKey);
}
};
}
on(subscriptionKey, event, listener) {
const subscription = this.activeSubscriptions.get(subscriptionKey);
if (subscription) {
subscription.eventBus.on(event, listener);
}
else {
console.warn(`No active subscription found for key: ${subscriptionKey}`);
}
}
}
exports.CandleClient = CandleClient;
//# sourceMappingURL=candleClient.js.map