diff --git a/src/constants/abacus.ts b/src/constants/abacus.ts index ac44cf3..f09fcac 100644 --- a/src/constants/abacus.ts +++ b/src/constants/abacus.ts @@ -278,6 +278,47 @@ export const ORDER_STATUS_STRINGS: Record, + Nullable +> = { + [AbacusOrderType.limit.name]: STRING_KEYS.LIMIT_ORDER_SHORT, + [AbacusOrderType.limit.rawValue]: STRING_KEYS.LIMIT_ORDER_SHORT, + + [AbacusOrderType.market.name]: STRING_KEYS.MARKET, + [AbacusOrderType.market.rawValue]: STRING_KEYS.MARKET, + + [AbacusOrderType.stopLimit.name]: STRING_KEYS.STOP_LIMIT, + [AbacusOrderType.stopLimit.rawValue]: STRING_KEYS.STOP_LIMIT, + + [AbacusOrderType.stopMarket.name]: STRING_KEYS.STOP_MARKET, + [AbacusOrderType.stopMarket.rawValue]: STRING_KEYS.STOP_MARKET, + + [AbacusOrderType.takeProfitLimit.name]: STRING_KEYS.TAKE_PROFIT, + [AbacusOrderType.takeProfitLimit.rawValue]: STRING_KEYS.TAKE_PROFIT, + + [AbacusOrderType.takeProfitMarket.name]: STRING_KEYS.TAKE_PROFIT_MARKET, + [AbacusOrderType.takeProfitMarket.rawValue]: STRING_KEYS.TAKE_PROFIT_MARKET, + + [AbacusOrderType.liquidated.name]: STRING_KEYS.LIQUIDATED, + [AbacusOrderType.liquidated.rawValue]: STRING_KEYS.LIQUIDATED, + + [AbacusOrderType.liquidation.name]: STRING_KEYS.LIQUIDATION, + [AbacusOrderType.liquidation.rawValue]: STRING_KEYS.LIQUIDATION, + + [AbacusOrderType.trailingStop.name]: STRING_KEYS.TRAILING_STOP, + [AbacusOrderType.trailingStop.rawValue]: STRING_KEYS.TRAILING_STOP, + + [AbacusOrderType.offsetting.name]: STRING_KEYS.OFFSETTING, + [AbacusOrderType.offsetting.rawValue]: STRING_KEYS.OFFSETTING, + + [AbacusOrderType.deleveraged.name]: STRING_KEYS.DELEVERAGED, + [AbacusOrderType.deleveraged.rawValue]: STRING_KEYS.DELEVERAGED, + + [AbacusOrderType.finalSettlement.name]: STRING_KEYS.FINAL_SETTLEMENT, + [AbacusOrderType.finalSettlement.rawValue]: STRING_KEYS.FINAL_SETTLEMENT, +}; + export const TRADE_TYPES: Record< KotlinIrEnumValues, Nullable diff --git a/src/hooks/tradingView/useTradingView.ts b/src/hooks/tradingView/useTradingView.ts index 68065d8..78348d4 100644 --- a/src/hooks/tradingView/useTradingView.ts +++ b/src/hooks/tradingView/useTradingView.ts @@ -1,19 +1,26 @@ -import { useEffect } from 'react'; -import { shallowEqual, useSelector } from 'react-redux'; +import { useEffect, useRef, useState } from 'react'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import isEmpty from 'lodash/isEmpty'; import { LanguageCode, ResolutionString, widget } from 'public/tradingview/charting_library'; import { DEFAULT_RESOLUTION } from '@/constants/candles'; -import { SUPPORTED_LOCALE_BASE_TAGS } from '@/constants/localization'; +import { SUPPORTED_LOCALE_BASE_TAGS, STRING_KEYS } from '@/constants/localization'; + import { LocalStorageKey } from '@/constants/localStorage'; -import { useDydxClient, useLocalStorage } from '@/hooks'; + +import { useDydxClient, useLocalStorage, useStringGetter } from '@/hooks'; import { store } from '@/state/_store'; import { getSelectedNetwork } from '@/state/appSelectors'; import { getAppTheme, getAppColorMode } from '@/state/configsSelectors'; import { getSelectedLocale } from '@/state/localizationSelectors'; -import { getCurrentMarketId, getMarketIds } from '@/state/perpetualsSelectors'; +import { setShowOrderLines } from '@/state/perpetuals'; +import { + getCurrentMarketId, + getShouldShowOrderLines, + getMarketIds, +} from '@/state/perpetualsSelectors'; import { getDydxDatafeed } from '@/lib/tradingView/dydxfeed'; import { getSavedResolution, getWidgetOptions, getWidgetOverrides } from '@/lib/tradingView/utils'; @@ -28,9 +35,16 @@ export const useTradingView = ({ tvWidgetRef: React.MutableRefObject; setIsChartReady: React.Dispatch>; }) => { - const marketId = useSelector(getCurrentMarketId); + const stringGetter = useStringGetter(); + const dispatch = useDispatch(); + const appTheme = useSelector(getAppTheme); const appColorMode = useSelector(getAppColorMode); + + const [displayOrdersButton, setDisplayOrdersButton] = useState(null); + const showOrderLines = useSelector(getShouldShowOrderLines); + + const marketId = useSelector(getCurrentMarketId); const marketIds = useSelector(getMarketIds, shallowEqual); const selectedLocale = useSelector(getSelectedLocale); const selectedNetwork = useSelector(getSelectedNetwork); @@ -44,12 +58,25 @@ export const useTradingView = ({ const savedResolution = getSavedResolution({ savedConfig: savedTvChartConfig }); const hasMarkets = marketIds.length > 0; + useEffect(() => { + if (displayOrdersButton) { + displayOrdersButton.onclick = () => { + const newShowOrderLinesState = !showOrderLines; + if (newShowOrderLinesState) { + displayOrdersButton?.classList?.add('order-lines-active'); + } else { + displayOrdersButton?.classList?.remove('order-lines-active'); + } + dispatch(setShowOrderLines({ showOrderLines: newShowOrderLinesState })); + }; + } + }, [displayOrdersButton, showOrderLines]); + useEffect(() => { if (hasMarkets && isClientConnected && marketId) { const widgetOptions = getWidgetOptions(); const widgetOverrides = getWidgetOverrides({ appTheme, appColorMode }); const options = { - // debug: true, ...widgetOptions, ...widgetOverrides, datafeed: getDydxDatafeed(store, getCandlesForDatafeed), @@ -63,6 +90,18 @@ export const useTradingView = ({ tvWidgetRef.current = tvChartWidget; tvWidgetRef.current.onChartReady(() => { + tvWidgetRef?.current?.headerReady().then(() => { + const button = tvWidgetRef?.current?.createButton(); + + if (button) { + button.innerHTML = `${stringGetter({ + key: STRING_KEYS.ORDER_LINES, + })}
`; + button.setAttribute('title', stringGetter({ key: STRING_KEYS.ORDER_LINES_TOOLTIP })); + setDisplayOrdersButton(button); + } + }); + tvWidgetRef?.current?.subscribe('onAutoSaveNeeded', () => tvWidgetRef?.current?.save((chartConfig: object) => setTvChartConfig(chartConfig)) ); diff --git a/src/hooks/tradingView/useTradingViewTheme.ts b/src/hooks/tradingView/useTradingViewTheme.ts index b0ed677..1c9b98d 100644 --- a/src/hooks/tradingView/useTradingViewTheme.ts +++ b/src/hooks/tradingView/useTradingViewTheme.ts @@ -5,8 +5,9 @@ import type { IChartingLibraryWidget, ThemeName } from 'public/tradingview/chart import { AppColorMode, AppTheme } from '@/state/configs'; import { getAppTheme, getAppColorMode } from '@/state/configsSelectors'; +import { getOrderLines } from '@/state/perpetualsSelectors'; -import { getWidgetOverrides } from '@/lib/tradingView/utils'; +import { getWidgetOverrides, getOrderLineColors } from '@/lib/tradingView/utils'; /** * @description Method to define a type guard and check that an element is an IFRAME @@ -31,14 +32,16 @@ export const useTradingViewTheme = ({ const appTheme: AppTheme = useSelector(getAppTheme); const appColorMode: AppColorMode = useSelector(getAppColorMode); + const orderLines = useSelector(getOrderLines); + useEffect(() => { if (tvWidget && isWidgetReady) { tvWidget .changeTheme?.( { [AppTheme.Classic]: '', - [AppTheme.Dark]: 'Dark', - [AppTheme.Light]: 'Light', + [AppTheme.Dark]: 'dark', + [AppTheme.Light]: 'light', }[appTheme] as ThemeName ) .then(() => { @@ -49,9 +52,17 @@ export const useTradingViewTheme = ({ if (isIFrame(frame) && frame.contentWindow) { const innerHtml = frame.contentWindow.document.documentElement; - - if (appTheme === AppTheme.Classic) { - innerHtml?.classList.remove('theme-dark', 'theme-light'); + switch (appTheme) { + case AppTheme.Classic: + innerHtml?.classList.remove('theme-dark', 'theme-light'); + break; + case AppTheme.Dark: + innerHtml?.classList.remove('theme-light'); + innerHtml?.classList.add('theme-dark'); + break; + case AppTheme.Light: + innerHtml?.classList.remove('theme-dark'); + innerHtml?.classList.add('theme-light'); } } } @@ -73,7 +84,22 @@ export const useTradingViewTheme = ({ 'volume.color.1': studies_overrides['volume.volume.color.1'], }); } + + // Necessary to update existing chart lines + Object.entries(orderLines).forEach(([key, line]) => { + const { orderColor, borderColor, backgroundColor, textColor, textButtonColor } = + getOrderLineColors({ side: key.split('-')[0], appTheme, appColorMode }); + + line + .setLineColor(orderColor) + .setQuantityBackgroundColor(orderColor) + .setQuantityBorderColor(borderColor) + .setBodyBackgroundColor(backgroundColor) + .setBodyBorderColor(borderColor) + .setBodyTextColor(textColor) + .setQuantityTextColor(textButtonColor); + }); }); } - }, [appTheme, appColorMode]); + }, [appTheme, appColorMode, isWidgetReady]); }; diff --git a/src/lib/tradingView/utils.ts b/src/lib/tradingView/utils.ts index 471a468..3e55537 100644 --- a/src/lib/tradingView/utils.ts +++ b/src/lib/tradingView/utils.ts @@ -1,6 +1,8 @@ +import { OrderSide } from '@dydxprotocol/v4-client-js'; + import { Candle, TradingViewBar, TradingViewSymbol } from '@/constants/candles'; -import type { AppTheme, AppColorMode } from '@/state/configs'; +import { AppTheme, type AppColorMode } from '@/state/configs'; import { Themes } from '@/styles/themes'; @@ -47,6 +49,30 @@ export const getHistorySlice = ({ return bars.filter(({ time }) => time >= fromMs); }; +export const getOrderLineColors = ({ + appTheme, + appColorMode, + side, +}: { + appTheme: AppTheme; + appColorMode: AppColorMode; + side: OrderSide; +}) => { + const theme = Themes[appTheme][appColorMode]; + const orderColor = { + [OrderSide.BUY]: theme.positive, + [OrderSide.SELL]: theme.negative, + }[side]; + + return { + orderColor, + borderColor: theme.borderDefault, + backgroundColor: theme.layer1, + textColor: theme.textTertiary, + textButtonColor: theme.textButton, + }; +}; + export const getWidgetOverrides = ({ appTheme, appColorMode, @@ -57,6 +83,7 @@ export const getWidgetOverrides = ({ const theme = Themes[appTheme][appColorMode]; return { + theme: appTheme === AppTheme.Dark ? 'dark' : AppTheme.Light ? 'light' : '', overrides: { 'paneProperties.background': theme.layer2, 'paneProperties.horzGridProperties.color': theme.layer3, diff --git a/src/state/accountSelectors.ts b/src/state/accountSelectors.ts index e7390eb..c756429 100644 --- a/src/state/accountSelectors.ts +++ b/src/state/accountSelectors.ts @@ -177,7 +177,7 @@ export const getMarketOrders = (state: RootState): { [marketId: string]: Subacco export const getCurrentMarketOrders = createSelector( [getCurrentMarketId, getMarketOrders], (currentMarketId, marketOrders): SubaccountOrder[] => - !currentMarketId ? [] : marketOrders[currentMarketId] + !currentMarketId ? [] : marketOrders[currentMarketId] ?? [] ); /** diff --git a/src/state/perpetuals.ts b/src/state/perpetuals.ts index 4a9f675..db47c95 100644 --- a/src/state/perpetuals.ts +++ b/src/state/perpetuals.ts @@ -1,6 +1,8 @@ import merge from 'lodash/merge'; import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import type { IOrderLineAdapter } from 'public/tradingview/charting_library'; + import type { MarketOrderbook, MarketTrade, @@ -35,6 +37,8 @@ export interface PerpetualsState { } >; historicalFundings: Record; + showOrderLines?: boolean; + orderLines: Record; } const initialState: PerpetualsState = { @@ -45,6 +49,8 @@ const initialState: PerpetualsState = { orderbooks: undefined, orderbooksMap: undefined, historicalFundings: {}, + showOrderLines: false, + orderLines: {}, }; const MAX_NUM_LIVE_TRADES = 100; @@ -146,6 +152,20 @@ export const perpetualsSlice = createSlice({ ) => { state.historicalFundings[action.payload.marketId] = action.payload.historicalFundings; }, + setShowOrderLines: ( + state: PerpetualsState, + action: PayloadAction<{ showOrderLines: boolean }> + ) => ({ + ...state, + showOrderLines: action.payload.showOrderLines, + }), + setOrderLines: ( + state: PerpetualsState, + action: PayloadAction<{ orderLines: Record }> + ) => ({ + ...state, + orderLines: action.payload.orderLines, + }), resetPerpetualsState: () => ({ ...initialState, @@ -163,5 +183,7 @@ export const { setOrderbook, setTvChartResolution, setHistoricalFundings, + setShowOrderLines, + setOrderLines, resetPerpetualsState, } = perpetualsSlice.actions; diff --git a/src/state/perpetualsSelectors.ts b/src/state/perpetualsSelectors.ts index 0c02a6b..3354bc3 100644 --- a/src/state/perpetualsSelectors.ts +++ b/src/state/perpetualsSelectors.ts @@ -153,3 +153,15 @@ export const getCurrentMarketNextFundingRate = createSelector( [getCurrentMarketData], (marketData) => marketData?.perpetual?.nextFundingRate ); + +/** + * + * @returns boolean on whether we should show order lines + */ +export const getShouldShowOrderLines = (state: RootState) => state.perpetuals.showOrderLines; + +/** + * + * @returns all order lines being shown on chart + */ +export const getOrderLines = (state: RootState) => state.perpetuals.orderLines; diff --git a/src/views/charts/TvChart.tsx b/src/views/charts/TvChart.tsx index f3586ff..61ff29b 100644 --- a/src/views/charts/TvChart.tsx +++ b/src/views/charts/TvChart.tsx @@ -1,28 +1,58 @@ import { useEffect, useRef, useState } from 'react'; import styled, { type AnyStyledComponent, css } from 'styled-components'; -import { useDispatch, useSelector } from 'react-redux'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; -import type { IChartingLibraryWidget, ResolutionString } from 'public/tradingview/charting_library'; +import { MustBigNumber } from '@/lib/numbers'; +import { getOrderLineColors } from '@/lib/tradingView/utils'; -import { DEFAULT_MARKETID } from '@/constants/markets'; +import type { + IChartingLibraryWidget, + IOrderLineAdapter, + ResolutionString, +} from 'public/tradingview/charting_library'; + +import { + AbacusOrderStatus, + AbacusOrderType, + KotlinIrEnumValues, + ORDER_TYPE_LABEL_MAPPING, +} from '@/constants/abacus'; import { DEFAULT_RESOLUTION, RESOLUTION_CHART_CONFIGS } from '@/constants/candles'; +import { DEFAULT_MARKETID } from '@/constants/markets'; + +import { useStringGetter } from '@/hooks'; import { useTradingView, useTradingViewTheme } from '@/hooks/tradingView'; import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; -import { setTvChartResolution } from '@/state/perpetuals'; - -import { getCurrentMarketId, getSelectedResolutionForMarket } from '@/state/perpetualsSelectors'; +import { getCurrentMarketOrders } from '@/state/accountSelectors'; +import { getAppTheme, getAppColorMode } from '@/state/configsSelectors'; +import { setOrderLines, setTvChartResolution } from '@/state/perpetuals'; +import { + getCurrentMarketId, + getOrderLines, + getShouldShowOrderLines, + getSelectedResolutionForMarket, +} from '@/state/perpetualsSelectors'; import { layoutMixins } from '@/styles/layoutMixins'; type TvWidget = IChartingLibraryWidget & { _id?: string; _ready?: boolean }; export const TvChart = () => { + const dispatch = useDispatch(); + const stringGetter = useStringGetter(); + const [isChartReady, setIsChartReady] = useState(false); - const dispatch = useDispatch(); + const appTheme = useSelector(getAppTheme); + const appColorMode = useSelector(getAppColorMode); + const currentMarketId: string = useSelector(getCurrentMarketId) || DEFAULT_MARKETID; + const currentMarketOrders = useSelector(getCurrentMarketOrders, shallowEqual); + + const showOrderLines = useSelector(getShouldShowOrderLines); + const orderLines = useSelector(getOrderLines); const selectedResolution: string = useSelector(getSelectedResolutionForMarket(currentMarketId)) || DEFAULT_RESOLUTION; @@ -49,6 +79,93 @@ export const TvChart = () => { tvWidget?.activeChart().setVisibleRange(newRange, { percentRightMargin: 10 }); }; + /** + * @description Hooks to handle state of show orders button + */ + + useEffect(() => { + if (!tvWidgetRef || !tvWidget || !isChartReady) { + return; + } + + tvWidget.onChartReady(() => { + tvWidget.chart().dataReady(() => { + if (showOrderLines) { + drawOrderLines(); + } else { + deleteOrderLines(); + } + }); + }); + }, [showOrderLines, currentMarketOrders]); + + const drawOrderLines = () => { + const updatedOrderLines: Record = {}; + currentMarketOrders.forEach( + ({ id, type, status, side, cancelReason, remainingSize, triggerPrice, price }) => { + const key = `${side.rawValue}-${id}`; + const orderType = type.rawValue as KotlinIrEnumValues; + const quantity = remainingSize ? remainingSize.toString() : ''; + + const orderString = stringGetter({ + key: ORDER_TYPE_LABEL_MAPPING[orderType] || '', + }); + const shouldShow = + !cancelReason && + (status === AbacusOrderStatus.open || status === AbacusOrderStatus.untriggered); + + const maybeOrderLine = key in orderLines ? orderLines[key] : null; + + if (maybeOrderLine) { + if (!shouldShow) { + maybeOrderLine.remove(); + } else if (maybeOrderLine.getQuantity() !== quantity) { + maybeOrderLine.setQuantity(quantity); + } + updatedOrderLines[key] = maybeOrderLine; + return; + } else if (!shouldShow) { + return; + } else { + const { orderColor, borderColor, backgroundColor, textColor, textButtonColor } = + getOrderLineColors({ side: side.rawValue, appTheme, appColorMode }); + + const orderPrice = triggerPrice ?? price; + + const orderLine = tvWidget + ?.chart() + .createOrderLine({ disableUndo: false }) + .setPrice(MustBigNumber(orderPrice).toNumber()) + .setQuantity(quantity) + .setText(orderString) + .setLineColor(orderColor) + .setQuantityBackgroundColor(orderColor) + .setQuantityBorderColor(borderColor) + .setBodyBackgroundColor(backgroundColor) + .setBodyBorderColor(borderColor) + .setBodyTextColor(textColor) + .setQuantityTextColor(textButtonColor); + + if (orderLine) { + updatedOrderLines[key] = orderLine; + } + } + } + ); + dispatch( + setOrderLines({ + orderLines: updatedOrderLines, + }) + ); + }; + + const deleteOrderLines = () => { + Object.values(orderLines).forEach((line) => { + line.remove(); + }); + dispatch(setOrderLines({ orderLines: {} })); + }; + useEffect(() => { if (chartResolution) { if (chartResolution !== selectedResolution) {