Websocket implementation (#726)

* feat: first steps

* feat: added websocket support and set the TradingChart to USD

* fix: oracle staleness button
This commit is contained in:
Linkie Link 2024-01-12 09:07:55 +01:00 committed by GitHub
parent ebe05b12fd
commit b57ae05db1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 445 additions and 720 deletions

View File

@ -40,6 +40,16 @@
cursor: pointer;
}
.layout__area--center {
background: var(--tv-background) !important;
}
.chart-widget.chart-widget--themed-dark.chart-widget__top--themed-dark.chart-widget__bottom--themed-dark
> table
canvas {
background: transparent !important;
}
/* Floating menu */
.floating-toolbar-react-widgets__button:hover,
[class^='button-']:hover:before {

View File

@ -27,7 +27,7 @@ export default async function getPrices(chainConfig: ChainConfig): Promise<BNCoi
const oraclePrices = await getOraclePrices(chainConfig, assetsWithOraclePrices)
const poolPrices = await requestPoolPrices(chainConfig, assetsWithPoolIds, pythAndOraclePrices)
useStore.setState({ isOracleStale: false })
if (oraclePrices) useStore.setState({ isOracleStale: false })
return [...pythAndOraclePrices, ...oraclePrices, ...poolPrices, usdPrice]
} catch (ex) {

View File

@ -1,5 +1,6 @@
import classNames from 'classnames'
import { isDesktop } from 'react-device-detect'
import { useMemo } from 'react'
import AccountMenu from 'components/Account/AccountMenu'
import EscButton from 'components/Button/EscButton'
@ -54,6 +55,8 @@ export default function DesktopHeader() {
useStore.setState({ focusComponent: null })
}
const showStaleOracle = useMemo(() => isOracleStale && address, [isOracleStale, address])
if (!isDesktop) return null
return (
@ -84,7 +87,7 @@ export default function DesktopHeader() {
</div>
) : (
<div className='flex gap-4'>
{address && isOracleStale && <OracleResyncButton />}
{showStaleOracle && <OracleResyncButton />}
{accountId && <RewardsCenter />}
{address && !isHLS && <AccountMenu />}
<Wallet />

View File

@ -1,508 +1,142 @@
import { defaultSymbolInfo } from 'components/Trade/TradeChart/constants'
import { MILLISECONDS_PER_MINUTE } from 'constants/math'
import { subscribeOnStream, unsubscribeFromStream } from 'components/Trade/TradeChart/streaming'
import { pythEndpoints } from 'constants/pyth'
import { byDenom } from 'utils/array'
import {
Bar,
ErrorCallback,
HistoryCallback,
IDatafeedChartApi,
LibrarySymbolInfo,
OnReadyCallback,
PeriodParams,
ResolutionString,
ResolveCallback,
SearchSymbolsCallback,
SubscribeBarsCallback,
SymbolResolveExtension,
} from 'utils/charting_library'
import { BN } from 'utils/helpers'
import { devideByPotentiallyZero } from 'utils/math'
interface PythBarQueryData {
s: string
t: number[]
o: number[]
h: number[]
l: number[]
c: number[]
v: number[]
}
const lastBarsCache = new Map()
interface TheGraphBarQueryData {
close: string
high: string
low: string
open: string
timestamp: string
volume: string
}
export const PAIR_SEPARATOR = '<>'
export class DataFeed implements IDatafeedChartApi {
candlesEndpoint: string
candlesEndpointTheGraph: string
assets: Asset[]
debug = false
enabledMarketAssetDenoms: string[] = []
batchSize = 1000
baseDecimals: number = 6
baseDenom: string = 'uosmo'
intervalsTheGraph: { [key: string]: string } = {
'15': '15m',
'30': '30m',
'60': '1h',
'240': '4h',
'1D': '1d',
}
millisecondsPerInterval: { [key: string]: number } = {
'1': MILLISECONDS_PER_MINUTE * 1,
'5': MILLISECONDS_PER_MINUTE * 5,
'15': MILLISECONDS_PER_MINUTE * 15,
'30': MILLISECONDS_PER_MINUTE * 30,
'60': MILLISECONDS_PER_MINUTE * 60,
'240': MILLISECONDS_PER_MINUTE * 240,
'1D': MILLISECONDS_PER_MINUTE * 1440,
}
pairs: { baseAsset: string; quoteAsset: string }[] = []
pairsWithData: string[] = []
supportedPools: string[] = []
supportedResolutions = ['1', '5', '15', '30', '60', '240', 'D'] as ResolutionString[]
constructor(
debug = false,
assets: Asset[],
baseDecimals: number,
baseDenom: string,
chainConfig: ChainConfig,
) {
if (debug) console.log('Start charting library datafeed')
this.candlesEndpoint = pythEndpoints.candles
this.candlesEndpointTheGraph = chainConfig.endpoints.graphCandles ?? ''
this.assets = assets
this.debug = debug
this.baseDecimals = baseDecimals
this.baseDenom = baseDenom
const enabledMarketAssets = assets.filter((asset) => asset.isEnabled && asset.isMarket)
this.enabledMarketAssetDenoms = enabledMarketAssets.map((asset) => asset.denom)
this.supportedPools = enabledMarketAssets
.map((asset) => asset.poolId?.toString())
.filter((poolId) => typeof poolId === 'string') as string[]
}
getDescription(pairName: string, inverted: boolean) {
const [denom1, denom2] = pairName.split(PAIR_SEPARATOR)
const asset1 = this.assets.find(byDenom(denom1))
const asset2 = this.assets.find(byDenom(denom2))
return inverted ? `${asset2?.symbol}/${asset1?.symbol}` : `${asset1?.symbol}/${asset2?.symbol}`
}
async getPairsWithData() {
const query = `
{
pairs(first: ${this.batchSize},
orderBy: symbol,
orderDirection: asc,
where: {
baseAsset_in: ${JSON.stringify(this.enabledMarketAssetDenoms)},
quoteAsset_in: ${JSON.stringify(this.enabledMarketAssetDenoms)},
poolId_in: ${JSON.stringify(this.supportedPools)}
}
) {
baseAsset
quoteAsset
}
}`
return fetch(this.candlesEndpointTheGraph, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
export const datafeed = {
onReady: (callback: OnReadyCallback) => {
callback({
supported_resolutions: [
'1',
'2',
'5',
'15',
'30',
'60',
'120',
'240',
'360',
'720',
'D',
'1D',
'W',
'1W',
'M',
'1M',
] as ResolutionString[],
supports_marks: true,
supports_timescale_marks: false,
})
.then((res) => res.json())
.then((json) => {
this.pairs = json.data.pairs
this.pairsWithData = json.data.pairs.map(
(pair: { baseAsset: string; quoteAsset: string }) => {
return `${pair.quoteAsset}${PAIR_SEPARATOR}${pair.baseAsset}`
},
)
})
.catch((err) => {
if (this.debug) console.error(err)
throw err
})
}
onReady(callback: OnReadyCallback) {
const configurationData = {
supported_resolutions: this.supportedResolutions,
}
setTimeout(async () => {
await this.getPairsWithData()
})
callback(configurationData)
}
resolveSymbol(pairName: string, onResolve: ResolveCallback, onError: ErrorCallback) {
pairName = this.getPairName(pairName)
setTimeout(() => {
const info: LibrarySymbolInfo = {
...defaultSymbolInfo,
name: this.getDescription(pairName, false),
full_name: this.getDescription(pairName, true),
description: this.getDescription(pairName, true),
ticker: this.getDescription(pairName, false),
exchange: this.getExchangeName(pairName),
listed_exchange: this.getExchangeName(pairName),
supported_resolutions: this.supportedResolutions,
base_name: [this.getDescription(pairName, false)],
pricescale: this.getPriceScale(pairName),
} as LibrarySymbolInfo
onResolve(info)
})
}
async getBars(
},
getBars: (
symbolInfo: LibrarySymbolInfo,
resolution: ResolutionString,
periodParams: PeriodParams,
onResult: HistoryCallback,
): Promise<void> {
try {
let bars = [] as Bar[]
const pythFeedIds = this.getPythFeedIds(symbolInfo.full_name)
const now = new Date().getTime()
const to = BN(now).dividedBy(1000).integerValue().toNumber()
const from = BN(now)
.minus(this.batchSize * this.millisecondsPerInterval[resolution])
.dividedBy(1000)
.integerValue()
.toNumber()
const pythFeedId1 = pythFeedIds[0]
const pythFeedId2 = pythFeedIds[1]
if (pythFeedId1 && pythFeedId2) {
const asset1Bars = this.queryBarData(pythFeedId1, resolution, from, to)
const asset2Bars = this.queryBarData(pythFeedId2, resolution, from, to)
await Promise.all([asset1Bars, asset2Bars]).then(([asset1Bars, asset2Bars]) => {
bars = this.combineBars(asset1Bars, asset2Bars)
onResult(bars)
onHistoryCallback: HistoryCallback,
onErrorCallback: ErrorCallback,
) => {
const { from, to, firstDataRequest } = periodParams
fetch(
`${pythEndpoints.candles}/history?symbol=${symbolInfo.ticker}&from=${from}&to=${to}&resolution=${resolution}`,
).then((response) => {
response
.json()
.then((data) => {
if (data.errmsg) {
onHistoryCallback([], { noData: true })
return
}
if (data.t.length === 0) {
onHistoryCallback([], { noData: true })
return
}
const bars = []
for (let i = 0; i < data.t.length; ++i) {
bars.push({
time: data.t[i] * 1000,
low: data.l[i],
high: data.h[i],
open: data.o[i],
close: data.c[i],
})
}
if (firstDataRequest) {
lastBarsCache.set(symbolInfo.ticker, {
...bars[bars.length - 1],
})
}
onHistoryCallback(bars, { noData: false })
})
} else {
//await this.getBarsFromTheGraph(symbolInfo, resolution, to).then((bars) => onResult(bars))
onResult([], { noData: true })
}
} catch (error) {
console.error(error)
return onResult([], { noData: true })
}
}
async getBarsFromTheGraph(
.catch((error) => {
onErrorCallback(error)
})
})
},
subscribeBars(
symbolInfo: LibrarySymbolInfo,
resolution: ResolutionString,
to: number,
) {
let pair1 = this.getPairName(symbolInfo.full_name)
let pair2: string = ''
let pair3: string = ''
let theGraphBars = [] as Bar[]
if (!this.pairsWithData.includes(pair1)) {
if (this.debug) console.log('Pair does not have data, need to combine with other pairs')
const [buyAssetDenom, sellAssetDenom] = pair1.split(PAIR_SEPARATOR)
const pair1Pools = this.pairs.filter((pair) => pair.baseAsset === buyAssetDenom)
const pair2Pools = this.pairs.filter((pair) => pair.quoteAsset === sellAssetDenom)
const matchedPools = pair1Pools.filter((pool) => {
const asset = pool.quoteAsset
return !!pair2Pools.find((pool) => pool.baseAsset === asset)
})
if (matchedPools.length) {
pair1 = `${buyAssetDenom}${PAIR_SEPARATOR}${matchedPools[0].quoteAsset}`
pair2 = `${matchedPools[0].quoteAsset}${PAIR_SEPARATOR}${sellAssetDenom}`
} else {
const middlePair = this.pairs.filter(
(pair) =>
pair1Pools.map((pairs) => pairs.quoteAsset).includes(pair.baseAsset) &&
pair2Pools.map((pairs) => pairs.baseAsset).includes(pair.quoteAsset),
)
pair1 = `${buyAssetDenom}${PAIR_SEPARATOR}${middlePair[0].baseAsset}`
pair2 = `${middlePair[0].baseAsset}${PAIR_SEPARATOR}${middlePair[0].quoteAsset}`
pair3 = `${middlePair[0].quoteAsset}${PAIR_SEPARATOR}${sellAssetDenom}`
}
}
const pair1Bars = this.queryBarDataTheGraph(
pair1.split(PAIR_SEPARATOR)[0],
pair1.split(PAIR_SEPARATOR)[1],
onRealtimeCallback: SubscribeBarsCallback,
subscriberUID: string,
onResetCacheNeededCallback: () => void,
): void {
subscribeOnStream(
symbolInfo,
resolution,
to,
onRealtimeCallback,
subscriberUID,
onResetCacheNeededCallback,
lastBarsCache.get(symbolInfo.ticker),
)
let pair2Bars: Promise<Bar[]> | null = null
if (pair2) {
pair2Bars = this.queryBarDataTheGraph(
pair2.split(PAIR_SEPARATOR)[0],
pair2.split(PAIR_SEPARATOR)[1],
resolution,
to,
)
}
let pair3Bars: Promise<Bar[]> | null = null
if (pair3) {
pair3Bars = this.queryBarDataTheGraph(
pair3.split(PAIR_SEPARATOR)[0],
pair3.split(PAIR_SEPARATOR)[1],
resolution,
to,
)
}
await Promise.all([pair1Bars, pair2Bars, pair3Bars]).then(
([pair1Bars, pair2Bars, pair3Bars]) => {
let bars = pair1Bars
if (!bars.length) {
return
}
if (pair2Bars) {
bars = this.combineBars(pair1Bars, pair2Bars)
}
if (pair3Bars) {
bars = this.combineBars(bars, pair3Bars)
}
const filler = Array.from({ length: this.batchSize - bars.length }).map((_, index) => ({
time:
(bars[0]?.time || new Date().getTime()) -
(index * this.millisecondsPerInterval[resolution]) / 1000,
close: 0,
open: 0,
high: 0,
low: 0,
volume: 0,
}))
theGraphBars = [...filler, ...bars]
},
)
return theGraphBars
}
async queryBarData(
feedSymbol: string,
resolution: ResolutionString,
from: PeriodParams['from'],
to: PeriodParams['to'],
): Promise<Bar[]> {
const URI = new URL('/v1/shims/tradingview/history', this.candlesEndpoint)
const params = new URLSearchParams(URI.search)
params.append('to', to.toString())
params.append('from', from.toString())
params.append('resolution', resolution)
params.append('symbol', feedSymbol)
URI.search = params.toString()
return fetch(URI)
.then((res) => res.json())
.then((json) => {
return this.resolveBarData(json, resolution, to)
})
.catch((err) => {
if (this.debug) console.error(err)
throw err
})
}
async queryBarDataTheGraph(
quote: string,
base: string,
resolution: ResolutionString,
to: PeriodParams['to'],
): Promise<Bar[]> {
const interval = this.intervalsTheGraph[resolution]
const query = `
{
candles(
first: ${this.batchSize},
orderBy: "timestamp",
orderDirection: "desc",
where: {
interval: "${interval}",
quote: "${quote}",
base: "${base}"
poolId_in: ${JSON.stringify(this.supportedPools)}
}
) {
timestamp
open
high
low
close
volume
},
unsubscribeBars(subscriberUID: string) {
unsubscribeFromStream(subscriberUID)
},
searchSymbols: (
userInput: string,
exchange: string,
symbolType: string,
onResult: SearchSymbolsCallback,
) => {
return
},
resolveSymbol: (
symbolName: string,
onResolve: ResolveCallback,
onError: ErrorCallback,
extension?: SymbolResolveExtension,
) => {
try {
fetch(`${pythEndpoints.candles}/symbols?symbol=${symbolName}`).then((response) => {
response
.json()
.then((symbolInfo) => {
if (symbolInfo.errmsg) {
symbolInfo.description = symbolName
} else {
symbolInfo.description = symbolInfo.ticker.split('Crypto.')[1]
}
}
`
return fetch(this.candlesEndpointTheGraph, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
})
.then((res) => res.json())
.then((json: { data?: { candles: TheGraphBarQueryData[] } }) => {
return this.resolveBarDataTheGraph(
json.data?.candles.reverse() || [],
base,
quote,
resolution,
to,
)
onResolve(symbolInfo)
})
.catch((error) => {
console.error(error)
return
})
})
.catch((err) => {
if (this.debug) console.error(err)
throw err
})
}
resolveBarData(data: PythBarQueryData, resolution: ResolutionString, to: number) {
let barData = [] as Bar[]
if (data['s'] === 'ok') {
barData = data['t'].map((timestamp, index) => ({
time: timestamp * 1000,
close: data['c'][index],
open: data['o'][index],
high: data['h'][index],
low: data['l'][index],
}))
} catch (error) {
console.error(error)
return
}
return this.fillBarData(barData, resolution, to)
}
resolveBarDataTheGraph(
bars: TheGraphBarQueryData[],
toDenom: string,
fromDenom: string,
resolution: ResolutionString,
to: number,
) {
let barData = [] as Bar[]
const toDecimals = this.assets.find(byDenom(toDenom))?.decimals || 6
const fromDecimals = this.assets.find(byDenom(fromDenom))?.decimals || 6
const additionalDecimals = toDecimals - fromDecimals
barData = bars.map((bar) => ({
time: BN(bar.timestamp).multipliedBy(1000).toNumber(),
close: BN(bar.close).shiftedBy(additionalDecimals).toNumber(),
open: BN(bar.open).shiftedBy(additionalDecimals).toNumber(),
high: BN(bar.high).shiftedBy(additionalDecimals).toNumber(),
low: BN(bar.low).shiftedBy(additionalDecimals).toNumber(),
volume: BN(bar.volume).shiftedBy(additionalDecimals).toNumber(),
}))
return this.fillBarData(barData, resolution, to)
}
fillBarData(barData: Bar[], resolution: ResolutionString, to: number) {
if (barData.length < this.batchSize) {
const filler = Array.from({ length: this.batchSize - barData.length }).map((_, index) => ({
time: (barData[0]?.time || to) - index * this.millisecondsPerInterval[resolution],
close: 0,
open: 0,
high: 0,
low: 0,
volume: 0,
}))
barData = [...filler, ...barData]
}
return barData.length > this.batchSize ? barData.slice(0, this.batchSize) : barData
}
combineBars(pair1Bars: Bar[], pair2Bars: Bar[]): Bar[] {
const bars: Bar[] = []
pair1Bars.forEach((pair1Bar, index) => {
const pair2Bar = pair2Bars[index]
bars.push({
time: pair1Bar.time,
open: devideByPotentiallyZero(pair1Bar.open, pair2Bar.open),
close: devideByPotentiallyZero(pair1Bar.close, pair2Bar.close),
high: devideByPotentiallyZero(pair1Bar.high, pair2Bar.high),
low: devideByPotentiallyZero(pair1Bar.low, pair2Bar.low),
})
})
return bars
}
getPairName(name: string) {
if (name.includes(PAIR_SEPARATOR)) return name
const [symbol1, symbol2] = name.split('/')
const asset1 = this.assets.find((asset) => asset.symbol === symbol1)
const asset2 = this.assets.find((asset) => asset.symbol === symbol2)
return `${asset1?.denom}${PAIR_SEPARATOR}${asset2?.denom}`
}
getPriceScale(name: string) {
const denoms = name.split(PAIR_SEPARATOR)
const asset2 = this.assets.find(byDenom(denoms[1]))
const decimalsOut = asset2?.decimals ?? 6
return BN(1)
.shiftedBy(decimalsOut > 8 ? 8 : decimalsOut)
.toNumber()
}
getExchangeName(name: string) {
const denoms = name.split(PAIR_SEPARATOR)
const pythFeedId1 = this.assets.find(byDenom(denoms[0]))?.pythHistoryFeedId
const pythFeedId2 = this.assets.find(byDenom(denoms[1]))?.pythHistoryFeedId
//if (!pythFeedId1 || !pythFeedId2) return 'Osmosis'
return 'Pyth Oracle'
}
getPythFeedIds(name: string) {
if (name.includes(PAIR_SEPARATOR)) {
const [denom1, denom2] = name.split(PAIR_SEPARATOR)
const denomFeedId1 = this.assets.find((asset) => asset.denom === denom1)?.pythHistoryFeedId
const denomFeedId2 = this.assets.find((asset) => asset.denom === denom2)?.pythHistoryFeedId
return [denomFeedId1, denomFeedId2]
}
const [symbol1, symbol2] = name.split('/')
const feedId1 = this.assets.find((asset) => asset.symbol === symbol1)?.pythHistoryFeedId
const feedId2 = this.assets.find((asset) => asset.symbol === symbol2)?.pythHistoryFeedId
return [feedId1, feedId2]
}
searchSymbols(): void {
// Don't allow to search for symbols
}
subscribeBars(): void {
// TheGraph doesn't support websockets yet
}
unsubscribeBars(listenerGuid: string): void {
// TheGraph doesn't support websockets yet
}
},
}

View File

@ -1,170 +0,0 @@
import { useEffect, useMemo, useRef } from 'react'
import Card from 'components/Card'
import DisplayCurrency from 'components/DisplayCurrency'
import { FormattedNumber } from 'components/FormattedNumber'
import Loading from 'components/Loading'
import Text from 'components/Text'
import { disabledFeatures, enabledFeatures, overrides } from 'components/Trade/TradeChart/constants'
import { DataFeed, PAIR_SEPARATOR } from 'components/Trade/TradeChart/DataFeed'
import PoweredByPyth from 'components/Trade/TradeChart/PoweredByPyth'
import { BN_ZERO } from 'constants/math'
import useAllAssets from 'hooks/assets/useAllAssets'
import useBaseAsset from 'hooks/assets/useBasetAsset'
import usePrices from 'hooks/usePrices'
import useStore from 'store'
import { BNCoin } from 'types/classes/BNCoin'
import { byDenom } from 'utils/array'
import {
ChartingLibraryWidgetOptions,
IChartingLibraryWidget,
ResolutionString,
Timezone,
widget,
} from 'utils/charting_library'
import { magnify } from 'utils/formatters'
interface Props {
buyAsset: Asset
sellAsset: Asset
}
export const TVChartContainer = (props: Props) => {
const chartContainerRef = useRef<HTMLDivElement>() as React.MutableRefObject<HTMLInputElement>
const widgetRef = useRef<IChartingLibraryWidget>()
const defaultSymbol = useRef<string>(
`${props.sellAsset.denom}${PAIR_SEPARATOR}${props.buyAsset.denom}`,
)
const chainConfig = useStore((s) => s.chainConfig)
const baseAsset = useBaseAsset()
const assets = useAllAssets()
const dataFeed = useMemo(
() => new DataFeed(false, assets, baseAsset.decimals, baseAsset.denom, chainConfig),
[assets, baseAsset.decimals, baseAsset.denom, chainConfig],
)
const { data: prices, isLoading } = usePrices()
const ratio = useMemo(() => {
const priceBuyAsset = prices.find(byDenom(props.buyAsset.denom))?.amount
const priceSellAsset = prices.find(byDenom(props.sellAsset.denom))?.amount
if (!priceBuyAsset || !priceSellAsset) return BN_ZERO
return priceBuyAsset.dividedBy(priceSellAsset)
}, [prices, props.buyAsset.denom, props.sellAsset.denom])
useEffect(() => {
const widgetOptions: ChartingLibraryWidgetOptions = {
symbol: defaultSymbol.current,
datafeed: dataFeed,
interval: '1h' as ResolutionString,
library_path: '/charting_library/',
locale: 'en',
time_scale: {
min_bar_spacing: 12,
},
toolbar_bg: '#220E1D',
disabled_features: disabledFeatures,
enabled_features: enabledFeatures,
charts_storage_api_version: '1.1',
client_id: 'sample-implementation',
timezone: 'Etc/UTC' as Timezone,
user_id: 'not-set',
fullscreen: false,
autosize: true,
container: chartContainerRef.current,
custom_css_url: '/tradingview.css',
settings_overrides: {
'paneProperties.background': '#220E1D',
'paneProperties.backgroundType': 'solid',
'paneProperties.vertGridProperties.color': '#220E1D',
'paneProperties.horzGridProperties.color': '#220E1D',
'mainSeriesProperties.candleStyle.upColor': '#3DAE9A',
'mainSeriesProperties.candleStyle.downColor': '#AE3D3D',
'mainSeriesProperties.candleStyle.borderColor': '#232834',
'mainSeriesProperties.candleStyle.borderUpColor': '#3DAE9A',
'mainSeriesProperties.candleStyle.borderDownColor': '#AE3D3D',
'mainSeriesProperties.candleStyle.wickUpColor': '#3DAE9A',
'mainSeriesProperties.candleStyle.wickDownColor': '#AE3D3D',
'mainSeriesProperties.candleStyle.barColorsOnPrevClose': false,
'scalesProperties.textColor': 'rgba(255, 255, 255, 0.7)',
'paneProperties.legendProperties.showSeriesTitle': true,
'paneProperties.legendProperties.showVolume': false,
'paneProperties.legendProperties.showStudyValues': false,
'paneProperties.legendProperties.showStudyTitles': false,
'scalesProperties.axisHighlightColor': '#381730',
'linetooltrendline.color': '#3DAE9A',
'linetooltrendline.linewidth': 10,
},
overrides,
loading_screen: {
backgroundColor: '#220E1D',
foregroundColor: 'rgba(255, 255, 255, 0.3)',
},
theme: 'dark',
}
const tvWidget = new widget(widgetOptions)
tvWidget.onChartReady(() => {
widgetRef.current = tvWidget
})
return () => {
tvWidget.remove()
}
}, [dataFeed, defaultSymbol])
useEffect(() => {
if (widgetRef?.current) {
widgetRef.current.setSymbol(
`${props.sellAsset.denom}${PAIR_SEPARATOR}${props.buyAsset.denom}`,
widgetRef.current.chart().resolution() || ('1h' as ResolutionString),
() => {},
)
}
}, [props.buyAsset.denom, props.sellAsset.denom])
return (
<Card
title={
<div className='flex items-center w-full bg-white/10'>
<Text size='lg' className='flex items-center flex-1 p-4 font-semibold'>
Trading Chart
</Text>
{ratio.isZero() || isLoading ? (
<Loading className='h-4 mr-4 w-60' />
) : (
<div className='flex items-center gap-1 p-4'>
<Text size='sm'>1 {props.buyAsset.symbol}</Text>
<FormattedNumber
className='text-sm'
amount={Number(ratio.toPrecision(6))}
options={{
prefix: '= ',
suffix: ` ${props.sellAsset.symbol}`,
abbreviated: false,
maxDecimals: props.sellAsset.decimals,
}}
/>
<DisplayCurrency
parentheses
options={{ abbreviated: false }}
className='justify-end pl-2 text-sm text-white/50'
coin={
new BNCoin({
denom: props.buyAsset.denom,
amount: magnify(1, props.buyAsset).toString(),
})
}
/>
</div>
)}
</div>
}
contentClassName='px-0.5 pb-0.5 h-full bg-chart w-[calc(100%-2px)] ml-[1px]'
className='h-[70dvh] max-h-[980px] min-h-[560px]'
>
<div ref={chartContainerRef} className='h-[calc(100%-32px)] overflow-hidden' />
<PoweredByPyth />
</Card>
)
}

View File

@ -1,7 +1,6 @@
import {
ChartingLibraryFeatureset,
LibrarySymbolInfo,
ResolutionString,
SeriesFormat,
Timezone,
} from 'utils/charting_library/charting_library'
@ -20,6 +19,7 @@ export const enabledFeatures: ChartingLibraryFeatureset[] = [
'timezone_menu',
'header_settings',
'use_localstorage_for_settings',
'chart_zoom',
]
export const overrides = {
@ -38,5 +38,4 @@ export const defaultSymbolInfo: Partial<LibrarySymbolInfo> = {
has_daily: true,
has_weekly_and_monthly: false,
format: 'price' as SeriesFormat,
supported_resolutions: ['15'] as ResolutionString[],
}

View File

@ -1,59 +1,124 @@
import dynamic from 'next/dynamic'
import Script from 'next/script'
import { useState } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import Card from 'components/Card'
import { CircularProgress } from 'components/CircularProgress'
import DisplayCurrency from 'components/DisplayCurrency'
import { FormattedNumber } from 'components/FormattedNumber'
import Loading from 'components/Loading'
import Text from 'components/Text'
import { datafeed } from 'components/Trade/TradeChart/DataFeed'
import PoweredByPyth from 'components/Trade/TradeChart/PoweredByPyth'
const TVChartContainer = dynamic(
() => import('components/Trade/TradeChart/TVChartContainer').then((mod) => mod.TVChartContainer),
{ ssr: false },
)
import { disabledFeatures, enabledFeatures } from 'components/Trade/TradeChart/constants'
import { BN_ZERO } from 'constants/math'
import usePrices from 'hooks/usePrices'
import { BNCoin } from 'types/classes/BNCoin'
import { byDenom } from 'utils/array'
import { ChartingLibraryWidgetOptions, ResolutionString, widget } from 'utils/charting_library'
import { magnify } from 'utils/formatters'
interface Props {
buyAsset: Asset
sellAsset: Asset
}
export default function TradeChart(props: Props) {
const [isScriptReady, setIsScriptReady] = useState(false)
const { data: prices, isLoading } = usePrices()
const ratio = useMemo(() => {
const priceBuyAsset = prices.find(byDenom(props.buyAsset.denom))?.amount
const priceSellAsset = prices.find(byDenom(props.sellAsset.denom))?.amount
if (!priceBuyAsset || !priceSellAsset) return BN_ZERO
return priceBuyAsset.dividedBy(priceSellAsset)
}, [prices, props.buyAsset.denom, props.sellAsset.denom])
const chartContainerRef = useRef<HTMLDivElement>() as React.MutableRefObject<HTMLInputElement>
useEffect(() => {
if (typeof window !== 'undefined' && window.TradingView) {
const widgetOptions: ChartingLibraryWidgetOptions = {
symbol: props.buyAsset.pythFeedName ?? `${props.buyAsset.symbol}/USD`,
datafeed: datafeed,
interval: '1h' as ResolutionString,
library_path: '/charting_library/',
locale: 'en',
time_scale: {
min_bar_spacing: 12,
},
toolbar_bg: '#220E1D',
disabled_features: disabledFeatures,
enabled_features: enabledFeatures,
fullscreen: false,
autosize: true,
container: chartContainerRef.current,
theme: 'dark',
overrides: {
'paneProperties.background': '#220E1D',
'linetooltrendline.linecolor': 'rgba(255, 255, 255, 0.8)',
'linetooltrendline.linewidth': 2,
},
loading_screen: {
backgroundColor: '#220E1D',
foregroundColor: 'rgba(255, 255, 255, 0.3)',
},
custom_css_url: '/tradingview.css',
}
const tvWidget = new widget(widgetOptions)
tvWidget.onChartReady(() => {
const chart = tvWidget.chart()
chart.getSeries().setChartStyleProperties(1, {
upColor: '#3DAE9A',
downColor: '#AE3D3D',
borderColor: '#232834',
borderUpColor: '#3DAE9A',
borderDownColor: '#AE3D3D',
wickUpColor: '#3DAE9A',
wickDownColor: '#AE3D3D',
barColorsOnPrevClose: false,
})
})
}
}, [props.buyAsset.pythFeedName, props.buyAsset.symbol])
return (
<>
<Script
src='/datafeeds/udf/dist/bundle.js'
strategy='lazyOnload'
onReady={() => {
setIsScriptReady(true)
}}
onLoad={() => {
setIsScriptReady(true)
}}
/>
{isScriptReady ? (
<TVChartContainer buyAsset={props.buyAsset} sellAsset={props.sellAsset} />
) : (
<Card
title={
<div className='flex items-center w-full bg-white/10'>
<Text size='lg' className='flex items-center flex-1 p-4 font-semibold'>
Trading Chart
</Text>
<Loading className='h-4 mr-4 w-60' />
<Card
title={
<div className='flex items-center w-full bg-white/10'>
<Text size='lg' className='flex items-center flex-1 p-4 font-semibold'>
Trading Chart
</Text>
{ratio.isZero() || isLoading ? (
<Loading className='h-4 mr-4 w-60' />
) : (
<div className='flex items-center gap-1 p-4'>
<Text size='sm'>1 {props.buyAsset.symbol}</Text>
<FormattedNumber
className='text-sm'
amount={Number(ratio.toPrecision(6))}
options={{
prefix: '= ',
suffix: ` ${props.sellAsset.symbol}`,
abbreviated: false,
maxDecimals: props.sellAsset.decimals,
}}
/>
<DisplayCurrency
parentheses
options={{ abbreviated: false }}
className='justify-end pl-2 text-sm text-white/50'
coin={
new BNCoin({
denom: props.buyAsset.denom,
amount: magnify(1, props.buyAsset).toString(),
})
}
/>
</div>
}
contentClassName='px-0.5 pb-0.5 h-full bg-chart w-[calc(100%-2px)] ml-[1px]'
className='h-[70dvh] max-h-[980px] min-h-[560px]'
>
<div className='flex items-center justify-center w-full h-[calc(100%-32px)] rounded-b-base bg-chart'>
<CircularProgress size={60} className='opacity-50' />
</div>
<PoweredByPyth />
</Card>
)}
</>
)}
</div>
}
contentClassName='px-0.5 pb-0.5 h-full bg-chart w-[calc(100%-2px)] ml-[1px]'
className='h-[70dvh] max-h-[980px] min-h-[560px]'
>
<div ref={chartContainerRef} className='h-[calc(100%-32px)] overflow-hidden' />
<PoweredByPyth />
</Card>
)
}

View File

@ -0,0 +1,148 @@
import { pythEndpoints } from 'constants/pyth'
import {
LibrarySymbolInfo,
ResolutionString,
SubscribeBarsCallback,
} from 'utils/charting_library/charting_library'
const streamingUrl = `${pythEndpoints.candles}/streaming`
const channelToSubscription = new Map()
function handleStreamingData(data: StreamData) {
const { id, p, t } = data
const tradePrice = p
const tradeTime = t * 1000 // Multiplying by 1000 to get milliseconds
const channelString = id
const subscriptionItem = channelToSubscription.get(channelString)
if (!subscriptionItem) {
return
}
const lastDailyBar = subscriptionItem.lastDailyBar
const nextDailyBarTime = getNextDailyBarTime(lastDailyBar.time)
let bar: Bar
if (tradeTime >= nextDailyBarTime) {
bar = {
time: nextDailyBarTime,
open: tradePrice,
high: tradePrice,
low: tradePrice,
close: tradePrice,
}
} else {
bar = {
...lastDailyBar,
high: Math.max(lastDailyBar.high, tradePrice),
low: Math.min(lastDailyBar.low, tradePrice),
close: tradePrice,
}
}
subscriptionItem.lastDailyBar = bar
// Send data to every subscriber of that symbol
subscriptionItem.handlers.forEach((handler: any) => handler.callback(bar))
channelToSubscription.set(channelString, subscriptionItem)
}
function startStreaming(retries = 3, delay = 3000) {
fetch(streamingUrl)
.then((response) => {
if (response.body === null) return
const reader = response.body.getReader()
function streamData() {
reader
.read()
.then(({ value, done }) => {
if (done) {
// console.error('Streaming ended.')
return
}
const dataStrings = new TextDecoder().decode(value).split('\n')
dataStrings.forEach((dataString) => {
const trimmedDataString = dataString.trim()
if (trimmedDataString) {
try {
var jsonData = JSON.parse(trimmedDataString)
handleStreamingData(jsonData)
} catch (e) {
if (e instanceof Error) {
// console.error('Error parsing JSON:', e.message)
}
}
}
})
streamData()
})
.catch((error) => {
// console.error('Error reading from stream:', error)
attemptReconnect(retries, delay)
})
}
streamData()
})
.catch((error) => {
// console.error('Error fetching from the streaming endpoint:', error)
})
function attemptReconnect(retriesLeft: number, delay: number) {
if (retriesLeft > 0) {
setTimeout(() => {
startStreaming(retriesLeft - 1, delay)
}, delay)
} else {
// console.error('Maximum reconnection attempts reached.')
}
}
}
function getNextDailyBarTime(barTime: number) {
const date = new Date(barTime * 1000)
date.setDate(date.getDate() + 1)
return date.getTime() / 1000
}
export function subscribeOnStream(
symbolInfo: LibrarySymbolInfo,
resolution: ResolutionString,
onRealtimeCallback: SubscribeBarsCallback,
subscriberUID: string,
onResetCacheNeededCallback: () => void,
lastDailyBar: Bar,
) {
const channelString = symbolInfo.ticker
const handler = {
id: subscriberUID,
callback: onRealtimeCallback,
}
let subscriptionItem = channelToSubscription.get(channelString)
subscriptionItem = {
subscriberUID,
resolution,
lastDailyBar,
handlers: [handler],
}
channelToSubscription.set(channelString, subscriptionItem)
startStreaming()
}
export function unsubscribeFromStream(subscriberUID: string) {
for (const channelString of channelToSubscription.keys()) {
const subscriptionItem = channelToSubscription.get(channelString)
const handlerIndex = subscriptionItem.handlers.findIndex(
(handler: any) => handler.id === subscriberUID,
)
if (handlerIndex !== -1) {
channelToSubscription.delete(channelString)
break
}
}
}

View File

@ -12,7 +12,7 @@ const AKT: AssetMetaData = {
isAutoLendEnabled: true,
isBorrowEnabled: true,
pythPriceFeedId: '4ea5bb4d2f5900cc2e97ba534240950740b4d3b89fe712a94a7304fd2fd92702',
pythHistoryFeedId: 'Crypto.AKT/USD',
pythFeedName: 'AKTUSD',
}
export default AKT

View File

@ -13,7 +13,7 @@ const ATOM: AssetMetaData = {
isAutoLendEnabled: true,
isBorrowEnabled: true,
pythPriceFeedId: 'b00b60f88b03a6a625a8d1c048c3f66653edf217439983d037e7222c4e612819',
pythHistoryFeedId: 'Crypto.ATOM/USD',
pythFeedName: 'ATOMUSD',
}
export default ATOM

View File

@ -11,7 +11,7 @@ const AXL: AssetMetaData = {
isDisplayCurrency: true,
isAutoLendEnabled: false,
pythPriceFeedId: '60144b1d5c9e9851732ad1d9760e3485ef80be39b984f6bf60f82b28a2b7f126',
pythHistoryFeedId: 'Crypto.AXL/USD',
pythFeedName: 'AXLUSD',
}
export default AXL

View File

@ -12,7 +12,7 @@ const DYDX: AssetMetaData = {
isAutoLendEnabled: true,
isBorrowEnabled: true,
pythPriceFeedId: '6489800bb8974169adfe35937bf6736507097d13c190d760c557108c7e93a81b',
pythHistoryFeedId: 'Crypto.DYDX/USD',
pythFeedName: 'DYDXUSD',
}
export default DYDX

View File

@ -12,7 +12,7 @@ const INJ: AssetMetaData = {
isAutoLendEnabled: true,
isBorrowEnabled: true,
pythPriceFeedId: '7a5bc1d2b56ad029048cd63964b3ad2776eadf812edc1a43a31406cb54bff592',
pythHistoryFeedId: 'Crypto.INJ/USD',
pythFeedName: 'INJUSD',
}
export default INJ

View File

@ -11,7 +11,7 @@ const NTRN: AssetMetaData = {
isMarket: true,
isBorrowEnabled: true,
isAutoLendEnabled: true,
pythHistoryFeedId: 'Crypto.NTRN/USD',
pythFeedName: 'NTRNUSD',
}
export default NTRN

View File

@ -12,7 +12,7 @@ const OSMO: AssetMetaData = {
isDisplayCurrency: true,
isAutoLendEnabled: true,
pythPriceFeedId: '5867f5683c757393a0670ef0f701490950fe93fdb006d181c8265a831ac0c5c6',
pythHistoryFeedId: 'Crypto.OSMO/USD',
pythFeedName: 'OSMOUSD',
}
export default OSMO

View File

@ -12,7 +12,7 @@ const TIA: AssetMetaData = {
isAutoLendEnabled: true,
isBorrowEnabled: true,
pythPriceFeedId: '09f7c1d7dfbb7df2b8fe3d3d87ee94a2259d212da4f30c1f0540d066dfa44723',
pythHistoryFeedId: 'Crypto.TIA/USD',
pythFeedName: 'TIAUSD',
}
export default TIA

View File

@ -13,7 +13,7 @@ const USDCaxl: AssetMetaData = {
isBorrowEnabled: true,
isAutoLendEnabled: true,
pythPriceFeedId: 'eaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a',
pythHistoryFeedId: 'Crypto.USDC/USD',
pythFeedName: 'USDCUSD',
}
export default USDCaxl

View File

@ -13,7 +13,7 @@ const USDC: AssetMetaData = {
isBorrowEnabled: true,
isAutoLendEnabled: true,
pythPriceFeedId: 'eaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a',
pythHistoryFeedId: 'Crypto.USDC/USD',
pythFeedName: 'USDCUSD',
}
export default USDC

View File

@ -13,7 +13,7 @@ const USDT: AssetMetaData = {
isAutoLendEnabled: true,
isBorrowEnabled: true,
pythPriceFeedId: '2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b',
pythHistoryFeedId: 'Crypto.USDT/USD',
pythFeedName: 'USDTUSD',
}
export default USDT

View File

@ -12,7 +12,7 @@ const WBTCaxl: AssetMetaData = {
isAutoLendEnabled: true,
isBorrowEnabled: true,
pythPriceFeedId: 'c9d8b075a5c69303365ae23633d4e085199bf5c520a3b90fed1322a0342ffc33',
pythHistoryFeedId: 'Crypto.WBTC/USD',
pythFeedName: 'WBTCUSD',
}
export default WBTCaxl

View File

@ -12,7 +12,7 @@ const WETHaxl: AssetMetaData = {
isAutoLendEnabled: true,
isBorrowEnabled: true,
pythPriceFeedId: 'ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace',
pythHistoryFeedId: 'Crypto.ETH/USD',
pythFeedName: 'ETHUSD',
}
export default WETHaxl

View File

@ -134,7 +134,6 @@ const Osmosis1: ChainConfig = {
rpc: 'https://osmosis-node.marsprotocol.io/GGSFGSFGFG34/osmosis-rpc-front/',
rest: 'https://osmosis-node.marsprotocol.io/GGSFGSFGFG34/osmosis-lcd-front/',
swap: 'https://app.osmosis.zone',
graphCandles: 'https://osmosis-candles.marsprotocol.io',
explorer: 'https://www.mintscan.io/osmosis/transactions/',
pools:
(process.env.NEXT_PUBLIC_OSMOSIS_REST ||

View File

@ -1,4 +1,4 @@
export const pythEndpoints = {
api: 'https://hermes.pyth.network/api',
candles: 'https://benchmarks.pyth.network',
candles: 'https://benchmarks.pyth.network/v1/shims/tradingview',
}

View File

@ -3,7 +3,11 @@ import { Head, Html, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html className='p-0 m-0' lang='en'>
<Head />
<Head>
<script defer src='/charting_library/charting_library.standalone.js' />
<script defer src='/datafeeds/udf/dist/bundle.js' />
<script defer src='https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.4/socket.io.js' />
</Head>
<body className='p-0 m-0 font-sans text-white cursor-default bg-body scrollbar-hide'>
<Main />
<NextScript />

View File

@ -21,7 +21,7 @@ interface AssetMetaData {
logo: string
name: string
prefix?: string
pythHistoryFeedId?: string
pythFeedName?: string
pythPriceFeedId?: string
symbol: string
testnetDenom?: string

View File

@ -27,7 +27,6 @@ interface ChainConfig {
rest: string
rpc: string
swap: string
graphCandles?: string
explorer: string
pools: string
aprs: {

View File

@ -0,0 +1,34 @@
interface PythBarQueryData {
s: string
t: number[]
o: number[]
h: number[]
l: number[]
c: number[]
v: number[]
}
interface TheGraphBarQueryData {
close: string
high: string
low: string
open: string
timestamp: string
volume: string
}
interface Bar {
time: number
open: number
high: number
low: number
close: number
}
interface StreamData {
id: string
p: number
t: number
f: string
s: number
}

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,