From 541a0114c780cfa9991452caadbeeeaa06db9ebb Mon Sep 17 00:00:00 2001 From: Riordan Panayides Date: Wed, 1 Feb 2023 20:22:09 +0000 Subject: [PATCH 1/3] Add basic perp ohlcv feed --- apis/mngo/datafeed.ts | 245 ++++++++++++++++++++++++++ apis/mngo/helpers.ts | 55 ++++++ apis/mngo/streaming.ts | 80 +++++++++ components/trade/TradingViewChart.tsx | 14 +- 4 files changed, 384 insertions(+), 10 deletions(-) create mode 100644 apis/mngo/datafeed.ts create mode 100644 apis/mngo/helpers.ts create mode 100644 apis/mngo/streaming.ts diff --git a/apis/mngo/datafeed.ts b/apis/mngo/datafeed.ts new file mode 100644 index 00000000..3db70a3e --- /dev/null +++ b/apis/mngo/datafeed.ts @@ -0,0 +1,245 @@ +import { makeApiRequest, parseResolution } from './helpers' +import { subscribeOnStream, unsubscribeFromStream } from './streaming' +import mangoStore from '@store/mangoStore' +import { + DatafeedConfiguration, + LibrarySymbolInfo, + ResolutionString, + SearchSymbolResultItem, +} from '@public/charting_library' + +export const SUPPORTED_RESOLUTIONS = [ + '1', + '3', + '5', + '15', + '30', + '60', + '120', + '240', + '1D', +] as const + +type BaseBar = { + low: number + high: number + open: number + close: number +} + +type KlineBar = BaseBar & { + volume: number + timestamp: number +} + +type TradingViewBar = BaseBar & { + time: number +} + +type Bar = KlineBar & TradingViewBar + +type SymbolInfo = LibrarySymbolInfo & { + address: string +} + +const lastBarsCache = new Map() + +const configurationData = { + supported_resolutions: SUPPORTED_RESOLUTIONS, + intraday_multipliers: [ + '1', + '3', + '5', + '15', + '30', + '45', + '60', + '120', + '240', + '1440', + ], + exchanges: [], +} + +// async function getAllSymbols() { +// const data = await makeApiRequest( +// 'public/tokenlist?sort_by=v24hUSD&sort_type=desc&offset=0&limit=-1' +// ) + +// return data.data.tokens +// } + +export const queryBars = async ( + tokenAddress: string, + resolution: typeof SUPPORTED_RESOLUTIONS[number], + periodParams: { + firstDataRequest: boolean + from: number + to: number + } +): Promise => { + const { from, to } = periodParams + const urlParameters = { + 'perp-market': tokenAddress, + resolution: parseResolution(resolution), + start_datetime: new Date(from * 1000).toISOString(), + end_datetime: new Date(to * 1000).toISOString(), + } + const query = Object.keys(urlParameters) + .map((name: string) => `${name}=${(urlParameters as any)[name]}`) + .join('&') + + const data = await makeApiRequest(`/stats/candles-perp?${query}`) + if (!data || !data.length) { + return [] + } + let bars: Bar[] = [] + for (const bar of data) { + const timestamp = new Date(bar.candle_start).getTime() + if (timestamp >= from * 1000 && timestamp < to * 1000) { + bars = [ + ...bars, + { + time: timestamp, + low: bar.low, + high: bar.high, + open: bar.open, + close: bar.close, + volume: bar.volume, + timestamp, + }, + ] + } + } + return bars +} + +export default { + onReady: (callback: (configuration: DatafeedConfiguration) => void) => { + setTimeout(() => callback(configurationData as any)) + }, + + searchSymbols: async ( + _userInput: string, + _exchange: string, + _symbolType: string, + _onResultReadyCallback: (items: SearchSymbolResultItem[]) => void + ) => { + return + }, + + resolveSymbol: async ( + symbolAddress: string, + onSymbolResolvedCallback: (symbolInfo: SymbolInfo) => void + // _onResolveErrorCallback: any, + // _extension: any + ) => { + let symbolItem: + | { + address: string + type: string + symbol: string + } + | undefined + + if (!symbolItem) { + symbolItem = { + address: symbolAddress, + type: 'pair', + symbol: '', + } + } + const ticker = mangoStore.getState().selectedMarket.name + + const symbolInfo: SymbolInfo = { + address: symbolItem.address, + ticker: symbolItem.address, + name: symbolItem.symbol || symbolItem.address, + description: ticker || symbolItem.address, + type: symbolItem.type, + session: '24x7', + timezone: 'Etc/UTC', + minmov: 1, + pricescale: 100, + has_intraday: true, + has_weekly_and_monthly: false, + supported_resolutions: configurationData.supported_resolutions as any, + intraday_multipliers: configurationData.intraday_multipliers, + volume_precision: 2, + data_status: 'streaming', + full_name: '', + exchange: '', + listed_exchange: '', + format: 'price', + } + + onSymbolResolvedCallback(symbolInfo) + }, + getBars: async ( + symbolInfo: SymbolInfo, + resolution: ResolutionString, + periodParams: { + countBack: number + firstDataRequest: boolean + from: number + to: number + }, + onHistoryCallback: ( + bars: Bar[], + t: { + noData: boolean + } + ) => void, + onErrorCallback: (e: any) => void + ) => { + try { + const { firstDataRequest } = periodParams + const bars = await queryBars( + symbolInfo.address, + resolution as any, + periodParams + ) + if (!bars || bars.length === 0) { + // "noData" should be set if there is no data in the requested period. + onHistoryCallback([], { + noData: true, + }) + return + } + + if (firstDataRequest) { + lastBarsCache.set(symbolInfo.address, { + ...bars[bars.length - 1], + }) + } + onHistoryCallback(bars, { + noData: false, + }) + } catch (error) { + console.warn('[getBars]: Get error', error) + onErrorCallback(error) + } + }, + + subscribeBars: ( + symbolInfo: SymbolInfo, + resolution: string, + onRealtimeCallback: (data: any) => void, + subscriberUID: string, + onResetCacheNeededCallback: () => void + ) => { + subscribeOnStream( + symbolInfo, + resolution, + onRealtimeCallback, + subscriberUID, + onResetCacheNeededCallback, + lastBarsCache.get(symbolInfo.address) + ) + }, + + unsubscribeBars: () => { + console.warn('[unsubscribeBars]') + unsubscribeFromStream() + }, +} diff --git a/apis/mngo/helpers.ts b/apis/mngo/helpers.ts new file mode 100644 index 00000000..f0937786 --- /dev/null +++ b/apis/mngo/helpers.ts @@ -0,0 +1,55 @@ +import { MANGO_DATA_API_URL } from 'utils/constants' + +// Make requests to mngo.cloud API +export async function makeApiRequest(path: string) { + try { + const response = await fetch(`${MANGO_DATA_API_URL}${path}`) + return response.json() + } catch (error: any) { + throw new Error(`mngo.cloud request error: ${error.status}`) + } +} + +const RESOLUTION_MAPPING: Record = { + '1': '1', + '3': '3', + '5': '5', + '15': '15', + '30': '30', + '45': '45', + '60': '60', + '120': '120', + '240': '240', + '1D': '1440', + '1W': '10080', +} + +export function parseResolution(resolution: string) { + if (!resolution || !RESOLUTION_MAPPING[resolution]) + return RESOLUTION_MAPPING[0] + + return RESOLUTION_MAPPING[resolution] +} + +export function getNextBarTime(lastBar: any, resolution = '1D') { + if (!lastBar) return + + const lastCharacter = resolution.slice(-1) + let nextBarTime + + switch (true) { + case lastCharacter === 'W': + nextBarTime = 7 * 24 * 60 * 60 * 1000 + lastBar.time + break + + case lastCharacter === 'D': + nextBarTime = 1 * 24 * 60 * 60 * 1000 + lastBar.time + break + + default: + nextBarTime = 1 * 60 * 1000 + lastBar.time + break + } + + return nextBarTime +} diff --git a/apis/mngo/streaming.ts b/apis/mngo/streaming.ts new file mode 100644 index 00000000..3778cc3a --- /dev/null +++ b/apis/mngo/streaming.ts @@ -0,0 +1,80 @@ +import { parseResolution, getNextBarTime } from './helpers' + +let subscriptionItem: any = {} + +// Create WebSocket connection. +const socket = new WebSocket(`wss://api.mngo.cloud/fills/v1/`) + +// Connection opened +socket.addEventListener('open', (_event) => { + console.log('[socket] Connected') +}) + +// Listen for messages +socket.addEventListener('message', (msg) => { + const data = JSON.parse(msg.data) + + if (!data.event) return console.warn(data) + if (data.event.maker) return + + const currTime = new Date(data.event.timestamp).getTime() + const lastBar = subscriptionItem.lastBar + const resolution = subscriptionItem.resolution + const nextBarTime = getNextBarTime(lastBar, resolution) + const price = data.event.price + const size = data.event.quantity + let bar + + if (currTime >= nextBarTime) { + bar = { + time: nextBarTime, + open: price, + high: price, + low: price, + close: price, + volume: size, + } + } else { + bar = { + ...lastBar, + high: Math.max(lastBar.high, price), + low: Math.min(lastBar.low, price), + close: price, + volume: lastBar.volume + size, + } + } + + subscriptionItem.lastBar = bar + subscriptionItem.callback(bar) +}) + +export function subscribeOnStream( + symbolInfo: any, + resolution: any, + onRealtimeCallback: any, + subscriberUID: any, + onResetCacheNeededCallback: any, + lastBar: any +) { + subscriptionItem = { + resolution, + lastBar, + callback: onRealtimeCallback, + } + + const msg = { + command: 'subscribe', + marketId: 'HwhVGkfsSQ9JSQeQYu2CbkRCLvsh3qRZxG6m4oMVwZpN', + } + + socket.send(JSON.stringify(msg)) +} + +export function unsubscribeFromStream() { + const msg = { + command: 'unsubscribe', + marketId: 'HwhVGkfsSQ9JSQeQYu2CbkRCLvsh3qRZxG6m4oMVwZpN', + } + + socket.send(JSON.stringify(msg)) +} diff --git a/components/trade/TradingViewChart.tsx b/components/trade/TradingViewChart.tsx index fea260c3..e9120911 100644 --- a/components/trade/TradingViewChart.tsx +++ b/components/trade/TradingViewChart.tsx @@ -11,7 +11,8 @@ import { useViewport } from 'hooks/useViewport' import { CHART_DATA_FEED, DEFAULT_MARKET_NAME } from 'utils/constants' import { breakpoints } from 'utils/theme' import { COLORS } from 'styles/colors' -import Datafeed from 'apis/birdeye/datafeed' +import SpotDatafeed from 'apis/birdeye/datafeed' +import PerpDatafeed from 'apis/mngo/datafeed' export interface ChartContainerProps { container: ChartingLibraryWidgetOptions['container'] @@ -134,22 +135,15 @@ const TradingViewChart = () => { useEffect(() => { if (window) { - // const tempBtcDatafeedUrl = 'https://dex-pyth-price-mainnet.zeta.markets/tv/history?symbol=BTC-USDC&resolution=5&from=1674427748&to=1674430748&countback=2' - const tempBtcDatafeedUrl = - 'https://redirect-origin.mangomarkets.workers.dev' - const btcDatafeed = new (window as any).Datafeeds.UDFCompatibleDatafeed( - tempBtcDatafeedUrl - ) - const widgetOptions: ChartingLibraryWidgetOptions = { // debug: true, symbol: spotOrPerp === 'spot' ? '8BnEgHoWFysVcuFFX7QztDmzuH8r5ZFvyP3sYwn1XTh6' - : 'BTC-USDC', + : 'HwhVGkfsSQ9JSQeQYu2CbkRCLvsh3qRZxG6m4oMVwZpN', // BEWARE: no trailing slash is expected in feed URL // tslint:disable-next-line:no-any - datafeed: spotOrPerp === 'spot' ? Datafeed : btcDatafeed, + datafeed: spotOrPerp === 'spot' ? SpotDatafeed : PerpDatafeed, interval: defaultProps.interval as ChartingLibraryWidgetOptions['interval'], container: From 7126f00c445e413717c46845178c87faa96497b6 Mon Sep 17 00:00:00 2001 From: tjs Date: Wed, 1 Feb 2023 18:05:01 -0500 Subject: [PATCH 2/3] add a memo for selected market --- components/trade/TradingViewChart.tsx | 33 +++++++++++++++------------ 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/components/trade/TradingViewChart.tsx b/components/trade/TradingViewChart.tsx index e9120911..00edf00f 100644 --- a/components/trade/TradingViewChart.tsx +++ b/components/trade/TradingViewChart.tsx @@ -94,20 +94,26 @@ const TradingViewChart = () => { } }) + const selectedMarket = useMemo(() => { + const group = mangoStore.getState().group + if (!group || !selectedMarketName) + return '8BnEgHoWFysVcuFFX7QztDmzuH8r5ZFvyP3sYwn1XTh6' + + if (!selectedMarketName.toLowerCase().includes('perp')) { + return group + .getSerum3MarketByName(selectedMarketName) + .serumMarketExternal.toString() + } else { + return group.getPerpMarketByName(selectedMarketName).publicKey.toString() + } + }, [selectedMarketName]) + useEffect(() => { const group = mangoStore.getState().group - if (tvWidgetRef.current && chartReady && selectedMarketName && group) { + if (tvWidgetRef.current && chartReady && selectedMarket && group) { try { - let symbolName - if (!selectedMarketName.toLowerCase().includes('PERP')) { - symbolName = group - .getSerum3MarketByName(selectedMarketName) - .serumMarketExternal.toString() - } else { - symbolName = selectedMarketName - } tvWidgetRef.current.setSymbol( - symbolName, + selectedMarket, tvWidgetRef.current.activeChart().resolution(), () => { return @@ -117,7 +123,7 @@ const TradingViewChart = () => { console.warn('Trading View change symbol error: ', e) } } - }, [selectedMarketName, chartReady]) + }, [selectedMarket, chartReady]) useEffect(() => { if ( @@ -137,10 +143,7 @@ const TradingViewChart = () => { if (window) { const widgetOptions: ChartingLibraryWidgetOptions = { // debug: true, - symbol: - spotOrPerp === 'spot' - ? '8BnEgHoWFysVcuFFX7QztDmzuH8r5ZFvyP3sYwn1XTh6' - : 'HwhVGkfsSQ9JSQeQYu2CbkRCLvsh3qRZxG6m4oMVwZpN', + symbol: selectedMarket, // BEWARE: no trailing slash is expected in feed URL // tslint:disable-next-line:no-any datafeed: spotOrPerp === 'spot' ? SpotDatafeed : PerpDatafeed, From 30ca20aa90f6b657ca81b9244deeea5e507d0387 Mon Sep 17 00:00:00 2001 From: tjs Date: Wed, 1 Feb 2023 21:56:07 -0500 Subject: [PATCH 3/3] show empty candles if no data --- apis/birdeye/datafeed.ts | 1 + apis/mngo/datafeed.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apis/birdeye/datafeed.ts b/apis/birdeye/datafeed.ts index 7d7ae735..37892a03 100644 --- a/apis/birdeye/datafeed.ts +++ b/apis/birdeye/datafeed.ts @@ -156,6 +156,7 @@ export default { pricescale: 100, has_intraday: true, has_weekly_and_monthly: false, + has_empty_bars: true, supported_resolutions: configurationData.supported_resolutions as any, intraday_multipliers: configurationData.intraday_multipliers, volume_precision: 2, diff --git a/apis/mngo/datafeed.ts b/apis/mngo/datafeed.ts index 3db70a3e..5f5527f5 100644 --- a/apis/mngo/datafeed.ts +++ b/apis/mngo/datafeed.ts @@ -163,6 +163,7 @@ export default { pricescale: 100, has_intraday: true, has_weekly_and_monthly: false, + has_empty_bars: true, supported_resolutions: configurationData.supported_resolutions as any, intraday_multipliers: configurationData.intraday_multipliers, volume_precision: 2,