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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"extends": ["plugin:@nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*", "__generated__"],
"ignorePatterns": ["!**/*", "__generated__", "charting-library.d.ts"],
"overrides": [
{
"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,
marketId,
interval,
studies,
onIntervalChange,
onAutoSaveNeeded,
state,
}: {
libraryPath: string;
libraryHash: string;
marketId: string;
interval: ResolutionString;
studies: string[];
onIntervalChange: (interval: string) => void;
onAutoSaveNeeded: OnAutoSaveNeededCallback;
state: object | undefined;
}) => {
const t = useT();
const scriptState = useScript(
@ -48,9 +48,9 @@ export const TradingViewContainer = ({
libraryPath={libraryPath}
marketId={marketId}
interval={interval}
studies={studies}
onIntervalChange={onIntervalChange}
onAutoSaveNeeded={onAutoSaveNeeded}
state={state}
/>
);
};

View File

@ -1,64 +1,104 @@
import { useEffect, useRef } from 'react';
import {
usePrevious,
useScreenDimensions,
useThemeSwitcher,
} from '@vegaprotocol/react-helpers';
import { useLanguage } from './use-t';
import { useDatafeed } from './use-datafeed';
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 = ({
marketId,
libraryPath,
interval,
studies,
onIntervalChange,
onAutoSaveNeeded,
state,
}: {
marketId: string;
libraryPath: string;
interval: ResolutionString;
studies: string[];
onIntervalChange: (interval: string) => void;
onAutoSaveNeeded: OnAutoSaveNeededCallback;
state: object | undefined;
}) => {
const { isMobile } = useScreenDimensions();
const { theme } = useThemeSwitcher();
const language = useLanguage();
const chartContainerRef =
useRef<HTMLDivElement>() as React.MutableRefObject<HTMLInputElement>;
// Cant get types as charting_library is externally loaded
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const widgetRef = useRef<any>();
const chartContainerRef = useRef<HTMLDivElement>(null);
const widgetRef = useRef<IChartingLibraryWidget>();
const datafeed = useDatafeed();
useEffect(
() => {
const disableOnSmallScreens = isMobile ? ['left_toolbar'] : [];
const prevMarketId = usePrevious(marketId);
const prevTheme = usePrevious(theme);
useEffect(() => {
// 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
);
}
// Update theme theme if changed
if (theme !== prevTheme) {
widgetRef.current.changeTheme(theme).then(() => {
if (!widgetRef.current) return;
widgetRef.current.applyOverrides(getOverrides(theme));
});
}
return;
}
if (!chartContainerRef.current) {
return;
}
// Create widget
const overrides = getOverrides(theme);
const widgetOptions = {
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,
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],
locale: language.split('-')[0] as LanguageCode,
enabled_features: ['tick_resolution'],
disabled_features: [
'header_symbol_search',
'header_compare',
'show_object_tree',
'timeframes_toolbar',
...disableOnSmallScreens,
],
disabled_features: disabledFeatures,
fullscreen: false,
autosize: true,
theme,
@ -66,62 +106,62 @@ export const TradingView = ({
loading_screen: {
backgroundColor: overrides['paneProperties.background'],
},
auto_save_delay: 1,
saved_data: state,
};
// @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 });
});
if (!widgetRef.current) return;
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);
});
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);
});
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;
};
},
// 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(() => {
if (!widgetRef.current || !widgetRef.current._ready) return;
// Calling changeTheme will reset the default dark/light background to the TV default
// so we need to re-apply the pane bg override. A promise is also required
// https://github.com/tradingview/charting_library/issues/6546#issuecomment-1139517908
widgetRef.current.changeTheme(theme).then(() => {
widgetRef.current.applyOverrides(getOverrides(theme));
});
}, [theme]);
}, []);
return <div ref={chartContainerRef} className="w-full h-full" />;
};
const getOverrides = (theme: 'dark' | 'light') => {
const getOverrides = (
theme: 'dark' | 'light'
): Partial<ChartPropertiesOverrides> => {
return {
// colors set here, trading view lets the user set a color
'paneProperties.background': theme === 'dark' ? '#05060C' : '#fff',

View File

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

View File

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