chore(trading): store all chart state (#5627)

Co-authored-by: bwallacee <ben@vega.xyz>
This commit is contained in:
Matthew Russell 2024-01-24 05:21:33 -05:00 committed by GitHub
parent f22a3bc2d2
commit 0660eda334
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 19420 additions and 143 deletions

View File

@ -25,12 +25,12 @@ export const ChartContainer = ({ marketId }: { marketId: string }) => {
overlays, overlays,
studies, studies,
studySizes, studySizes,
tradingViewStudies,
setInterval, setInterval,
setStudies, setStudies,
setStudySizes, setStudySizes,
setOverlays, setOverlays,
setTradingViewStudies, state,
setState,
} = useChartSettings(); } = useChartSettings();
const pennantChart = ( const pennantChart = (
@ -64,13 +64,13 @@ export const ChartContainer = ({ marketId }: { marketId: string }) => {
libraryHash={CHARTING_LIBRARY_HASH} libraryHash={CHARTING_LIBRARY_HASH}
marketId={marketId} marketId={marketId}
interval={toTradingViewResolution(interval)} interval={toTradingViewResolution(interval)}
studies={tradingViewStudies}
onIntervalChange={(newInterval) => { onIntervalChange={(newInterval) => {
setInterval(fromTradingViewResolution(newInterval)); setInterval(fromTradingViewResolution(newInterval));
}} }}
onAutoSaveNeeded={(data: { studies: string[] }) => { onAutoSaveNeeded={(data) => {
setTradingViewStudies(data.studies); setState(data);
}} }}
state={state}
/> />
); );
} }

View File

@ -27,11 +27,11 @@ describe('ChartMenu', () => {
render(<ChartMenu />); render(<ChartMenu />);
await userEvent.click(screen.getByRole('button', { name: 'Vega chart' })); await userEvent.click(screen.getByTestId('chartlib-toggle-button'));
expect(useChartSettingsStore.getState().chartlib).toEqual('pennant');
await userEvent.click(screen.getByRole('button', { name: 'TradingView' }));
expect(useChartSettingsStore.getState().chartlib).toEqual('tradingview'); expect(useChartSettingsStore.getState().chartlib).toEqual('tradingview');
await userEvent.click(screen.getByTestId('chartlib-toggle-button'));
expect(useChartSettingsStore.getState().chartlib).toEqual('pennant');
}); });
describe('tradingview', () => { describe('tradingview', () => {

View File

@ -68,6 +68,7 @@ export const ChartMenu = () => {
setChartlib(isPennant ? 'tradingview' : 'pennant'); setChartlib(isPennant ? 'tradingview' : 'pennant');
}} }}
size="extra-small" size="extra-small"
testId="chartlib-toggle-button"
> >
{isPennant ? 'TradingView' : t('Vega chart')} {isPennant ? 'TradingView' : t('Vega chart')}
</TradingButton> </TradingButton>

View File

@ -9,6 +9,7 @@ type StudySizes = { [S in Study]?: number };
export type Chartlib = 'pennant' | 'tradingview'; export type Chartlib = 'pennant' | 'tradingview';
interface StoredSettings { interface StoredSettings {
state: object | undefined; // Don't see a better type provided from TradingView type definitions
chartlib: Chartlib; chartlib: Chartlib;
// For interval we use the enum from @vegaprotocol/types, this is to make mapping between different // For interval we use the enum from @vegaprotocol/types, this is to make mapping between different
// chart types easier and more consistent // chart types easier and more consistent
@ -17,7 +18,6 @@ interface StoredSettings {
overlays: Overlay[]; overlays: Overlay[];
studies: Study[]; studies: Study[];
studySizes: StudySizes; studySizes: StudySizes;
tradingViewStudies: string[];
} }
export const STUDY_SIZE = 90; export const STUDY_SIZE = 90;
@ -30,13 +30,13 @@ const STUDY_ORDER: Study[] = [
]; ];
export const DEFAULT_CHART_SETTINGS = { export const DEFAULT_CHART_SETTINGS = {
chartlib: 'tradingview' as const, state: undefined,
chartlib: 'pennant' as const,
interval: Interval.INTERVAL_I15M, interval: Interval.INTERVAL_I15M,
type: ChartType.CANDLE, type: ChartType.CANDLE,
overlays: [Overlay.MOVING_AVERAGE], overlays: [Overlay.MOVING_AVERAGE],
studies: [Study.MACD, Study.VOLUME], studies: [Study.MACD, Study.VOLUME],
studySizes: {}, studySizes: {},
tradingViewStudies: ['Volume'],
}; };
export const useChartSettingsStore = create< export const useChartSettingsStore = create<
@ -47,7 +47,7 @@ export const useChartSettingsStore = create<
setStudies: (studies?: Study[]) => void; setStudies: (studies?: Study[]) => void;
setStudySizes: (sizes: number[]) => void; setStudySizes: (sizes: number[]) => void;
setChartlib: (lib: Chartlib) => void; setChartlib: (lib: Chartlib) => void;
setTradingViewStudies: (studies: string[]) => void; setState: (state: object) => void;
} }
>()( >()(
persist( persist(
@ -95,10 +95,8 @@ export const useChartSettingsStore = create<
state.chartlib = lib; state.chartlib = lib;
}); });
}, },
setTradingViewStudies: (studies: string[]) => { setState: (state) => {
set((state) => { set({ state });
state.tradingViewStudies = studies;
});
}, },
})), })),
{ {
@ -147,13 +145,13 @@ export const useChartSettings = () => {
overlays, overlays,
studies, studies,
studySizes, studySizes,
tradingViewStudies: settings.tradingViewStudies,
setInterval: settings.setInterval, setInterval: settings.setInterval,
setType: settings.setType, setType: settings.setType,
setStudies: settings.setStudies, setStudies: settings.setStudies,
setOverlays: settings.setOverlays, setOverlays: settings.setOverlays,
setStudySizes: settings.setStudySizes, setStudySizes: settings.setStudySizes,
setChartlib: settings.setChartlib, setChartlib: settings.setChartlib,
setTradingViewStudies: settings.setTradingViewStudies, state: settings.state,
setState: settings.setState,
}; };
}; };

