From 360c6a3b09be5ee348700fb665f30432d5cef56d Mon Sep 17 00:00:00 2001 From: Matthew Russell Date: Wed, 22 Nov 2023 18:19:25 -0800 Subject: [PATCH] feat: generate queries add period params --- libs/trading-view/.eslintrc.json | 2 +- libs/trading-view/src/lib/Bars.graphql | 40 +++ libs/trading-view/src/lib/Symbol.graphql | 21 ++ .../src/lib/__generated__/Bars.ts | 119 +++++++++ .../src/lib/__generated__/Symbol.ts | 64 +++++ libs/trading-view/src/lib/datafeed.ts | 214 --------------- libs/trading-view/src/lib/trading-view.tsx | 24 +- libs/trading-view/src/lib/use-datafeed.ts | 250 ++++++++++++++++++ 8 files changed, 497 insertions(+), 237 deletions(-) create mode 100644 libs/trading-view/src/lib/Bars.graphql create mode 100644 libs/trading-view/src/lib/Symbol.graphql create mode 100644 libs/trading-view/src/lib/__generated__/Bars.ts create mode 100644 libs/trading-view/src/lib/__generated__/Symbol.ts delete mode 100644 libs/trading-view/src/lib/datafeed.ts create mode 100644 libs/trading-view/src/lib/use-datafeed.ts diff --git a/libs/trading-view/.eslintrc.json b/libs/trading-view/.eslintrc.json index a39ac5d05..f3153d3b4 100644 --- a/libs/trading-view/.eslintrc.json +++ b/libs/trading-view/.eslintrc.json @@ -1,6 +1,6 @@ { "extends": ["plugin:@nx/react", "../../.eslintrc.json"], - "ignorePatterns": ["!**/*"], + "ignorePatterns": ["!**/*", "__generated__"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], diff --git a/libs/trading-view/src/lib/Bars.graphql b/libs/trading-view/src/lib/Bars.graphql new file mode 100644 index 000000000..9f3b1fc21 --- /dev/null +++ b/libs/trading-view/src/lib/Bars.graphql @@ -0,0 +1,40 @@ +fragment Bar on Candle { + periodStart + lastUpdateInPeriod + high + low + open + close + volume +} + +query GetBars( + $marketId: ID! + $interval: Interval! + $since: String! + $to: String +) { + market(id: $marketId) { + id + decimalPlaces + positionDecimalPlaces + candlesConnection( + interval: $interval + since: $since + to: $to + pagination: { last: 5000 } + ) { + edges { + node { + ...Bar + } + } + } + } +} + +subscription LastBar($marketId: ID!, $interval: Interval!) { + candles(marketId: $marketId, interval: $interval) { + ...Bar + } +} diff --git a/libs/trading-view/src/lib/Symbol.graphql b/libs/trading-view/src/lib/Symbol.graphql new file mode 100644 index 000000000..fe20850ec --- /dev/null +++ b/libs/trading-view/src/lib/Symbol.graphql @@ -0,0 +1,21 @@ +query Symbol($marketId: ID!) { + market(id: $marketId) { + id + decimalPlaces + positionDecimalPlaces + tradableInstrument { + instrument { + code + name + product { + ... on Future { + __typename + } + ... on Perpetual { + __typename + } + } + } + } + } +} diff --git a/libs/trading-view/src/lib/__generated__/Bars.ts b/libs/trading-view/src/lib/__generated__/Bars.ts new file mode 100644 index 000000000..6e93b1c65 --- /dev/null +++ b/libs/trading-view/src/lib/__generated__/Bars.ts @@ -0,0 +1,119 @@ +import * as Types from '@vegaprotocol/types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type BarFragment = { __typename?: 'Candle', periodStart: any, lastUpdateInPeriod: any, high: string, low: string, open: string, close: string, volume: string }; + +export type GetBarsQueryVariables = Types.Exact<{ + marketId: Types.Scalars['ID']; + interval: Types.Interval; + since: Types.Scalars['String']; + to?: Types.InputMaybe; +}>; + + +export type GetBarsQuery = { __typename?: 'Query', market?: { __typename?: 'Market', id: string, decimalPlaces: number, positionDecimalPlaces: number, candlesConnection?: { __typename?: 'CandleDataConnection', edges?: Array<{ __typename?: 'CandleEdge', node: { __typename?: 'Candle', periodStart: any, lastUpdateInPeriod: any, high: string, low: string, open: string, close: string, volume: string } } | null> | null } | null } | null }; + +export type LastBarSubscriptionVariables = Types.Exact<{ + marketId: Types.Scalars['ID']; + interval: Types.Interval; +}>; + + +export type LastBarSubscription = { __typename?: 'Subscription', candles: { __typename?: 'Candle', periodStart: any, lastUpdateInPeriod: any, high: string, low: string, open: string, close: string, volume: string } }; + +export const BarFragmentDoc = gql` + fragment Bar on Candle { + periodStart + lastUpdateInPeriod + high + low + open + close + volume +} + `; +export const GetBarsDocument = gql` + query GetBars($marketId: ID!, $interval: Interval!, $since: String!, $to: String) { + market(id: $marketId) { + id + decimalPlaces + positionDecimalPlaces + candlesConnection( + interval: $interval + since: $since + to: $to + pagination: {last: 5000} + ) { + edges { + node { + ...Bar + } + } + } + } +} + ${BarFragmentDoc}`; + +/** + * __useGetBarsQuery__ + * + * To run a query within a React component, call `useGetBarsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetBarsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetBarsQuery({ + * variables: { + * marketId: // value for 'marketId' + * interval: // value for 'interval' + * since: // value for 'since' + * to: // value for 'to' + * }, + * }); + */ +export function useGetBarsQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetBarsDocument, options); + } +export function useGetBarsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetBarsDocument, options); + } +export type GetBarsQueryHookResult = ReturnType; +export type GetBarsLazyQueryHookResult = ReturnType; +export type GetBarsQueryResult = Apollo.QueryResult; +export const LastBarDocument = gql` + subscription LastBar($marketId: ID!, $interval: Interval!) { + candles(marketId: $marketId, interval: $interval) { + ...Bar + } +} + ${BarFragmentDoc}`; + +/** + * __useLastBarSubscription__ + * + * To run a query within a React component, call `useLastBarSubscription` and pass it any options that fit your needs. + * When your component renders, `useLastBarSubscription` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useLastBarSubscription({ + * variables: { + * marketId: // value for 'marketId' + * interval: // value for 'interval' + * }, + * }); + */ +export function useLastBarSubscription(baseOptions: Apollo.SubscriptionHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useSubscription(LastBarDocument, options); + } +export type LastBarSubscriptionHookResult = ReturnType; +export type LastBarSubscriptionResult = Apollo.SubscriptionResult; \ No newline at end of file diff --git a/libs/trading-view/src/lib/__generated__/Symbol.ts b/libs/trading-view/src/lib/__generated__/Symbol.ts new file mode 100644 index 000000000..de0a95016 --- /dev/null +++ b/libs/trading-view/src/lib/__generated__/Symbol.ts @@ -0,0 +1,64 @@ +import * as Types from '@vegaprotocol/types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type SymbolQueryVariables = Types.Exact<{ + marketId: Types.Scalars['ID']; +}>; + + +export type SymbolQuery = { __typename?: 'Query', market?: { __typename?: 'Market', id: string, decimalPlaces: number, positionDecimalPlaces: number, tradableInstrument: { __typename?: 'TradableInstrument', instrument: { __typename?: 'Instrument', code: string, name: string, product: { __typename: 'Future' } | { __typename: 'Perpetual' } | { __typename?: 'Spot' } } } } | null }; + + +export const SymbolDocument = gql` + query Symbol($marketId: ID!) { + market(id: $marketId) { + id + decimalPlaces + positionDecimalPlaces + tradableInstrument { + instrument { + code + name + product { + ... on Future { + __typename + } + ... on Perpetual { + __typename + } + } + } + } + } +} + `; + +/** + * __useSymbolQuery__ + * + * To run a query within a React component, call `useSymbolQuery` and pass it any options that fit your needs. + * When your component renders, `useSymbolQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useSymbolQuery({ + * variables: { + * marketId: // value for 'marketId' + * }, + * }); + */ +export function useSymbolQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(SymbolDocument, options); + } +export function useSymbolLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(SymbolDocument, options); + } +export type SymbolQueryHookResult = ReturnType; +export type SymbolLazyQueryHookResult = ReturnType; +export type SymbolQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/libs/trading-view/src/lib/datafeed.ts b/libs/trading-view/src/lib/datafeed.ts deleted file mode 100644 index abf9895cb..000000000 --- a/libs/trading-view/src/lib/datafeed.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { useEffect, useMemo } from 'react'; -import { - type LibrarySymbolInfo, - type IBasicDataFeed, - type ResolutionString, - type SeriesFormat, -} from '../charting_library/charting_library'; - -type Candle = { - start: string; - lastUpdate: string; - high: string; - low: string; - open: string; - close: string; - volume: string; - notional: string; -}; - -/* eslint-disable no-console */ -const url = 'https://api.n07.testnet.vega.xyz/api/v2'; -const websocketUrl = 'wss://api.n07.testnet.vega.xyz/api/v2/stream/candle/data'; -let socket: WebSocket; - -const configurationData = { - // only showing Vega ofc - exchanges: undefined, - - // Represents the resolutions for bars supported by your datafeed - supported_resolutions: ['1'] as ResolutionString[], -} as const; - -const resolutionMap: Record string> = { - // @ts-ignore something something - '1': (marketId: string) => `trades_candle_1_minute_${marketId}`, -}; - -export const useDataFeed = (marketId: string) => { - const datafeed = useMemo(() => { - const feed: IBasicDataFeed = { - onReady: (callback) => { - console.log('[onReady]: Method call'); - setTimeout(() => callback(configurationData)); - }, - searchSymbols: () => { - console.log('[searchSymbols]: Method call'); - }, - resolveSymbol: async ( - marketId, - onSymbolResolvedCallback, - onResolveErrorCallback, - ) => { - console.log('[resolveSymbol]: Method call', marketId); - try { - const data = await request('market/' + marketId); - - const symbolInfo: LibrarySymbolInfo = { - ticker: data.market.id, - name: data.market.tradableInstrument.instrument.code, - full_name: data.market.tradableInstrument.instrument.code, - listed_exchange: 'vega', - format: 'price' as SeriesFormat, - description: data.market.tradableInstrument.instrument.name, - type: 'futures', - session: '24x7', - timezone: 'Etc/UTC', - exchange: '', - minmov: 1, - pricescale: 100, - visible_plots_set: 'ohlc', - supported_resolutions: ['1'] as ResolutionString[], - volume_precision: data.market.positionDecimalPlaces, - data_status: 'pulsed', - delay: 1000, - has_intraday: true, // required for less than 1 day interval - }; - - onSymbolResolvedCallback(symbolInfo); - } catch (err) { - onResolveErrorCallback('Cannot resolve symbol'); - } - }, - getBars: async ( - symbolInfo, - resolution, - periodParams, - onHistoryCallback, - onErrorCallback, - ) => { - console.log('HERE', resolution, symbolInfo); - - if (!symbolInfo.ticker) { - onErrorCallback('No symbol.ticker'); - return; - } - - try { - const candleId = resolutionMap[resolution](symbolInfo.ticker); - console.log(candleId); - const data = await request(`candle?candleId=${candleId}`); - - if (!data.candles.edges.length) { - onHistoryCallback([], { noData: true }); - return; - } - - const bars = data.candles.edges - .map((e: { node: Candle }) => { - return prepareBar(e.node); - }) - .reverse(); - - console.log(bars); - - onHistoryCallback(bars, { noData: false }); - } catch (err) { - console.log('Cannot getBars', err); - onErrorCallback(err as Error); - } - }, - - subscribeBars: ( - symbolInfo, - resolution, - onTick, - subscriberUID, - onResetCacheNeededCallback, - ) => { - console.log( - '[subscribeBars]: Method call with subscriberUID:', - subscriberUID, - ); - - if (!symbolInfo.ticker) { - console.error('No symbolInfo.ticker'); - return; - } - - const candleId = resolutionMap[resolution](symbolInfo.ticker); - - socket = new WebSocket(`${websocketUrl}?candleId=${candleId}`); - - socket.onopen = (event) => { - console.log('open', event); - }; - - socket.onmessage = (event) => { - console.log('message', event); - try { - // TODO: update or append to candle set - const data = JSON.parse(event.data); - const bar = prepareBar(data.result.candle); - onTick(bar); - } catch (err) { - console.error(err); - } - }; - - socket.onerror = (event) => { - console.log('error', event); - }; - - socket.onclose = (event) => { - console.log('close', event); - }; - }, - - unsubscribeBars: (subscriberUID) => { - console.log( - '[unsubscribeBars]: Method call with subscriberUID:', - subscriberUID, - ); - - if (socket) { - socket.close(); - } - }, - }; - - return feed; - }, []); - - useEffect(() => { - return () => { - if (socket) { - socket.close(); - } - }; - }, []); - - return datafeed; -}; - -const prepareBar = (candle: Candle) => { - return { - time: Number(candle.start.slice(0, -6)), // trim to milliseconds - low: Number(candle.low), - high: Number(candle.high), - open: Number(candle.open), - close: Number(candle.close), - volume: Number(candle.volume), - }; -}; - -const request = async (path: string) => { - try { - const res = await fetch(`${url}/${path}`); - const json = await res.json(); - return json; - console.log(json); - } catch (err) { - console.error(err); - } -}; diff --git a/libs/trading-view/src/lib/trading-view.tsx b/libs/trading-view/src/lib/trading-view.tsx index 63fd65c20..e1e125505 100644 --- a/libs/trading-view/src/lib/trading-view.tsx +++ b/libs/trading-view/src/lib/trading-view.tsx @@ -6,14 +6,14 @@ import { type ResolutionString, widget, } from '../charting_library'; -import { useDataFeed } from './datafeed'; +import { useDatafeed } from './use-datafeed'; export const TradingView = ({ marketId }: { marketId: string }) => { const { theme } = useThemeSwitcher(); const chartContainerRef = useRef() as React.MutableRefObject; - const datafeed = useDataFeed(marketId); + const datafeed = useDatafeed(); useEffect(() => { const widgetOptions: ChartingLibraryWidgetOptions = { @@ -43,26 +43,6 @@ export const TradingView = ({ marketId }: { marketId: string }) => { const tvWidget = new widget(widgetOptions); - // Add a custom button - tvWidget.onChartReady(() => { - tvWidget.headerReady().then(() => { - const button = tvWidget.createButton(); - button.setAttribute('title', 'Click to show a notification popup'); - button.classList.add('apply-common-tooltip'); - button.addEventListener('click', () => - tvWidget.showNoticeDialog({ - title: 'Notification', - body: 'TradingView Charting Library API works correctly', - callback: () => { - console.log('Noticed!'); - }, - }), - ); - - button.innerHTML = 'Check API'; - }); - }); - return () => { tvWidget.remove(); }; diff --git a/libs/trading-view/src/lib/use-datafeed.ts b/libs/trading-view/src/lib/use-datafeed.ts new file mode 100644 index 000000000..739fb5ac7 --- /dev/null +++ b/libs/trading-view/src/lib/use-datafeed.ts @@ -0,0 +1,250 @@ +import { useEffect, useMemo, useRef } from 'react'; +import compact from 'lodash/compact'; +import { useApolloClient } from '@apollo/client'; +import { type Subscription } from 'zen-observable-ts'; +import { + type LibrarySymbolInfo, + type IBasicDataFeed, + type ResolutionString, + type SeriesFormat, +} from '../charting_library/charting_library'; +import { + GetBarsDocument, + LastBarDocument, + type BarFragment, + type GetBarsQuery, + type GetBarsQueryVariables, + type LastBarSubscription, + type LastBarSubscriptionVariables, +} from './__generated__/Bars'; +import { Interval } from '@vegaprotocol/types'; +import { + SymbolDocument, + type SymbolQuery, + type SymbolQueryVariables, +} from './__generated__/Symbol'; +import { toBigNum } from '@vegaprotocol/utils'; + +const resolutionMap: Record = { + '1T': Interval.INTERVAL_BLOCK, + '1': Interval.INTERVAL_I1M, + '5': Interval.INTERVAL_I5M, + '15': Interval.INTERVAL_I15M, + '60': Interval.INTERVAL_I1H, + '360': Interval.INTERVAL_I6H, + '1D': Interval.INTERVAL_I1D, +} as const; + +const supportedResolutions = Object.keys(resolutionMap); + +const configurationData = { + // only showing Vega ofc + exchanges: undefined, + + // Represents the resolutions for bars supported by your datafeed + supported_resolutions: supportedResolutions as ResolutionString[], +} as const; + +export const useDatafeed = () => { + const subRef = useRef(); + const client = useApolloClient(); + + const datafeed = useMemo(() => { + const feed: IBasicDataFeed = { + onReady: (callback) => { + setTimeout(() => callback(configurationData)); + }, + searchSymbols: () => { + /* no op, we handle finding markets in app */ + }, + resolveSymbol: async ( + marketId, + onSymbolResolvedCallback, + onResolveErrorCallback + ) => { + try { + const result = await client.query({ + query: SymbolDocument, + variables: { + marketId, + }, + }); + + if (!result.data.market) { + onResolveErrorCallback('Cannot resolve symbol: market not found'); + return; + } + + const market = result.data.market; + const instrument = market.tradableInstrument.instrument; + const productType = instrument.product.__typename; + + if (!productType) { + onResolveErrorCallback( + 'Cannot resolve symbol: invalid product type' + ); + return; + } + + const symbolInfo: LibrarySymbolInfo = { + ticker: market.id, + name: instrument.code, + full_name: instrument.code, + listed_exchange: 'vega', + format: 'price' as SeriesFormat, + description: instrument.name, + type: 'futures', // TODO no hard code + session: '24x7', + timezone: 'Etc/UTC', + exchange: '', + minmov: 1, + pricescale: Number('1' + '0'.repeat(market.decimalPlaces)), // for number of decimal places + visible_plots_set: 'ohlc', + volume_precision: market.positionDecimalPlaces, + data_status: 'pulsed', + delay: 1000, + has_intraday: true, // required for less than 1 day interval + + // @ts-ignore required for data conversion + vegaDecimalPlaces: market.decimalPlaces, + // @ts-ignore required for data conversion + vegaPositionDecimalPlaces: market.positionDecimalPlaces, + }; + + onSymbolResolvedCallback(symbolInfo); + } catch (err) { + onResolveErrorCallback('Cannot resolve symbol'); + } + }, + getBars: async ( + symbolInfo, + resolution, + periodParams, + onHistoryCallback, + onErrorCallback + ) => { + if (!symbolInfo.ticker) { + onErrorCallback('No symbol.ticker'); + return; + } + + try { + const result = await client.query< + GetBarsQuery, + GetBarsQueryVariables + >({ + query: GetBarsDocument, + variables: { + marketId: symbolInfo.ticker, + since: unixTimestampToDate(periodParams.from).toISOString(), + to: unixTimestampToDate(periodParams.to).toISOString(), + interval: resolutionMap[resolution], + }, + }); + + const candleEdges = compact( + result.data.market?.candlesConnection?.edges + ); + + if (!candleEdges.length) { + onHistoryCallback([], { noData: true }); + return; + } + + const bars = candleEdges.map((edge) => { + return prepareBar( + edge.node, + // @ts-ignore added in resolveSymbol + symbolInfo.vegaDecimalPlaces, + // @ts-ignore added in resolveSymbol + symbolInfo.vegaPositionDecimalPlaces + ); + }); + + onHistoryCallback(bars, { noData: false }); + } catch (err) { + onErrorCallback( + err instanceof Error ? err.message : 'Failed to get bars' + ); + } + }, + + subscribeBars: ( + symbolInfo, + resolution, + onTick + + // subscriberUID, // chart will subscribe and unsbuscribe when the parent market of the page changes so we don't need to use subscriberUID as of now + + // TODO: figure out how/when we should use onResetCacheNeededCallback + // onResetCacheNeededCallback, + ) => { + if (!symbolInfo.ticker) { + throw new Error('No symbolInfo.ticker'); + } + + subRef.current = client + .subscribe({ + query: LastBarDocument, + variables: { + marketId: symbolInfo.ticker, + interval: resolutionMap[resolution], + }, + }) + .subscribe(({ data }) => { + if (data) { + const bar = prepareBar( + data.candles, + // @ts-ignore added in resolveSymbol + symbolInfo.vegaDecimalPlaces, + // @ts-ignore added in resolveSymbol + symbolInfo.vegaPositionDecimalPlaces + ); + + onTick(bar); + } + }); + }, + + /** + * We only have one active subscription no need to use the uid provided by unsubscribeBars + */ + unsubscribeBars: () => { + if (subRef.current) { + subRef.current.unsubscribe(); + } + }, + }; + + return feed; + }, [client]); + + useEffect(() => { + return () => { + if (subRef.current) { + subRef.current.unsubscribe(); + } + }; + }, []); + + return datafeed; +}; + +const prepareBar = ( + bar: BarFragment, + decimalPlaces: number, + positionDecimalPlaces: number +) => { + return { + time: new Date(bar.periodStart).getTime(), + low: toBigNum(bar.low, decimalPlaces).toNumber(), + high: toBigNum(bar.high, decimalPlaces).toNumber(), + open: toBigNum(bar.open, decimalPlaces).toNumber(), + close: toBigNum(bar.close, decimalPlaces).toNumber(), + volume: toBigNum(bar.volume, positionDecimalPlaces).toNumber(), + }; +}; + +const unixTimestampToDate = (timestamp: number) => { + return new Date(timestamp * 1000); +};