feat(trading): trading view (#5348)

This commit is contained in:
Matthew Russell 2023-12-12 17:33:41 -08:00 committed by GitHub
parent 0796f2b31f
commit f178b85846
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 1690 additions and 176 deletions

View File

@ -25,3 +25,6 @@ NX_ICEBERG_ORDERS=true
# NX_PRODUCT_PERPETUALS # NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=true NX_METAMASK_SNAPS=true
NX_REFERRALS=true NX_REFERRALS=true
NX_CHARTING_LIBRARY_PATH=https://assets.vega.community/trading-view-bundle/v0.0.1/
NX_CHARTING_LIBRARY_HASH=PDjWaqPFndDp+LCvqbKvntWriaqNzNpZ5i9R/BULzCg=

View File

@ -28,3 +28,6 @@ NX_REFERRALS=true
NX_TENDERMINT_URL=https://tm.be.testnet.vega.xyz NX_TENDERMINT_URL=https://tm.be.testnet.vega.xyz
NX_TENDERMINT_WEBSOCKET_URL=wss://be.testnet.vega.xyz/websocket NX_TENDERMINT_WEBSOCKET_URL=wss://be.testnet.vega.xyz/websocket
NX_CHARTING_LIBRARY_PATH=https://assets.vega.community/trading-view-bundle/v0.0.1/
NX_CHARTING_LIBRARY_HASH=PDjWaqPFndDp+LCvqbKvntWriaqNzNpZ5i9R/BULzCg=

View File

@ -62,10 +62,10 @@ const MainGrid = memo(
id="chart" id="chart"
overflowHidden overflowHidden
name={t('Chart')} name={t('Chart')}
menu={<TradingViews.candles.menu />} menu={<TradingViews.chart.menu />}
> >
<ErrorBoundary feature="chart"> <ErrorBoundary feature="chart">
<TradingViews.candles.component marketId={marketId} /> <TradingViews.chart.component marketId={marketId} />
</ErrorBoundary> </ErrorBoundary>
</Tab> </Tab>
<Tab id="depth" name={t('Depth')}> <Tab id="depth" name={t('Depth')}>

View File

@ -23,7 +23,7 @@ interface TradePanelsProps {
export const TradePanels = ({ market, pinnedAsset }: TradePanelsProps) => { export const TradePanels = ({ market, pinnedAsset }: TradePanelsProps) => {
const featureFlags = useFeatureFlags((state) => state.flags); const featureFlags = useFeatureFlags((state) => state.flags);
const [view, setView] = useState<TradingView>('candles'); const [view, setView] = useState<TradingView>('chart');
const renderView = () => { const renderView = () => {
const Component = TradingViews[view].component; const Component = TradingViews[view].component;
@ -50,7 +50,7 @@ export const TradePanels = ({ market, pinnedAsset }: TradePanelsProps) => {
const Menu = viewCfg.menu; const Menu = viewCfg.menu;
return ( return (
<div className="flex gap-1 p-1 bg-vega-clight-800 dark:bg-vega-cdark-800 border-b border-default"> <div className="flex items-center justify-end gap-1 p-1 bg-vega-clight-800 dark:bg-vega-cdark-800 border-b border-default">
<Menu /> <Menu />
</div> </div>
); );
@ -149,7 +149,7 @@ const useViewLabel = (view: TradingView) => {
const t = useT(); const t = useT();
const labels = { const labels = {
candles: t('Candles'), chart: t('Chart'),
depth: t('Depth'), depth: t('Depth'),
liquidity: t('Liquidity'), liquidity: t('Liquidity'),
funding: t('Funding'), funding: t('Funding'),

View File

@ -1,8 +1,4 @@
import { DepthChartContainer } from '@vegaprotocol/market-depth'; import { DepthChartContainer } from '@vegaprotocol/market-depth';
import {
CandlesChartContainer,
CandlesMenu,
} from '@vegaprotocol/candles-chart';
import { Filter, OpenOrdersMenu } from '@vegaprotocol/orders'; import { Filter, OpenOrdersMenu } from '@vegaprotocol/orders';
import { TradesContainer } from '../../components/trades-container'; import { TradesContainer } from '../../components/trades-container';
import { OrderbookContainer } from '../../components/orderbook-container'; import { OrderbookContainer } from '../../components/orderbook-container';
@ -16,13 +12,14 @@ import { OrdersContainer } from '../../components/orders-container';
import { StopOrdersContainer } from '../../components/stop-orders-container'; import { StopOrdersContainer } from '../../components/stop-orders-container';
import { AccountsMenu } from '../../components/accounts-menu'; import { AccountsMenu } from '../../components/accounts-menu';
import { PositionsMenu } from '../../components/positions-menu'; import { PositionsMenu } from '../../components/positions-menu';
import { ChartContainer, ChartMenu } from '../../components/chart-container';
export type TradingView = keyof typeof TradingViews; export type TradingView = keyof typeof TradingViews;
export const TradingViews = { export const TradingViews = {
candles: { chart: {
component: CandlesChartContainer, component: ChartContainer,
menu: CandlesMenu, menu: ChartMenu,
}, },
depth: { depth: {
component: DepthChartContainer, component: DepthChartContainer,

View File

@ -0,0 +1,66 @@
import { render, screen } from '@testing-library/react';
import { ChartContainer } from './chart-container';
import { useChartSettingsStore } from './use-chart-settings';
import { useEnvironment } from '@vegaprotocol/environment';
jest.mock('@vegaprotocol/candles-chart', () => ({
...jest.requireActual('@vegaprotocol/candles-chart'),
CandlesChartContainer: ({ marketId }: { marketId: string }) => (
<div data-testid="pennant">{marketId}</div>
),
}));
jest.mock('@vegaprotocol/trading-view', () => ({
...jest.requireActual('@vegaprotocol/trading-view'),
TradingViewContainer: ({ marketId }: { marketId: string }) => (
<div data-testid="tradingview">{marketId}</div>
),
}));
describe('ChartContainer', () => {
it('renders pennant if no library path is set', () => {
useChartSettingsStore.setState({
chartlib: 'tradingview',
});
useEnvironment.setState({
CHARTING_LIBRARY_PATH: undefined,
CHARTING_LIBRARY_HASH: undefined,
});
const marketId = 'market-id';
render(<ChartContainer marketId={marketId} />);
expect(screen.getByTestId('pennant')).toHaveTextContent(marketId);
});
it('renders trading view if library path is set', () => {
useChartSettingsStore.setState({
chartlib: 'tradingview',
});
useEnvironment.setState({
CHARTING_LIBRARY_PATH: 'dummy-path',
CHARTING_LIBRARY_HASH: 'hash',
});
const marketId = 'market-id';
render(<ChartContainer marketId={marketId} />);
expect(screen.getByTestId('tradingview')).toHaveTextContent(marketId);
});
it('renders pennant chart if stored in settings', () => {
useChartSettingsStore.setState({
chartlib: 'pennant',
});
const marketId = 'market-id';
render(<ChartContainer marketId={marketId} />);
expect(screen.getByTestId('pennant')).toHaveTextContent(marketId);
});
});

View File

@ -0,0 +1,120 @@
import invert from 'lodash/invert';
import { type Interval } from '@vegaprotocol/types';
import {
TradingViewContainer,
ALLOWED_TRADINGVIEW_HOSTNAMES,
TRADINGVIEW_INTERVAL_MAP,
} from '@vegaprotocol/trading-view';
import {
CandlesChartContainer,
PENNANT_INTERVAL_MAP,
} from '@vegaprotocol/candles-chart';
import { useEnvironment } from '@vegaprotocol/environment';
import { useChartSettings, STUDY_SIZE } from './use-chart-settings';
/**
* Renders either the pennant chart or the tradingview chart
*/
export const ChartContainer = ({ marketId }: { marketId: string }) => {
const { CHARTING_LIBRARY_PATH, CHARTING_LIBRARY_HASH } = useEnvironment();
const {
chartlib,
interval,
chartType,
overlays,
studies,
studySizes,
tradingViewStudies,
setInterval,
setStudies,
setStudySizes,
setOverlays,
setTradingViewStudies,
} = useChartSettings();
const pennantChart = (
<CandlesChartContainer
marketId={marketId}
interval={toPennantInterval(interval)}
chartType={chartType}
overlays={overlays}
studies={studies}
studySizes={studySizes}
setStudySizes={setStudySizes}
setStudies={setStudies}
setOverlays={setOverlays}
defaultStudySize={STUDY_SIZE}
/>
);
if (!ALLOWED_TRADINGVIEW_HOSTNAMES.includes(window.location.hostname)) {
return pennantChart;
}
if (!CHARTING_LIBRARY_PATH || !CHARTING_LIBRARY_HASH) {
return pennantChart;
}
switch (chartlib) {
case 'tradingview': {
return (
<TradingViewContainer
libraryPath={CHARTING_LIBRARY_PATH}
libraryHash={CHARTING_LIBRARY_HASH}
marketId={marketId}
interval={toTradingViewResolution(interval)}
studies={tradingViewStudies}
onIntervalChange={(newInterval) => {
setInterval(fromTradingViewResolution(newInterval));
}}
onAutoSaveNeeded={(data: { studies: string[] }) => {
setTradingViewStudies(data.studies);
}}
/>
);
}
case 'pennant': {
return pennantChart;
}
default: {
throw new Error('invalid chart lib');
}
}
};
const toTradingViewResolution = (interval: Interval) => {
const resolution = TRADINGVIEW_INTERVAL_MAP[interval];
if (!resolution) {
throw new Error(
`failed to convert interval: ${interval} to valid resolution`
);
}
return resolution;
};
const fromTradingViewResolution = (resolution: string) => {
const interval = invert(TRADINGVIEW_INTERVAL_MAP)[resolution];
if (!interval) {
throw new Error(
`failed to convert resolution: ${resolution} to valid interval`
);
}
return interval as Interval;
};
const toPennantInterval = (interval: Interval) => {
const pennantInterval = PENNANT_INTERVAL_MAP[interval];
if (!pennantInterval) {
throw new Error(
`failed to convert interval: ${interval} to valid pennant interval`
);
}
return pennantInterval;
};

View File

@ -0,0 +1,140 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ChartMenu } from './chart-menu';
import {
useChartSettingsStore,
DEFAULT_CHART_SETTINGS,
} from './use-chart-settings';
import { Overlay, Study, overlayLabels, studyLabels } from 'pennant';
import { useEnvironment } from '@vegaprotocol/environment';
describe('ChartMenu', () => {
it('doesnt show trading view option if library path undefined', () => {
useEnvironment.setState({ CHARTING_LIBRARY_PATH: undefined });
render(<ChartMenu />);
expect(
screen.queryByRole('button', { name: 'TradingView' })
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Vega chart' })
).not.toBeInTheDocument();
});
it('can switch between charts if library path', async () => {
useEnvironment.setState({ CHARTING_LIBRARY_PATH: 'dummy' });
render(<ChartMenu />);
await userEvent.click(screen.getByRole('button', { name: 'TradingView' }));
expect(useChartSettingsStore.getState().chartlib).toEqual('tradingview');
await userEvent.click(screen.getByRole('button', { name: 'Vega chart' }));
expect(useChartSettingsStore.getState().chartlib).toEqual('pennant');
});
describe('tradingview', () => {
beforeEach(() => {
useEnvironment.setState({ CHARTING_LIBRARY_PATH: 'dummy-path' });
// clear store each time to avoid conditional testing of defaults
useChartSettingsStore.setState({
chartlib: 'tradingview',
});
});
it('only shows chartlib switch and attribution', () => {
render(<ChartMenu />);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveTextContent('Vega chart');
expect(screen.getByText('Chart by')).toBeInTheDocument();
});
});
describe('pennant', () => {
const openDropdown = async () => {
await userEvent.click(
screen.getByRole('button', {
name: 'Indicators',
})
);
};
beforeEach(() => {
// clear store each time to avoid conditional testing of defaults
useChartSettingsStore.setState({
chartlib: 'pennant',
overlays: [],
studies: [],
});
});
it.each(Object.values(Overlay))('can set %s overlay', async (overlay) => {
render(<ChartMenu />);
await openDropdown();
const menu = within(await screen.findByRole('menu'));
await userEvent.click(menu.getByText(overlayLabels[overlay as Overlay]));
// re-open the dropdown
await openDropdown();
expect(
screen.getByText(overlayLabels[overlay as Overlay])
).toHaveAttribute('data-state', 'checked');
});
it.each(Object.values(Study))('can set %s study', async (study) => {
render(<ChartMenu />);
await openDropdown();
const menu = within(await screen.findByRole('menu'));
await userEvent.click(menu.getByText(studyLabels[study as Study]));
// re-open the dropdown
await openDropdown();
expect(screen.getByText(studyLabels[study as Study])).toHaveAttribute(
'data-state',
'checked'
);
});
it('should render with the correct default studies and overlays', async () => {
useChartSettingsStore.setState({
...DEFAULT_CHART_SETTINGS,
chartlib: 'pennant',
});
render(<ChartMenu />);
await userEvent.click(
screen.getByRole('button', {
name: 'Indicators',
})
);
const menu = within(await screen.findByRole('menu'));
expect(menu.getByText(studyLabels.volume)).toHaveAttribute(
'data-state',
'checked'
);
expect(menu.getByText(studyLabels.macd)).toHaveAttribute(
'data-state',
'checked'
);
expect(menu.getByText(overlayLabels.movingAverage)).toHaveAttribute(
'data-state',
'checked'
);
});
});
});

View File

@ -1,14 +1,12 @@
import 'pennant/dist/style.css';
import { import {
ChartType, ChartType,
Interval,
Overlay, Overlay,
Study, Study,
chartTypeLabels, chartTypeLabels,
intervalLabels,
overlayLabels, overlayLabels,
studyLabels, studyLabels,
} from 'pennant'; } from 'pennant';
import { Trans } from 'react-i18next';
import { import {
TradingButton, TradingButton,
TradingDropdown, TradingDropdown,
@ -20,10 +18,21 @@ import {
TradingDropdownTrigger, TradingDropdownTrigger,
Icon, Icon,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import { type IconName } from '@blueprintjs/icons'; import { Interval } from '@vegaprotocol/types';
import { IconNames } from '@blueprintjs/icons'; import { useEnvironment } from '@vegaprotocol/environment';
import { useCandlesChartSettings } from './use-candles-chart-settings'; import { ALLOWED_TRADINGVIEW_HOSTNAMES } from '@vegaprotocol/trading-view';
import { useT } from './use-t'; import { IconNames, type IconName } from '@blueprintjs/icons';
import { useChartSettings } from './use-chart-settings';
import { useT } from '../../lib/use-t';
const INTERVALS = [
Interval.INTERVAL_I1M,
Interval.INTERVAL_I5M,
Interval.INTERVAL_I15M,
Interval.INTERVAL_I1H,
Interval.INTERVAL_I6H,
Interval.INTERVAL_I1D,
];
const chartTypeIcon = new Map<ChartType, IconName>([ const chartTypeIcon = new Map<ChartType, IconName>([
[ChartType.AREA, IconNames.TIMELINE_AREA_CHART], [ChartType.AREA, IconNames.TIMELINE_AREA_CHART],
@ -32,30 +41,46 @@ const chartTypeIcon = new Map<ChartType, IconName>([
[ChartType.OHLC, IconNames.WATERFALL_CHART], [ChartType.OHLC, IconNames.WATERFALL_CHART],
]); ]);
export const CandlesMenu = () => { export const ChartMenu = () => {
const { CHARTING_LIBRARY_PATH } = useEnvironment();
const { const {
chartlib,
interval, interval,
chartType, chartType,
studies, studies,
overlays, overlays,
setChartlib,
setInterval, setInterval,
setType, setType,
setStudies, setStudies,
setOverlays, setOverlays,
} = useCandlesChartSettings(); } = useChartSettings();
const t = useT(); const t = useT();
const triggerClasses = 'text-xs';
const contentAlign = 'end'; const contentAlign = 'end';
const triggerClasses = 'text-xs';
const triggerButtonProps = { size: 'extra-small' } as const; const triggerButtonProps = { size: 'extra-small' } as const;
return ( const isPennant = chartlib === 'pennant';
const commonMenuItems = (
<TradingButton
onClick={() => {
setChartlib(isPennant ? 'tradingview' : 'pennant');
}}
size="extra-small"
>
{isPennant ? 'TradingView' : t('Vega chart')}
</TradingButton>
);
const pennantMenuItems = (
<> <>
<TradingDropdown <TradingDropdown
trigger={ trigger={
<TradingDropdownTrigger className={triggerClasses}> <TradingDropdownTrigger className={triggerClasses}>
<TradingButton {...triggerButtonProps}> <TradingButton {...triggerButtonProps}>
{t('Interval: {{interval}}', { {t('Interval: {{interval}}', {
interval: intervalLabels[interval], interval: t(interval),
})} })}
</TradingButton> </TradingButton>
</TradingDropdownTrigger> </TradingDropdownTrigger>
@ -68,13 +93,13 @@ export const CandlesMenu = () => {
setInterval(value as Interval); setInterval(value as Interval);
}} }}
> >
{Object.values(Interval).map((timeInterval) => ( {INTERVALS.map((timeInterval) => (
<TradingDropdownRadioItem <TradingDropdownRadioItem
key={timeInterval} key={timeInterval}
inset inset
value={timeInterval} value={timeInterval}
> >
{intervalLabels[timeInterval]} {t(timeInterval)}
<TradingDropdownItemIndicator /> <TradingDropdownItemIndicator />
</TradingDropdownRadioItem> </TradingDropdownRadioItem>
))} ))}
@ -158,4 +183,50 @@ export const CandlesMenu = () => {
</TradingDropdown> </TradingDropdown>
</> </>
); );
const tradingViewMenuItems = (
<p className="text-xs mr-2 whitespace-nowrap">
<Trans
i18nKey="Chart by <0>TradingView</0>"
components={[
// eslint-disable-next-line
<a
className="underline"
target="_blank"
href="https://www.tradingview.com"
/>,
]}
/>
</p>
);
if (!ALLOWED_TRADINGVIEW_HOSTNAMES.includes(window.location.hostname)) {
return pennantMenuItems;
}
if (!CHARTING_LIBRARY_PATH) {
return pennantMenuItems;
}
switch (chartlib) {
case 'tradingview': {
return (
<>
{tradingViewMenuItems}
{commonMenuItems}
</>
);
}
case 'pennant': {
return (
<>
{pennantMenuItems}
{commonMenuItems}
</>
);
}
default: {
throw new Error('invalid chart lib');
}
}
}; };

View File

@ -0,0 +1,2 @@
export { ChartContainer } from './chart-container';
export { ChartMenu } from './chart-menu';

View File

@ -1,18 +1,23 @@
import { getValidItem, getValidSubset } from '@vegaprotocol/react-helpers'; import { ChartType, Overlay, Study } from 'pennant';
import { ChartType, Interval, Study } from 'pennant';
import { Overlay } from 'pennant';
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer'; import { immer } from 'zustand/middleware/immer';
import { Interval } from '@vegaprotocol/types';
import { getValidItem, getValidSubset } from '@vegaprotocol/react-helpers';
type StudySizes = { [S in Study]?: number }; type StudySizes = { [S in Study]?: number };
export type Chartlib = 'pennant' | 'tradingview';
interface StoredSettings { interface StoredSettings {
chartlib: Chartlib;
// For interval we use the enum from @vegaprotocol/types, this is to make mapping between different
// chart types easier and more consistent
interval: Interval; interval: Interval;
type: ChartType; type: ChartType;
overlays: Overlay[]; overlays: Overlay[];
studies: Study[]; studies: Study[];
studySizes: StudySizes; studySizes: StudySizes;
tradingViewStudies: string[];
} }
export const STUDY_SIZE = 90; export const STUDY_SIZE = 90;
@ -25,20 +30,24 @@ const STUDY_ORDER: Study[] = [
]; ];
export const DEFAULT_CHART_SETTINGS = { export const DEFAULT_CHART_SETTINGS = {
interval: Interval.I15M, chartlib: 'pennant' as const,
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 useCandlesChartSettingsStore = create< export const useChartSettingsStore = create<
StoredSettings & { StoredSettings & {
setType: (type: ChartType) => void; setType: (type: ChartType) => void;
setInterval: (interval: Interval) => void; setInterval: (interval: Interval) => void;
setOverlays: (overlays?: Overlay[]) => void; setOverlays: (overlays?: Overlay[]) => void;
setStudies: (studies?: Study[]) => void; setStudies: (studies?: Study[]) => void;
setStudySizes: (sizes: number[]) => void; setStudySizes: (sizes: number[]) => void;
setChartlib: (lib: Chartlib) => void;
setTradingViewStudies: (studies: string[]) => void;
} }
>()( >()(
persist( persist(
@ -81,6 +90,16 @@ export const useCandlesChartSettingsStore = create<
}); });
}); });
}, },
setChartlib: (lib) => {
set((state) => {
state.chartlib = lib;
});
},
setTradingViewStudies: (studies: string[]) => {
set((state) => {
state.tradingViewStudies = studies;
});
},
})), })),
{ {
name: 'vega_candles_chart_store', name: 'vega_candles_chart_store',
@ -88,13 +107,13 @@ export const useCandlesChartSettingsStore = create<
) )
); );
export const useCandlesChartSettings = () => { export const useChartSettings = () => {
const settings = useCandlesChartSettingsStore(); const settings = useChartSettingsStore();
const interval: Interval = getValidItem( const interval: Interval = getValidItem(
settings.interval, settings.interval,
Object.values(Interval), Object.values(Interval),
Interval.I15M Interval.INTERVAL_I15M
); );
const chartType: ChartType = getValidItem( const chartType: ChartType = getValidItem(
@ -122,15 +141,19 @@ export const useCandlesChartSettings = () => {
}); });
return { return {
chartlib: settings.chartlib,
interval, interval,
chartType, chartType,
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,
setTradingViewStudies: settings.setTradingViewStudies,
}; };
}; };

View File

@ -4,9 +4,18 @@ export default {
preset: '../../jest.preset.js', preset: '../../jest.preset.js',
transform: { transform: {
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest',
'^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/next/babel'] }], '^.+\\.[tj]sx?$': [
'babel-jest',
{
presets: ['@nx/next/babel'],
// required for pennant to work in jest, due to having untranspiled exports
plugins: [['@babel/plugin-proposal-private-methods']],
},
],
}, },
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/apps/trading', coverageDirectory: '../../coverage/apps/trading',
setupFilesAfterEnv: ['./setup-tests.ts'], setupFilesAfterEnv: ['./setup-tests.ts'],
// dont ignore pennant from transpilation
transformIgnorePatterns: ['<rootDir>/node_modules/pennant'],
}; };

View File

@ -75,6 +75,7 @@ i18n
'positions', 'positions',
'trades', 'trades',
'trading', 'trading',
'trading-view',
'ui-toolkit', 'ui-toolkit',
'utils', 'utils',
'wallet', 'wallet',

View File

@ -1,42 +1,51 @@
import 'pennant/dist/style.css'; import 'pennant/dist/style.css';
import { CandlestickChart } from 'pennant'; import {
CandlestickChart,
type Overlay,
type ChartType,
type Interval,
type Study,
} from 'pennant';
import { VegaDataSource } from './data-source'; import { VegaDataSource } from './data-source';
import { useApolloClient } from '@apollo/client'; import { useApolloClient } from '@apollo/client';
import { useMemo } from 'react'; import { useMemo } from 'react';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { useVegaWallet } from '@vegaprotocol/wallet'; import { useVegaWallet } from '@vegaprotocol/wallet';
import {
STUDY_SIZE,
useCandlesChartSettings,
} from './use-candles-chart-settings';
import { useT } from './use-t'; import { useT } from './use-t';
import { useThemeSwitcher } from '@vegaprotocol/react-helpers'; import { useThemeSwitcher } from '@vegaprotocol/react-helpers';
export type CandlesChartContainerProps = { export type CandlesChartContainerProps = {
marketId: string; marketId: string;
interval: Interval;
chartType: ChartType;
overlays: Overlay[];
studies: Study[];
studySizes: number[];
defaultStudySize: number;
setStudies: (studies?: Study[]) => void;
setStudySizes: (sizes: number[]) => void;
setOverlays: (overlays?: Overlay[]) => void;
}; };
const CANDLES_TO_WIDTH_FACTOR = 0.2; const CANDLES_TO_WIDTH_FACTOR = 0.2;
export const CandlesChartContainer = ({ export const CandlesChartContainer = ({
marketId, marketId,
}: CandlesChartContainerProps) => {
const client = useApolloClient();
const { pubKey } = useVegaWallet();
const { theme } = useThemeSwitcher();
const t = useT();
const {
interval, interval,
chartType, chartType,
overlays, overlays,
studies, studies,
studySizes, studySizes,
defaultStudySize,
setStudies, setStudies,
setStudySizes, setStudySizes,
setOverlays, setOverlays,
} = useCandlesChartSettings(); }: CandlesChartContainerProps) => {
const client = useApolloClient();
const { pubKey } = useVegaWallet();
const { theme } = useThemeSwitcher();
const t = useT();
const handlePaneChange = useMemo( const handlePaneChange = useMemo(
() => () =>
@ -69,7 +78,7 @@ export const CandlesChartContainer = ({
</span> </span>
), ),
initialNumCandlesToDisplay: candlesCount, initialNumCandlesToDisplay: candlesCount,
studySize: STUDY_SIZE, studySize: defaultStudySize,
studySizes, studySizes,
}} }}
interval={interval} interval={interval}

View File

@ -1,85 +0,0 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CandlesMenu } from './candles-menu';
import {
useCandlesChartSettingsStore,
DEFAULT_CHART_SETTINGS,
} from './use-candles-chart-settings';
import { Overlay, Study, overlayLabels, studyLabels } from 'pennant';
describe('CandlesMenu', () => {
const openDropdown = async () => {
await userEvent.click(
screen.getByRole('button', {
name: 'Indicators',
})
);
};
beforeEach(() => {
// clear store each time to avoid conditional testing of defaults
useCandlesChartSettingsStore.setState({ overlays: [], studies: [] });
});
it.each(Object.values(Overlay))('can set %s overlay', async (overlay) => {
render(<CandlesMenu />);
await openDropdown();
const menu = within(await screen.findByRole('menu'));
await userEvent.click(menu.getByText(overlayLabels[overlay as Overlay]));
// re-open the dropdown
await openDropdown();
expect(screen.getByText(overlayLabels[overlay as Overlay])).toHaveAttribute(
'data-state',
'checked'
);
});
it.each(Object.values(Study))('can set %s study', async (study) => {
render(<CandlesMenu />);
await openDropdown();
const menu = within(await screen.findByRole('menu'));
await userEvent.click(menu.getByText(studyLabels[study as Study]));
// re-open the dropdown
await openDropdown();
expect(screen.getByText(studyLabels[study as Study])).toHaveAttribute(
'data-state',
'checked'
);
});
it('should render with the correct default studies and overlays', async () => {
useCandlesChartSettingsStore.setState(DEFAULT_CHART_SETTINGS);
render(<CandlesMenu />);
await userEvent.click(
screen.getByRole('button', {
name: 'Indicators',
})
);
const menu = within(await screen.findByRole('menu'));
expect(menu.getByText(studyLabels.volume)).toHaveAttribute(
'data-state',
'checked'
);
expect(menu.getByText(studyLabels.macd)).toHaveAttribute(
'data-state',
'checked'
);
expect(menu.getByText(overlayLabels.movingAverage)).toHaveAttribute(
'data-state',
'checked'
);
});
});

