Merge pull request #78 from blockworks-foundation/pan/candles
Add basic perp ohlcv feed
This commit is contained in:
commit
d0e937b559
|
@ -157,6 +157,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,
|
||||
|
|
|
@ -0,0 +1,246 @@
|
|||
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<Bar[]> => {
|
||||
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,
|
||||
has_empty_bars: true,
|
||||
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()
|
||||
},
|
||||
}
|
|
@ -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<string, string> = {
|
||||
'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
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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']
|
||||
|
@ -93,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
|
||||
|
@ -116,7 +123,7 @@ const TradingViewChart = () => {
|
|||
console.warn('Trading View change symbol error: ', e)
|
||||
}
|
||||
}
|
||||
}, [selectedMarketName, chartReady])
|
||||
}, [selectedMarket, chartReady])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
@ -134,22 +141,12 @@ 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',
|
||||
symbol: selectedMarket,
|
||||
// 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:
|
||||
|
|
Loading…
Reference in New Issue