View File

@ -1,6 +1,6 @@
{ {
"extends": ["plugin:@nx/react", "../../.eslintrc.json"], "extends": ["plugin:@nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*", "__generated__"], "ignorePatterns": ["!**/*", "__generated__", "charting-library.d.ts"],
"overrides": [ "overrides": [
{ {
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],

19258
libs/trading-view/src/charting-library.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,17 +9,17 @@ export const TradingViewContainer = ({
libraryHash, libraryHash,
marketId, marketId,
interval, interval,
studies,
onIntervalChange, onIntervalChange,
onAutoSaveNeeded, onAutoSaveNeeded,
state,
}: { }: {
libraryPath: string; libraryPath: string;
libraryHash: string; libraryHash: string;
marketId: string; marketId: string;
interval: ResolutionString; interval: ResolutionString;
studies: string[];
onIntervalChange: (interval: string) => void; onIntervalChange: (interval: string) => void;
onAutoSaveNeeded: OnAutoSaveNeededCallback; onAutoSaveNeeded: OnAutoSaveNeededCallback;
state: object | undefined;
}) => { }) => {
const t = useT(); const t = useT();
const scriptState = useScript( const scriptState = useScript(
@ -48,9 +48,9 @@ export const TradingViewContainer = ({
libraryPath={libraryPath} libraryPath={libraryPath}
marketId={marketId} marketId={marketId}
interval={interval} interval={interval}
studies={studies}
onIntervalChange={onIntervalChange} onIntervalChange={onIntervalChange}
onAutoSaveNeeded={onAutoSaveNeeded} onAutoSaveNeeded={onAutoSaveNeeded}
state={state}
/> />
); );
}; };

View File

@ -1,127 +1,167 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { import {
usePrevious,
useScreenDimensions, useScreenDimensions,
useThemeSwitcher, useThemeSwitcher,
} from '@vegaprotocol/react-helpers'; } from '@vegaprotocol/react-helpers';
import { useLanguage } from './use-t'; import { useLanguage } from './use-t';
import { useDatafeed } from './use-datafeed'; import { useDatafeed } from './use-datafeed';
import { type ResolutionString } from './constants'; import { type ResolutionString } from './constants';
import {
type ChartingLibraryFeatureset,
type LanguageCode,
type ChartingLibraryWidgetOptions,
type IChartingLibraryWidget,
type ChartPropertiesOverrides,
type ResolutionString as TVResolutionString,
} from '../charting-library';
export type OnAutoSaveNeededCallback = (data: { studies: string[] }) => void; const noop = () => {};
export type OnAutoSaveNeededCallback = (data: object) => void;
export const TradingView = ({ export const TradingView = ({
marketId, marketId,
libraryPath, libraryPath,
interval, interval,
studies,
onIntervalChange, onIntervalChange,
onAutoSaveNeeded, onAutoSaveNeeded,
state,
}: { }: {
marketId: string; marketId: string;
libraryPath: string; libraryPath: string;
interval: ResolutionString; interval: ResolutionString;
studies: string[];
onIntervalChange: (interval: string) => void; onIntervalChange: (interval: string) => void;
onAutoSaveNeeded: OnAutoSaveNeededCallback; onAutoSaveNeeded: OnAutoSaveNeededCallback;
state: object | undefined;
}) => { }) => {
const { isMobile } = useScreenDimensions(); const { isMobile } = useScreenDimensions();
const { theme } = useThemeSwitcher(); const { theme } = useThemeSwitcher();
const language = useLanguage(); const language = useLanguage();
const chartContainerRef = const chartContainerRef = useRef<HTMLDivElement>(null);
useRef<HTMLDivElement>() as React.MutableRefObject<HTMLInputElement>; const widgetRef = useRef<IChartingLibraryWidget>();
// Cant get types as charting_library is externally loaded
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const widgetRef = useRef<any>();
const datafeed = useDatafeed(); const datafeed = useDatafeed();
useEffect( const prevMarketId = usePrevious(marketId);
() => { const prevTheme = usePrevious(theme);
const disableOnSmallScreens = isMobile ? ['left_toolbar'] : [];
const overrides = getOverrides(theme);
const widgetOptions = {
symbol: marketId,
datafeed,
interval: interval,
container: chartContainerRef.current,
library_path: libraryPath,
custom_css_url: 'vega_styles.css',
// Trading view accepts just 'en' rather than 'en-US' which is what react-i18next provides
// https://www.tradingview.com/charting-library-docs/latest/core_concepts/Localization?_highlight=language#supported-languages
locale: language.split('-')[0],
enabled_features: ['tick_resolution'],
disabled_features: [
'header_symbol_search',
'header_compare',
'show_object_tree',
'timeframes_toolbar',
...disableOnSmallScreens,
],
fullscreen: false,
autosize: true,
theme,
overrides,
loading_screen: {
backgroundColor: overrides['paneProperties.background'],
},
};
// @ts-ignore parent component loads TradingView onto window obj
widgetRef.current = new window.TradingView.widget(widgetOptions);
widgetRef.current.onChartReady(() => {
widgetRef.current.applyOverrides(getOverrides(theme));
widgetRef.current.subscribe('onAutoSaveNeeded', () => {
const studies = widgetRef.current
.activeChart()
.getAllStudies()
.map((s: { id: string; name: string }) => s.name);
onAutoSaveNeeded({ studies });
});
const activeChart = widgetRef.current.activeChart();
// Show volume study by default, second bool arg adds it as a overlay on top of the chart
studies.forEach((study) => {
activeChart.createStudy(study);
});
// Subscribe to interval changes so it can be persisted in chart settings
activeChart.onIntervalChanged().subscribe(null, onIntervalChange);
});
return () => {
if (!widgetRef.current) return;
widgetRef.current.remove();
};
},
// No theme in deps to avoid full chart reload when the theme changes
// Instead the theme is changed programmatically in a separate useEffect
// eslint-disable-next-line react-hooks/exhaustive-deps
[datafeed, marketId, language, libraryPath, isMobile]
);
// Update the trading view theme every time the app theme updates, done separately
// to avoid full chart reload
useEffect(() => { useEffect(() => {
if (!widgetRef.current || !widgetRef.current._ready) return; // Widget already created
if (widgetRef.current !== undefined) {
// Update the symbol if changed
if (marketId !== prevMarketId) {
widgetRef.current.setSymbol(
marketId,
(interval ? interval : '15') as TVResolutionString,
noop
);
}
// Calling changeTheme will reset the default dark/light background to the TV default // Update theme theme if changed
// so we need to re-apply the pane bg override. A promise is also required if (theme !== prevTheme) {
// https://github.com/tradingview/charting_library/issues/6546#issuecomment-1139517908 widgetRef.current.changeTheme(theme).then(() => {
widgetRef.current.changeTheme(theme).then(() => { if (!widgetRef.current) return;
widgetRef.current.applyOverrides(getOverrides(theme)); widgetRef.current.applyOverrides(getOverrides(theme));
});
}
return;
}
if (!chartContainerRef.current) {
return;
}
// Create widget
const overrides = getOverrides(theme);
const disabledOnSmallScreens: ChartingLibraryFeatureset[] = isMobile
? ['left_toolbar']
: [];
const disabledFeatures: ChartingLibraryFeatureset[] = [
'header_symbol_search',
'header_compare',
'show_object_tree',
'timeframes_toolbar',
...disabledOnSmallScreens,
];
const widgetOptions: ChartingLibraryWidgetOptions = {
symbol: marketId,
datafeed,
interval: interval as TVResolutionString,
container: chartContainerRef.current,
library_path: libraryPath,
custom_css_url: 'vega_styles.css',
// Trading view accepts just 'en' rather than 'en-US' which is what react-i18next provides
// https://www.tradingview.com/charting-library-docs/latest/core_concepts/Localization?_highlight=language#supported-languages
locale: language.split('-')[0] as LanguageCode,
enabled_features: ['tick_resolution'],
disabled_features: disabledFeatures,
fullscreen: false,
autosize: true,
theme,
overrides,
loading_screen: {
backgroundColor: overrides['paneProperties.background'],
},
auto_save_delay: 1,
saved_data: state,
};
widgetRef.current = new window.TradingView.widget(widgetOptions);
widgetRef.current.onChartReady(() => {
if (!widgetRef.current) return;
const activeChart = widgetRef.current.activeChart();
if (!state) {
// If chart has loaded with no state, create a volume study
activeChart.createStudy('Volume');
}
// Subscribe to interval changes so it can be persisted in chart settings
activeChart.onIntervalChanged().subscribe(null, onIntervalChange);
}); });
}, [theme]);
widgetRef.current.subscribe('onAutoSaveNeeded', () => {
if (!widgetRef.current) return;
widgetRef.current.save((newState) => {
onAutoSaveNeeded(newState);
});
});
}, [
state,
datafeed,
interval,
prevTheme,
prevMarketId,
marketId,
theme,
language,
libraryPath,
isMobile,
onAutoSaveNeeded,
onIntervalChange,
]);
useEffect(() => {
return () => {
if (!widgetRef.current) return;
widgetRef.current.remove();
widgetRef.current = undefined;
};
}, []);
return <div ref={chartContainerRef} className="w-full h-full" />; return <div ref={chartContainerRef} className="w-full h-full" />;
}; };
const getOverrides = (theme: 'dark' | 'light') => { const getOverrides = (
theme: 'dark' | 'light'
): Partial<ChartPropertiesOverrides> => {
return { return {
// colors set here, trading view lets the user set a color // colors set here, trading view lets the user set a color
'paneProperties.background': theme === 'dark' ? '#05060C' : '#fff', 'paneProperties.background': theme === 'dark' ? '#05060C' : '#fff',

View File

@ -2,15 +2,6 @@ import { useEffect, useMemo, useRef } from 'react';
import compact from 'lodash/compact'; import compact from 'lodash/compact';
import { useApolloClient } from '@apollo/client'; import { useApolloClient } from '@apollo/client';
import { type Subscription } from 'zen-observable-ts'; import { type Subscription } from 'zen-observable-ts';
/*
* TODO: figure out how we can get the chart types
import {
type LibrarySymbolInfo,
type IBasicDataFeed,
type ResolutionString,
type SeriesFormat,
} from '../charting_library/charting_library';
*/
import { import {
GetBarsDocument, GetBarsDocument,
LastBarDocument, LastBarDocument,
@ -27,6 +18,12 @@ import {
type SymbolQueryVariables, type SymbolQueryVariables,
} from './__generated__/Symbol'; } from './__generated__/Symbol';
import { getMarketExpiryDate, toBigNum } from '@vegaprotocol/utils'; import { getMarketExpiryDate, toBigNum } from '@vegaprotocol/utils';
import {
type IBasicDataFeed,
type DatafeedConfiguration,
type LibrarySymbolInfo,
type ResolutionString,
} from '../charting-library';
const EXCHANGE = 'VEGA'; const EXCHANGE = 'VEGA';
@ -42,12 +39,8 @@ const resolutionMap: Record<string, Interval> = {
const supportedResolutions = Object.keys(resolutionMap); const supportedResolutions = Object.keys(resolutionMap);
const configurationData = { const configurationData: DatafeedConfiguration = {
// only showing Vega ofc
exchanges: [EXCHANGE],
// Represents the resolutions for bars supported by your datafeed // Represents the resolutions for bars supported by your datafeed
// @ts-ignore cant import types as chartin_library is external
supported_resolutions: supportedResolutions as ResolutionString[], supported_resolutions: supportedResolutions as ResolutionString[],
} as const; } as const;
@ -57,9 +50,7 @@ export const useDatafeed = () => {
const client = useApolloClient(); const client = useApolloClient();
const datafeed = useMemo(() => { const datafeed = useMemo(() => {
// @ts-ignore cant import types as chartin_library is external
const feed: IBasicDataFeed = { const feed: IBasicDataFeed = {
// @ts-ignore cant import types as chartin_library is external
onReady: (callback) => { onReady: (callback) => {
setTimeout(() => callback(configurationData)); setTimeout(() => callback(configurationData));
}, },
@ -69,11 +60,8 @@ export const useDatafeed = () => {
}, },
resolveSymbol: async ( resolveSymbol: async (
// @ts-ignore cant import types as chartin_library is external
marketId, marketId,
// @ts-ignore cant import types as chartin_library is external
onSymbolResolvedCallback, onSymbolResolvedCallback,
// @ts-ignore cant import types as chartin_library is external
onResolveErrorCallback onResolveErrorCallback
) => { ) => {
try { try {
@ -110,9 +98,8 @@ export const useDatafeed = () => {
const expirationDate = getMarketExpiryDate(instrument.metadata.tags); const expirationDate = getMarketExpiryDate(instrument.metadata.tags);
const expirationTimestamp = expirationDate const expirationTimestamp = expirationDate
? Math.floor(expirationDate.getTime() / 1000) ? Math.floor(expirationDate.getTime() / 1000)
: null; : undefined;
// @ts-ignore cant import types as chartin_library is external
const symbolInfo: LibrarySymbolInfo = { const symbolInfo: LibrarySymbolInfo = {
ticker: market.id, // use ticker as our unique identifier so that code/name can be used for name/description ticker: market.id, // use ticker as our unique identifier so that code/name can be used for name/description
name: instrument.code, name: instrument.code,
@ -120,10 +107,9 @@ export const useDatafeed = () => {
description: instrument.name, description: instrument.name,
listed_exchange: EXCHANGE, listed_exchange: EXCHANGE,
expired: productType === 'Perpetual' ? false : true, expired: productType === 'Perpetual' ? false : true,
expirationDate: expirationTimestamp, expiration_date: expirationTimestamp,
// @ts-ignore cant import types as chartin_library is external format: 'price',
format: 'price' as SeriesFormat,
type, type,
session: '24x7', session: '24x7',
timezone: 'Etc/UTC', timezone: 'Etc/UTC',
@ -151,15 +137,10 @@ export const useDatafeed = () => {
}, },
getBars: async ( getBars: async (
// @ts-ignore cant import types as chartin_library is external
symbolInfo, symbolInfo,
// @ts-ignore cant import types as chartin_library is external
resolution, resolution,
// @ts-ignore cant import types as chartin_library is external
periodParams, periodParams,
// @ts-ignore cant import types as chartin_library is external
onHistoryCallback, onHistoryCallback,
// @ts-ignore cant import types as chartin_library is external
onErrorCallback onErrorCallback
) => { ) => {
if (!symbolInfo.ticker) { if (!symbolInfo.ticker) {
@ -211,13 +192,9 @@ export const useDatafeed = () => {
}, },
subscribeBars: ( subscribeBars: (
// @ts-ignore cant import types as chartin_library is external
symbolInfo, symbolInfo,
// @ts-ignore cant import types as chartin_library is external
resolution, resolution,
// @ts-ignore cant import types as chartin_library is external
onTick 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 // 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
) => { ) => {
if (!symbolInfo.ticker) { if (!symbolInfo.ticker) {

View File

@ -16,6 +16,7 @@ type TradingButtonProps = {
subLabel?: ReactNode; subLabel?: ReactNode;
fill?: boolean; fill?: boolean;
minimal?: boolean; minimal?: boolean;
testId?: string;
}; };
const getClassName = ( const getClassName = (
@ -120,6 +121,7 @@ export const TradingButton = forwardRef<
className, className,
subLabel, subLabel,
fill, fill,
testId,
...props ...props
}, },
ref ref
@ -132,6 +134,7 @@ export const TradingButton = forwardRef<
{ size, subLabel, intent, fill, minimal }, { size, subLabel, intent, fill, minimal },
className className
)} )}
data-testid={testId}
{...props} {...props}
> >
<Content icon={icon} subLabel={subLabel} children={children} /> <Content icon={icon} subLabel={subLabel} children={children} />