View File

@ -0,0 +1,12 @@
import { Interval as PennantInterval } from 'pennant';
import { Interval } from '@vegaprotocol/types';
export const PENNANT_INTERVAL_MAP = {
[Interval.INTERVAL_BLOCK]: undefined, // TODO: handle block tick
[Interval.INTERVAL_I1M]: PennantInterval.I1M,
[Interval.INTERVAL_I5M]: PennantInterval.I5M,
[Interval.INTERVAL_I15M]: PennantInterval.I15M,
[Interval.INTERVAL_I1H]: PennantInterval.I1H,
[Interval.INTERVAL_I6H]: PennantInterval.I6H,
[Interval.INTERVAL_I1D]: PennantInterval.I1D,
} as const;

View File

@ -1,5 +1,5 @@
export * from './__generated__/Candles'; export * from './__generated__/Candles';
export * from './__generated__/Chart'; export * from './__generated__/Chart';
export * from './candles-chart'; export * from './candles-chart';
export * from './candles-menu'; export { PENNANT_INTERVAL_MAP } from './constants';
export * from './data-source'; export * from './data-source';

View File

@ -154,6 +154,7 @@ const compileEnvVars = () => {
'VEGA_ENV', 'VEGA_ENV',
process.env['NX_VEGA_ENV'] process.env['NX_VEGA_ENV']
) as Networks; ) as Networks;
const env: Environment = { const env: Environment = {
VEGA_URL: windowOrDefault('VEGA_URL', process.env['NX_VEGA_URL']), VEGA_URL: windowOrDefault('VEGA_URL', process.env['NX_VEGA_URL']),
VEGA_ENV, VEGA_ENV,
@ -253,6 +254,14 @@ const compileEnvVars = () => {
'NX_MOZILLA_EXTENSION_URL', 'NX_MOZILLA_EXTENSION_URL',
process.env['NX_MOZILLA_EXTENSION_URL'] process.env['NX_MOZILLA_EXTENSION_URL']
), ),
CHARTING_LIBRARY_PATH: windowOrDefault(
'NX_CHARTING_LIBRARY_PATH',
process.env['NX_CHARTING_LIBRARY_PATH']
),
CHARTING_LIBRARY_HASH: windowOrDefault(
'NX_CHARTING_LIBRARY_HASH',
process.env['NX_CHARTING_LIBRARY_HASH']
),
}; };
return env; return env;
@ -360,6 +369,7 @@ export const compileFeatureFlags = (refresh = false): FeatureFlags => {
) as string ) as string
), ),
}; };
const EXPLORER_FLAGS = { const EXPLORER_FLAGS = {
EXPLORER_ASSETS: TRUTHY.includes( EXPLORER_ASSETS: TRUTHY.includes(
windowOrDefault( windowOrDefault(
@ -416,6 +426,7 @@ export const compileFeatureFlags = (refresh = false): FeatureFlags => {
) as string ) as string
), ),
}; };
const GOVERNANCE_FLAGS = { const GOVERNANCE_FLAGS = {
GOVERNANCE_NETWORK_DOWN: TRUTHY.includes( GOVERNANCE_NETWORK_DOWN: TRUTHY.includes(
windowOrDefault( windowOrDefault(

View File

@ -60,6 +60,8 @@ export const envSchema = z
TENDERMINT_WEBSOCKET_URL: z.optional(z.string()), TENDERMINT_WEBSOCKET_URL: z.optional(z.string()),
CHROME_EXTENSION_URL: z.optional(z.string()), CHROME_EXTENSION_URL: z.optional(z.string()),
MOZILLA_EXTENSION_URL: z.optional(z.string()), MOZILLA_EXTENSION_URL: z.optional(z.string()),
CHARTING_LIBRARY_PATH: z.optional(z.string()),
CHARTING_LIBRARY_HASH: z.optional(z.string()),
}) })
.refine( .refine(
(data) => { (data) => {

View File

@ -11,6 +11,7 @@ import en_fills from './locales/en/fills.json';
import en_funding_payments from './locales/en/funding-payments.json'; import en_funding_payments from './locales/en/funding-payments.json';
import en_governance from './locales/en/governance.json'; import en_governance from './locales/en/governance.json';
import en_trading from './locales/en/trading.json'; import en_trading from './locales/en/trading.json';
import en_trading_view from './locales/en/trading-view.json';
import en_markets from './locales/en/markets.json'; import en_markets from './locales/en/markets.json';
import en_web3 from './locales/en/web3.json'; import en_web3 from './locales/en/web3.json';
import en_proposals from './locales/en/proposals.json'; import en_proposals from './locales/en/proposals.json';
@ -32,6 +33,7 @@ export const locales = {
'funding-payments': en_funding_payments, 'funding-payments': en_funding_payments,
governance: en_governance, governance: en_governance,
trading: en_trading, trading: en_trading,
trading_view: en_trading_view,
markets: en_markets, markets: en_markets,
web3: en_web3, web3: en_web3,
positions: en_positions, positions: en_positions,

View File

@ -1,5 +1,3 @@
{ {
"Indicators": "Indicators",
"Interval: {{interval}}": "Interval: {{interval}}",
"No open orders": "No open orders" "No open orders": "No open orders"
} }

View File

@ -0,0 +1,4 @@
{
"Failed to initialize Trading view": "Failed to initialize Trading view",
"Loading Trading View": "Loading Trading View"
}

View File

@ -26,9 +26,10 @@
"Best offer": "Best offer", "Best offer": "Best offer",
"Browse": "Browse", "Browse": "Browse",
"By using the Vega Console, you acknowledge that you have read and understood the <0>Vega Console Disclaimer</0>": "By using the Vega Console, you acknowledge that you have read and understood the <0>Vega Console Disclaimer</0>", "By using the Vega Console, you acknowledge that you have read and understood the <0>Vega Console Disclaimer</0>": "By using the Vega Console, you acknowledge that you have read and understood the <0>Vega Console Disclaimer</0>",
"Candles": "Candles", "Chart": "Chart",
"Change (24h)": "Change (24h)", "Change (24h)": "Change (24h)",
"Chart": "Chart", "Chart": "Chart",
"Chart by <0>TradingView</0>": "Chart by <0>TradingView</0>",
"checkOutProposalsAndVote": "Check out the terms of the proposals and vote:", "checkOutProposalsAndVote": "Check out the terms of the proposals and vote:",
"checkOutProposalsAndVote_one": "Check out the terms of the proposal and vote:", "checkOutProposalsAndVote_one": "Check out the terms of the proposal and vote:",
"checkOutProposalsAndVote_other": "Check out the terms of the proposals and vote:", "checkOutProposalsAndVote_other": "Check out the terms of the proposals and vote:",
@ -120,7 +121,15 @@
"Improve vega console": "Improve vega console", "Improve vega console": "Improve vega console",
"Inactive": "Inactive", "Inactive": "Inactive",
"Index Price": "Index Price", "Index Price": "Index Price",
"Indicators": "Indicators",
"Infrastructure": "Infrastructure", "Infrastructure": "Infrastructure",
"Interval: {{interval}}": "Interval: {{interval}}",
"INTERVAL_I1M": "1m",
"INTERVAL_I5M": "5m",
"INTERVAL_I15M": "15m",
"INTERVAL_I1H": "1H",
"INTERVAL_I6H": "6H",
"INTERVAL_I1D": "1D",
"Invite friends and earn rewards from the trading fees they pay. Stake those rewards to earn multipliers on future rewards.": "Invite friends and earn rewards from the trading fees they pay. Stake those rewards to earn multipliers on future rewards.", "Invite friends and earn rewards from the trading fees they pay. Stake those rewards to earn multipliers on future rewards.": "Invite friends and earn rewards from the trading fees they pay. Stake those rewards to earn multipliers on future rewards.",
"Learn about providing liquidity": "Learn about providing liquidity", "Learn about providing liquidity": "Learn about providing liquidity",
"Learn more": "Learn more", "Learn more": "Learn more",
@ -192,6 +201,7 @@
"pastEpochs": "Past {{count}} epochs", "pastEpochs": "Past {{count}} epochs",
"pastEpochs_one": "Past {{count}} epoch", "pastEpochs_one": "Past {{count}} epoch",
"pastEpochs_other": "Past {{count}} epochs", "pastEpochs_other": "Past {{count}} epochs",
"Pennant": "Pennant",
"Perpetuals": "Perpetuals", "Perpetuals": "Perpetuals",
"Please choose another market from the <0>market list</0>": "Please choose another market from the <0>market list</0>", "Please choose another market from the <0>market list</0>": "Please choose another market from the <0>market list</0>",
"Please connect Vega wallet": "Please connect Vega wallet", "Please connect Vega wallet": "Please connect Vega wallet",
@ -291,6 +301,7 @@
"Trader": "Trader", "Trader": "Trader",
"Trades": "Trades", "Trades": "Trades",
"Trading": "Trading", "Trading": "Trading",
"TradingView": "TradingView",
"Trading has been terminated as a result of the product definition": "Trading has been terminated as a result of the product definition", "Trading has been terminated as a result of the product definition": "Trading has been terminated as a result of the product definition",
"Trading mode": "Trading mode", "Trading mode": "Trading mode",
"Trading on Market {{name}} may stop on {{date}}. There is open proposal to close this market.": "Trading on Market {{name}} may stop on {{date}}. There is open proposal to close this market.", "Trading on Market {{name}} may stop on {{date}}. There is open proposal to close this market.": "Trading on Market {{name}} may stop on {{date}}. There is open proposal to close this market.",
@ -302,6 +313,7 @@
"totalCommission_other": "Total commission (last {{count}} epochs)", "totalCommission_other": "Total commission (last {{count}} epochs)",
"Unknown": "Unknown", "Unknown": "Unknown",
"Unknown settlement date": "Unknown settlement date", "Unknown settlement date": "Unknown settlement date",
"Vega chart": "Vega chart",
"Vega Reward pot": "Vega Reward pot", "Vega Reward pot": "Vega Reward pot",
"Vega Wallet <0>full featured<0>": "Vega Wallet <0>full featured<0>", "Vega Wallet <0>full featured<0>": "Vega Wallet <0>full featured<0>",
"Vesting": "Vesting", "Vesting": "Vesting",

View File

@ -1,3 +1,4 @@
export * from './use-copy-timeout';
export * from './use-fetch'; export * from './use-fetch';
export * from './use-local-storage'; export * from './use-local-storage';
export * from './use-mutation-observer'; export * from './use-mutation-observer';
@ -11,4 +12,4 @@ export * from './use-theme-switcher';
export * from './use-storybook-theme-observer'; export * from './use-storybook-theme-observer';
export * from './use-yesterday'; export * from './use-yesterday';
export * from './use-previous'; export * from './use-previous';
export * from './use-copy-timeout'; export { useScript } from './use-script';

View File

@ -0,0 +1,18 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useScript } from './use-script';
describe('useScript', () => {
it('appends a script to the body', async () => {
const url = 'http://localhost:8080/foo.js';
const { result } = renderHook(() => useScript(url, 'integrity-hash'));
expect(result.current).toBe('loading');
await waitFor(() => {
const script = document.body.getElementsByTagName('script')[0];
expect(script).toBeInTheDocument();
expect(script).toHaveAttribute('src', url);
});
});
});

View File

@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
type State = 'idle' | 'loading' | 'ready' | 'error';
// keep record of loaded script state, so if the component re-mounts but the script
// was already appended its not re-appended and will have the correct state
type Url = string;
const cache: Record<Url, State> = {};
export const useScript = (url: string, integrity: string) => {
// track state of the script as it loads or possibly fails
const [state, setState] = useState<State>(url ? 'loading' : 'idle');
useEffect(() => {
if (!url) {
setState('idle');
return;
}
// Use the integrity hash of the script as an identifier
let script = document.getElementById(integrity) as HTMLScriptElement | null;
if (script) {
// script already on the page
setState(cache[url]);
} else {
// script not found, create and append script
script = document.createElement('script');
script.id = integrity;
script.src = url;
script.async = true;
script.crossOrigin = 'anonymous'; // make sure sri is respected with cross origin request
script.integrity = `sha256-${integrity}`;
document.body.appendChild(script);
}
// Setup/teardown listeners to notify component when script has loaded
const _setState = (event: Event) => {
const result = event.type === 'load' ? 'ready' : 'error';
setState(result);
cache[url] = result;
};
script.addEventListener('load', _setState);
script.addEventListener('error', _setState);
return () => {
if (script) {
script.removeEventListener('load', _setState);
script.removeEventListener('error', _setState);
}
};
}, [url, integrity]);
return state;
};

View File

@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}

View File

@ -0,0 +1,18 @@
{
"extends": ["plugin:@nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*", "__generated__"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View File

@ -0,0 +1,7 @@
# trading-view
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test trading-view` to execute the unit tests via [Jest](https://jestjs.io).

View File

@ -0,0 +1,12 @@
/* eslint-disable */
export default {
displayName: 'trading-view',
preset: '../../jest.preset.js',
transform: {
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest',
'^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/libs/trading-view',
setupFilesAfterEnv: ['./src/setup-tests.ts'],
};

View File

@ -0,0 +1,4 @@
{
"name": "trading-view",
"version": "0.0.1"
}

View File

@ -0,0 +1,43 @@
{
"name": "trading-view",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/trading-view/src",
"projectType": "library",
"tags": [],
"targets": {
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/trading-view/**/*.{ts,tsx,js,jsx}"]
}
},
"build": {
"executor": "@nx/rollup:rollup",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/trading-view",
"tsConfig": "libs/trading-view/tsconfig.lib.json",
"project": "libs/trading-view/package.json",
"entryFile": "libs/trading-view/src/index.ts",
"external": ["react", "react-dom", "react/jsx-runtime"],
"rollupConfig": "@nx/react/plugins/bundle-rollup",
"compiler": "babel",
"assets": [
{
"glob": "libs/trading-view/README.md",
"input": ".",
"output": "."
}
]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/trading-view/jest.config.ts"
}
}
}
}

View File

@ -0,0 +1,5 @@
export { TradingViewContainer } from './lib/trading-view-container';
export {
ALLOWED_TRADINGVIEW_HOSTNAMES,
TRADINGVIEW_INTERVAL_MAP,
} from './lib/constants';

View File

@ -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
}
}

View File

@ -0,0 +1,24 @@
query Symbol($marketId: ID!) {
market(id: $marketId) {
id
decimalPlaces
positionDecimalPlaces
tradableInstrument {
instrument {
code
name
metadata {
tags
}
product {
... on Future {
__typename
}
... on Perpetual {
__typename
}
}
}
}
}
}

View File

@ -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<Types.Scalars['String']>;
}>;
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<GetBarsQuery, GetBarsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetBarsQuery, GetBarsQueryVariables>(GetBarsDocument, options);
}
export function useGetBarsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetBarsQuery, GetBarsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetBarsQuery, GetBarsQueryVariables>(GetBarsDocument, options);
}
export type GetBarsQueryHookResult = ReturnType<typeof useGetBarsQuery>;
export type GetBarsLazyQueryHookResult = ReturnType<typeof useGetBarsLazyQuery>;
export type GetBarsQueryResult = Apollo.QueryResult<GetBarsQuery, GetBarsQueryVariables>;
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<LastBarSubscription, LastBarSubscriptionVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useSubscription<LastBarSubscription, LastBarSubscriptionVariables>(LastBarDocument, options);
}
export type LastBarSubscriptionHookResult = ReturnType<typeof useLastBarSubscription>;
export type LastBarSubscriptionResult = Apollo.SubscriptionResult<LastBarSubscription>;

View File

@ -0,0 +1,67 @@
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, metadata: { __typename?: 'InstrumentMetadata', tags?: Array<string> | null }, 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
metadata {
tags
}
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<SymbolQuery, SymbolQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<SymbolQuery, SymbolQueryVariables>(SymbolDocument, options);
}
export function useSymbolLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<SymbolQuery, SymbolQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<SymbolQuery, SymbolQueryVariables>(SymbolDocument, options);
}
export type SymbolQueryHookResult = ReturnType<typeof useSymbolQuery>;
export type SymbolLazyQueryHookResult = ReturnType<typeof useSymbolLazyQuery>;
export type SymbolQueryResult = Apollo.QueryResult<SymbolQuery, SymbolQueryVariables>;

View File

@ -0,0 +1,24 @@
import { Interval } from '@vegaprotocol/types';
export const ALLOWED_TRADINGVIEW_HOSTNAMES = [
'localhost',
'vegafairground.eth.limo',
'vegafairground.eth',
'vegaprotocol.eth',
'vegaprotocol.eth.limo',
];
export const CHARTING_LIBRARY_FILE = 'charting_library.standalone.js';
export const TRADINGVIEW_INTERVAL_MAP = {
[Interval.INTERVAL_BLOCK]: undefined, // TODO: handle block tick
[Interval.INTERVAL_I1M]: '1',
[Interval.INTERVAL_I5M]: '5',
[Interval.INTERVAL_I15M]: '15',
[Interval.INTERVAL_I1H]: '60',
[Interval.INTERVAL_I6H]: '360',
[Interval.INTERVAL_I1D]: '1D',
} as const;
export type ResolutionRecord = typeof TRADINGVIEW_INTERVAL_MAP;
export type ResolutionString = ResolutionRecord[keyof ResolutionRecord];

View File

@ -0,0 +1,56 @@
import { render, screen } from '@testing-library/react';
import { TradingViewContainer } from './trading-view-container';
import * as useScriptModule from '@vegaprotocol/react-helpers';
import { CHARTING_LIBRARY_FILE } from './constants';
jest.mock('./trading-view', () => ({
TradingView: ({ marketId }: { marketId: string }) => (
<div data-testid="trading-view">{marketId}</div>
),
}));
describe('TradingView', () => {
const props = {
libraryPath: 'foo',
libraryHash: 'hash',
marketId: 'marketId',
};
const renderComponent = () => render(<TradingViewContainer {...props} />);
it.each(['loading', 'idle'])(
'renders loading state when script is %s',
(state: string) => {
const spyOnScript = jest
.spyOn(useScriptModule, 'useScript')
.mockReturnValue(state as 'idle' | 'loading');
renderComponent();
expect(screen.getByText('Loading Trading View')).toBeInTheDocument();
expect(spyOnScript).toHaveBeenCalledWith(
props.libraryPath + CHARTING_LIBRARY_FILE,
props.libraryHash
);
}
);
it('renders error state if script fails to load', () => {
jest.spyOn(useScriptModule, 'useScript').mockReturnValue('error');
renderComponent();
expect(
screen.getByText('Failed to initialize Trading view')
).toBeInTheDocument();
});
it('renders TradingView if script loads successfully', () => {
jest.spyOn(useScriptModule, 'useScript').mockReturnValue('ready');
renderComponent();
expect(screen.getByTestId('trading-view')).toHaveTextContent(
props.marketId
);
});
});

View File

@ -0,0 +1,56 @@
import { useScript } from '@vegaprotocol/react-helpers';
import { Splash } from '@vegaprotocol/ui-toolkit';
import { useT } from './use-t';
import { TradingView, type OnAutoSaveNeededCallback } from './trading-view';
import { CHARTING_LIBRARY_FILE, type ResolutionString } from './constants';
export const TradingViewContainer = ({
libraryPath,
libraryHash,
marketId,
interval,
studies,
onIntervalChange,
onAutoSaveNeeded,
}: {
libraryPath: string;
libraryHash: string;
marketId: string;
interval: ResolutionString;
studies: string[];
onIntervalChange: (interval: string) => void;
onAutoSaveNeeded: OnAutoSaveNeededCallback;
}) => {
const t = useT();
const scriptState = useScript(
libraryPath + CHARTING_LIBRARY_FILE,
libraryHash
);
if (scriptState === 'loading' || scriptState === 'idle') {
return (
<Splash>
<p>{t('Loading Trading View')}</p>
</Splash>
);
}
if (scriptState === 'error') {
return (
<Splash>
<p>{t('Failed to initialize Trading view')}</p>
</Splash>
);
}
return (
<TradingView
libraryPath={libraryPath}
marketId={marketId}
interval={interval}
studies={studies}
onIntervalChange={onIntervalChange}
onAutoSaveNeeded={onAutoSaveNeeded}
/>
);
};

View File

@ -0,0 +1,131 @@
import { useEffect, useRef } from 'react';
import {
useScreenDimensions,
useThemeSwitcher,
} from '@vegaprotocol/react-helpers';
import { useLanguage } from './use-t';
import { useDatafeed } from './use-datafeed';
import { type ResolutionString } from './constants';
export type OnAutoSaveNeededCallback = (data: { studies: string[] }) => void;
export const TradingView = ({
marketId,
libraryPath,
interval,
studies,
onIntervalChange,
onAutoSaveNeeded,
}: {
marketId: string;
libraryPath: string;
interval: ResolutionString;
studies: string[];
onIntervalChange: (interval: string) => void;
onAutoSaveNeeded: OnAutoSaveNeededCallback;
}) => {
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 datafeed = useDatafeed();
useEffect(
() => {
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) => {
const asOverlay = study === 'Volume';
activeChart.createStudy(study, asOverlay);
});
// 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 programmitcally 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, doen 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') => {
return {
// colors set here, trading view lets the user set a color
'paneProperties.background': theme === 'dark' ? '#05060C' : '#fff',
'paneProperties.backgroundType': 'solid',
};
};

View File

@ -0,0 +1,298 @@
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,
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 { getMarketExpiryDate, toBigNum } from '@vegaprotocol/utils';
const EXCHANGE = 'VEGA';
const resolutionMap: Record<string, Interval> = {
'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: [EXCHANGE],
// 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;
export const useDatafeed = () => {
const hasHistory = useRef(false);
const subRef = useRef<Subscription>();
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));
},
searchSymbols: () => {
/* no op, we handle finding markets in app */
},
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 {
const result = await client.query<SymbolQuery, SymbolQueryVariables>({
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;
}
let type = 'undefined'; // https://www.tradingview.com/charting-library-docs/latest/api/modules/Charting_Library#symboltype
if (productType === 'Future' || productType === 'Perpetual') {
type = 'futures';
} else if (productType === 'Spot') {
type = 'spot';
}
const expirationDate = getMarketExpiryDate(instrument.metadata.tags);
const expirationTimestamp = expirationDate
? Math.floor(expirationDate.getTime() / 1000)
: null;
// @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,
full_name: `${EXCHANGE}:${instrument.code}`,
description: instrument.name,
listed_exchange: EXCHANGE,
expired: productType === 'Perpetual' ? false : true,
expirationDate: expirationTimestamp,
// @ts-ignore cant import types as chartin_library is external
format: 'price' as SeriesFormat,
type,
session: '24x7',
timezone: 'Etc/UTC',
exchange: 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: 'streaming',
delay: 1000, // around 1 block time
has_intraday: true, // required for less than 1 day interval
has_empty_bars: true, // library will generate bars if there are gaps, useful for auctions
has_ticks: false, // switch to true when enabling block intervals
// @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 (
// @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) {
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
);
});
hasHistory.current = true;
onHistoryCallback(bars, { noData: false });
} catch (err) {
onErrorCallback(
err instanceof Error ? err.message : 'Failed to get bars'
);
}
},
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) {
throw new Error('No symbolInfo.ticker');
}
// Dont start the subscription if there is no candle history. This protects against a
// problem where drawing on the chart throws an error if there is no prior history, instead
// no you'll just get the no data message
if (!hasHistory.current) {
return;
}
subRef.current = client
.subscribe<LastBarSubscription, LastBarSubscriptionVariables>({
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);
};

View File

@ -0,0 +1,4 @@
import { useTranslation } from 'react-i18next';
export const ns = 'trading-view';
export const useT = () => useTranslation(ns).t;
export const useLanguage = () => useTranslation(ns).i18n.language;

View File

@ -0,0 +1,15 @@
import '@testing-library/jest-dom';
import { locales } from '@vegaprotocol/i18n';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
// Set up i18n instance so that components have the correct default
// en translations
i18n.use(initReactI18next).init({
// we init with resources
resources: locales,
fallbackLng: 'en',
ns: ['trading-view'],
defaultNS: 'trading-view',
});

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../tsconfig.base.json"
}

View File

@ -0,0 +1,24 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts"
]
},
"exclude": [
"jest.config.ts",
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec.tsx",
"src/**/*.test.tsx",
"src/**/*.spec.js",
"src/**/*.test.js",
"src/**/*.spec.jsx",
"src/**/*.test.jsx"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}

View File

@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node", "@testing-library/jest-dom"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}

View File

@ -89,7 +89,6 @@ export const Tabs = ({
ref={menuRef} ref={menuRef}
className={classNames('flex-1 p-1', { className={classNames('flex-1 p-1', {
'bg-vega-clight-700 dark:bg-vega-cdark-700': wrapped, 'bg-vega-clight-700 dark:bg-vega-cdark-700': wrapped,
'': wrapped,
})} })}
> >
{Children.map(children, (child) => { {Children.map(children, (child) => {
@ -97,7 +96,7 @@ export const Tabs = ({
return ( return (
<TabsPrimitive.Content <TabsPrimitive.Content
value={child.props.id} value={child.props.id}
className={classNames('flex flex-nowrap gap-1', { className={classNames('flex items-center flex-nowrap gap-1', {
'justify-end': !wrapped, 'justify-end': !wrapped,
})} })}
> >

View File

@ -146,6 +146,7 @@
"@storybook/react-webpack5": "7.5.3", "@storybook/react-webpack5": "7.5.3",
"@svgr/rollup": "^8.0.1", "@svgr/rollup": "^8.0.1",
"@svgr/webpack": "^6.1.2", "@svgr/webpack": "^6.1.2",
"@swc-node/register": "~1.6.7",
"@swc/cli": "^0.1.62", "@swc/cli": "^0.1.62",
"@swc/core": "~1.3.85", "@swc/core": "~1.3.85",
"@swc/jest": "0.2.20", "@swc/jest": "0.2.20",
@ -161,9 +162,9 @@
"@types/lodash": "^4.14.171", "@types/lodash": "^4.14.171",
"@types/node": "18.14.2", "@types/node": "18.14.2",
"@types/prismjs": "^1.26.0", "@types/prismjs": "^1.26.0",
"@types/react": "18.2.24", "@types/react": "18.2.33",
"@types/react-copy-to-clipboard": "5.0.7", "@types/react-copy-to-clipboard": "5.0.7",
"@types/react-dom": "18.2.9", "@types/react-dom": "18.2.14",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/react-syntax-highlighter": "^15.5.5", "@types/react-syntax-highlighter": "^15.5.5",
"@types/react-virtualized-auto-sizer": "^1.0.1", "@types/react-virtualized-auto-sizer": "^1.0.1",

View File

@ -1,6 +1,14 @@
# Chart # Chart
## Display options ## Chart lib type
- **Must** be able to view the Vega chart by default
- **Must** be able to switch to the TradingView chart
- **Must** have the interval persisted between chart types
## Pennant chart
### Display options
- **Must** be able to change time interval from a list of intervals (<a name="6007-CHAR-001" href="#6007-CHAR-001">6007-CHAR-001</a>) - **Must** be able to change time interval from a list of intervals (<a name="6007-CHAR-001" href="#6007-CHAR-001">6007-CHAR-001</a>)
- 1m - 1m
@ -36,7 +44,7 @@
- **Must** be able to add multiple overlays at the same time (<a name="6007-CHAR-008" href="#6007-CHAR-008">6007-CHAR-008</a>) - **Must** be able to add multiple overlays at the same time (<a name="6007-CHAR-008" href="#6007-CHAR-008">6007-CHAR-008</a>)
- **Must** be able to close any overlays selected (<a name="6007-CHAR-009" href="#6007-CHAR-009">6007-CHAR-009</a>) - **Must** be able to close any overlays selected (<a name="6007-CHAR-009" href="#6007-CHAR-009">6007-CHAR-009</a>)
## Price and Time ### Price and Time
- **Must** see details of price from where my mouse cursor is on the chart(<a name="6007-CHAR-010" href="#6007-CHAR-010">6007-CHAR-010</a>) - **Must** see details of price from where my mouse cursor is on the chart(<a name="6007-CHAR-010" href="#6007-CHAR-010">6007-CHAR-010</a>)
@ -54,13 +62,13 @@
- **Must** y axis shows price range(<a name="6007-CHAR-014" href="#6007-CHAR-014">6007-CHAR-014</a>) - **Must** y axis shows price range(<a name="6007-CHAR-014" href="#6007-CHAR-014">6007-CHAR-014</a>)
- **Must** show the last price line on the Y axis (<a name="6007-CHAR-015" href="#6007-CHAR-015">6007-CHAR-015</a>) - **Must** show the last price line on the Y axis (<a name="6007-CHAR-015" href="#6007-CHAR-015">6007-CHAR-015</a>)
# Display Types ### Display Types
## Mountain #### Mountain
- **Must** show area line chart with the line being at the last price (<a name="6007-CHAR-016" href="#6007-CHAR-016">6007-CHAR-016</a>) - **Must** show area line chart with the line being at the last price (<a name="6007-CHAR-016" href="#6007-CHAR-016">6007-CHAR-016</a>)
## Candlestick #### Candlestick
- **Must** body is green if the close is higher than the open (<a name="6007-CHAR-017" href="#6007-CHAR-017">6007-CHAR-017</a>) - **Must** body is green if the close is higher than the open (<a name="6007-CHAR-017" href="#6007-CHAR-017">6007-CHAR-017</a>)
- **Must** body is red if the close is lower than the open (<a name="6007-CHAR-018" href="#6007-CHAR-018">6007-CHAR-018</a>) - **Must** body is red if the close is lower than the open (<a name="6007-CHAR-018" href="#6007-CHAR-018">6007-CHAR-018</a>)
@ -69,11 +77,11 @@
- **Must** show low price (<a name="6007-CHAR-021" href="#6007-CHAR-021">6007-CHAR-021</a>) - **Must** show low price (<a name="6007-CHAR-021" href="#6007-CHAR-021">6007-CHAR-021</a>)
- **Must** show close price (<a name="6007-CHAR-022" href="#6007-CHAR-022">6007-CHAR-022</a>) - **Must** show close price (<a name="6007-CHAR-022" href="#6007-CHAR-022">6007-CHAR-022</a>)
## Line #### Line
- **Must** show line on the chart with the line being the the last price (<a name="6007-CHAR-023" href="#6007-CHAR-023">6007-CHAR-023</a>) - **Must** show line on the chart with the line being the the last price (<a name="6007-CHAR-023" href="#6007-CHAR-023">6007-CHAR-023</a>)
## OHLC #### OHLC
- **Must** show open price (<a name="6007-CHAR-024" href="#6007-CHAR-024">6007-CHAR-024</a>) - **Must** show open price (<a name="6007-CHAR-024" href="#6007-CHAR-024">6007-CHAR-024</a>)
- **Must** show high price (<a name="6007-CHAR-025" href="#6007-CHAR-025">6007-CHAR-025</a>) - **Must** show high price (<a name="6007-CHAR-025" href="#6007-CHAR-025">6007-CHAR-025</a>)
@ -82,63 +90,68 @@
- **Must** show in green if the close is higher than the open (<a name="6007-CHAR-028" href="#6007-CHAR-028">6007-CHAR-028</a>) - **Must** show in green if the close is higher than the open (<a name="6007-CHAR-028" href="#6007-CHAR-028">6007-CHAR-028</a>)
- **Must** show in red if the close is lower than the open (<a name="6007-CHAR-029" href="#6007-CHAR-029">6007-CHAR-029</a>) - **Must** show in red if the close is lower than the open (<a name="6007-CHAR-029" href="#6007-CHAR-029">6007-CHAR-029</a>)
# Overlays ### Overlays
## Bollinger bands #### Bollinger bands
- **Must** show upper band (<a name="6007-CHAR-030" href="#6007-CHAR-030">6007-CHAR-030</a>) - **Must** show upper band (<a name="6007-CHAR-030" href="#6007-CHAR-030">6007-CHAR-030</a>)
- **Must** show lower band (<a name="6007-CHAR-031" href="#6007-CHAR-031">6007-CHAR-031</a>) - **Must** show lower band (<a name="6007-CHAR-031" href="#6007-CHAR-031">6007-CHAR-031</a>)
- **Must** show band values at time of cursor position (<a name="6007-CHAR-032" href="#6007-CHAR-032">6007-CHAR-032</a>) - **Must** show band values at time of cursor position (<a name="6007-CHAR-032" href="#6007-CHAR-032">6007-CHAR-032</a>)
## Envelope #### Envelope
- **Must** show upper line (<a name="6007-CHAR-033" href="#6007-CHAR-033">6007-CHAR-033</a>) - **Must** show upper line (<a name="6007-CHAR-033" href="#6007-CHAR-033">6007-CHAR-033</a>)
- **Must** show lower line (<a name="6007-CHAR-034" href="#6007-CHAR-034">6007-CHAR-034</a>) - **Must** show lower line (<a name="6007-CHAR-034" href="#6007-CHAR-034">6007-CHAR-034</a>)
- **Must** show line values at time of cursor position (<a name="6007-CHAR-035" href="#6007-CHAR-035">6007-CHAR-035</a>) - **Must** show line values at time of cursor position (<a name="6007-CHAR-035" href="#6007-CHAR-035">6007-CHAR-035</a>)
## EMA #### EMA
- **Must** show line (<a name="6007-CHAR-036" href="#6007-CHAR-036">6007-CHAR-036</a>) - **Must** show line (<a name="6007-CHAR-036" href="#6007-CHAR-036">6007-CHAR-036</a>)
- **Must** show line value at time of cursor position (<a name="6007-CHAR-037" href="#6007-CHAR-037">6007-CHAR-037</a>) - **Must** show line value at time of cursor position (<a name="6007-CHAR-037" href="#6007-CHAR-037">6007-CHAR-037</a>)
## Moving Average #### Moving Average
- **Must** show line (<a name="6007-CHAR-038" href="#6007-CHAR-038">6007-CHAR-038</a>) - **Must** show line (<a name="6007-CHAR-038" href="#6007-CHAR-038">6007-CHAR-038</a>)
- **Must** show line value at time of cursor position (<a name="6007-CHAR-039" href="#6007-CHAR-039">6007-CHAR-039</a>) - **Must** show line value at time of cursor position (<a name="6007-CHAR-039" href="#6007-CHAR-039">6007-CHAR-039</a>)
## Price monitoring bounds #### Price monitoring bounds
- **Must** show min line (<a name="6007-CHAR-040" href="#6007-CHAR-040">6007-CHAR-040</a>) - **Must** show min line (<a name="6007-CHAR-040" href="#6007-CHAR-040">6007-CHAR-040</a>)
- **Must** show max line (<a name="6007-CHAR-041" href="#6007-CHAR-041">6007-CHAR-041</a>) - **Must** show max line (<a name="6007-CHAR-041" href="#6007-CHAR-041">6007-CHAR-041</a>)
- **Must** show reference line (<a name="6007-CHAR-042" href="#6007-CHAR-042">6007-CHAR-042</a>) - **Must** show reference line (<a name="6007-CHAR-042" href="#6007-CHAR-042">6007-CHAR-042</a>)
- **Must** show line values at time of cursor position (<a name="6007-CHAR-043" href="#6007-CHAR-043">6007-CHAR-043</a>) - **Must** show line values at time of cursor position (<a name="6007-CHAR-043" href="#6007-CHAR-043">6007-CHAR-043</a>)
# Studies ### Studies
## Eldar-ray #### Eldar-ray
- **Must** show bear power line (<a name="6007-CHAR-044" href="#6007-CHAR-044">6007-CHAR-044</a>) - **Must** show bear power line (<a name="6007-CHAR-044" href="#6007-CHAR-044">6007-CHAR-044</a>)
- **Must** show bull power line (<a name="6007-CHAR-045" href="#6007-CHAR-045">6007-CHAR-045</a>) - **Must** show bull power line (<a name="6007-CHAR-045" href="#6007-CHAR-045">6007-CHAR-045</a>)
- **Must** show line values at time of cursor position (<a name="6007-CHAR-046" href="#6007-CHAR-046">6007-CHAR-046</a>) - **Must** show line values at time of cursor position (<a name="6007-CHAR-046" href="#6007-CHAR-046">6007-CHAR-046</a>)
## Force index #### Force index
- **Must** show force line (<a name="6007-CHAR-047" href="#6007-CHAR-047">6007-CHAR-047</a>) - **Must** show force line (<a name="6007-CHAR-047" href="#6007-CHAR-047">6007-CHAR-047</a>)
- **Must** show line value at time of cursor position (<a name="6007-CHAR-048" href="#6007-CHAR-048">6007-CHAR-048</a>) - **Must** show line value at time of cursor position (<a name="6007-CHAR-048" href="#6007-CHAR-048">6007-CHAR-048</a>)
## MACD #### MACD
- **Must** show MACD line (<a name="6007-CHAR-049" href="#6007-CHAR-049">6007-CHAR-049</a>) - **Must** show MACD line (<a name="6007-CHAR-049" href="#6007-CHAR-049">6007-CHAR-049</a>)
- **Must** show signal line (<a name="6007-CHAR-050" href="#6007-CHAR-050">6007-CHAR-050</a>) - **Must** show signal line (<a name="6007-CHAR-050" href="#6007-CHAR-050">6007-CHAR-050</a>)
- **Must** show histogram (<a name="6007-CHAR-051" href="#6007-CHAR-051">6007-CHAR-051</a>) - **Must** show histogram (<a name="6007-CHAR-051" href="#6007-CHAR-051">6007-CHAR-051</a>)
- **Must** show line values at time of cursor position (<a name="6007-CHAR-052" href="#6007-CHAR-052">6007-CHAR-052</a>) - **Must** show line values at time of cursor position (<a name="6007-CHAR-052" href="#6007-CHAR-052">6007-CHAR-052</a>)
## RSI #### RSI
- **Must** show RSI line (<a name="6007-CHAR-053" href="#6007-CHAR-053">6007-CHAR-053</a>) - **Must** show RSI line (<a name="6007-CHAR-053" href="#6007-CHAR-053">6007-CHAR-053</a>)
- **Must** show line value at time of cursor position (<a name="6007-CHAR-054" href="#6007-CHAR-054">6007-CHAR-054</a>) - **Must** show line value at time of cursor position (<a name="6007-CHAR-054" href="#6007-CHAR-054">6007-CHAR-054</a>)
## Volume #### Volume
- **Must** show volume bars (<a name="6007-CHAR-055" href="#6007-CHAR-055">6007-CHAR-055</a>) - **Must** show volume bars (<a name="6007-CHAR-055" href="#6007-CHAR-055">6007-CHAR-055</a>)
- **Must** show bar value at time of cursor position (<a name="6007-CHAR-056" href="#6007-CHAR-056">6007-CHAR-056</a>) - **Must** show bar value at time of cursor position (<a name="6007-CHAR-056" href="#6007-CHAR-056">6007-CHAR-056</a>)
## TradingView
- **Must** persist interval in chart settings
- **Must** must show an attribution to trading view

View File

@ -51,6 +51,7 @@
], ],
"@vegaprotocol/tendermint": ["libs/tendermint/src/index.ts"], "@vegaprotocol/tendermint": ["libs/tendermint/src/index.ts"],
"@vegaprotocol/trades": ["libs/trades/src/index.ts"], "@vegaprotocol/trades": ["libs/trades/src/index.ts"],
"@vegaprotocol/trading-view": ["libs/trading-view/src/index.ts"],
"@vegaprotocol/types": ["libs/types/src/index.ts"], "@vegaprotocol/types": ["libs/types/src/index.ts"],
"@vegaprotocol/ui-toolkit": ["libs/ui-toolkit/src/index.ts"], "@vegaprotocol/ui-toolkit": ["libs/ui-toolkit/src/index.ts"],
"@vegaprotocol/utils": ["libs/utils/src/index.ts"], "@vegaprotocol/utils": ["libs/utils/src/index.ts"],

View File

@ -6643,6 +6643,31 @@
"@svgr/plugin-jsx" "8.1.0" "@svgr/plugin-jsx" "8.1.0"
"@svgr/plugin-svgo" "8.1.0" "@svgr/plugin-svgo" "8.1.0"
"@swc-node/core@^1.10.6":
version "1.10.6"
resolved "https://registry.yarnpkg.com/@swc-node/core/-/core-1.10.6.tgz#5c2af68bd4c9c8f5d91178a724af341a4402f5b6"
integrity sha512-lDIi/rPosmKIknWzvs2/Fi9zWRtbkx8OJ9pQaevhsoGzJSal8Pd315k1W5AIrnknfdAB4HqRN12fk6AhqnrEEw==
"@swc-node/register@~1.6.7":
version "1.6.8"
resolved "https://registry.yarnpkg.com/@swc-node/register/-/register-1.6.8.tgz#4e2402b42ae5b538d5041e0c4d86d9c3c8d5b323"
integrity sha512-74ijy7J9CWr1Z88yO+ykXphV29giCrSpANQPQRooE0bObpkTO1g4RzQovIfbIaniBiGDDVsYwDoQ3FIrCE8HcQ==
dependencies:
"@swc-node/core" "^1.10.6"
"@swc-node/sourcemap-support" "^0.3.0"
colorette "^2.0.19"
debug "^4.3.4"
pirates "^4.0.5"
tslib "^2.5.0"
"@swc-node/sourcemap-support@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@swc-node/sourcemap-support/-/sourcemap-support-0.3.0.tgz#e8a0d139bd3e8db39635f63fde43dbe6c39237cc"
integrity sha512-gqBJSmJMWomZFxlppaKea7NeAqFrDrrS0RMt24No92M3nJWcyI9YKGEQKl+EyJqZ5gh6w1s0cTklMHMzRwA1NA==
dependencies:
source-map-support "^0.5.21"
tslib "^2.5.0"
"@swc/cli@^0.1.62": "@swc/cli@^0.1.62":
version "0.1.62" version "0.1.62"
resolved "https://registry.yarnpkg.com/@swc/cli/-/cli-0.1.62.tgz#6442fde2fcf75175a300fb4fcf30f8c60bbb3ab3" resolved "https://registry.yarnpkg.com/@swc/cli/-/cli-0.1.62.tgz#6442fde2fcf75175a300fb4fcf30f8c60bbb3ab3"
@ -7415,10 +7440,10 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react-dom@18.2.9": "@types/react-dom@18.2.14":
version "18.2.9" version "18.2.14"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.9.tgz#c4ce3c7c91a134e1bff58692aa2d2f2f4029c38b" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.14.tgz#c01ba40e5bb57fc1dc41569bb3ccdb19eab1c539"
integrity sha512-6nNhVzZ9joQ6F7lozrASuQKC0Kf6ArYMU+DqA2ZrUbB+d+9lC6ZLn1GxiEBI1edmAwvTULtuJ6uPZpv3XudwUg== integrity sha512-V835xgdSVmyQmI1KLV2BEIUgqEuinxp9O4G6g3FqO/SqLac049E53aysv0oEFD2kHfejeKU+ZqL2bcFWj9gLAQ==
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
@ -7484,10 +7509,10 @@
"@types/scheduler" "*" "@types/scheduler" "*"
csstype "^3.0.2" csstype "^3.0.2"
"@types/react@18.2.24": "@types/react@18.2.33":
version "18.2.24" version "18.2.33"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.24.tgz#3c7d68c02e0205a472f04abe4a0c1df35d995c05" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.33.tgz#055356243dc4350a9ee6c6a2c07c5cae12e38877"
integrity sha512-Ee0Jt4sbJxMu1iDcetZEIKQr99J1Zfb6D4F3qfUWoR1JpInkY1Wdg4WwCyBjL257D0+jGqSl1twBjV8iCaC0Aw== integrity sha512-v+I7S+hu3PIBoVkKGpSYYpiBT1ijqEzWpzQD62/jm4K74hPpSP7FF9BnKG6+fg2+62weJYkkBWDJlZt5JO/9hg==
dependencies: dependencies:
"@types/prop-types" "*" "@types/prop-types" "*"
"@types/scheduler" "*" "@types/scheduler" "*"
@ -10228,7 +10253,7 @@ colorette@^1.1.0:
resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.4.0.tgz#5190fbb87276259a86ad700bff2c6d6faa3fca40" resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.4.0.tgz#5190fbb87276259a86ad700bff2c6d6faa3fca40"
integrity sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g== integrity sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==
colorette@^2.0.10, colorette@^2.0.16, colorette@^2.0.20: colorette@^2.0.10, colorette@^2.0.16, colorette@^2.0.19, colorette@^2.0.20:
version "2.0.20" version "2.0.20"
resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a"
integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==
@ -20355,7 +20380,7 @@ source-map-support@0.5.19:
buffer-from "^1.0.0" buffer-from "^1.0.0"
source-map "^0.6.0" source-map "^0.6.0"
source-map-support@^0.5.16, source-map-support@~0.5.20: source-map-support@^0.5.16, source-map-support@^0.5.21, source-map-support@~0.5.20:
version "0.5.21" version "0.5.21"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==