1038 lines
34 KiB
TypeScript
1038 lines
34 KiB
TypeScript
import {
|
|
useEffect,
|
|
useRef,
|
|
useMemo,
|
|
useState,
|
|
useCallback,
|
|
Fragment,
|
|
} from 'react'
|
|
import {
|
|
widget,
|
|
ChartingLibraryWidgetOptions,
|
|
IChartingLibraryWidget,
|
|
ResolutionString,
|
|
IOrderLineAdapter,
|
|
AvailableSaveloadVersions,
|
|
IExecutionLineAdapter,
|
|
Direction,
|
|
} from '@public/charting_library'
|
|
import mangoStore from '@store/mangoStore'
|
|
import { useViewport } from 'hooks/useViewport'
|
|
import { SHOW_ORDER_LINES_KEY, TV_USER_ID_KEY } from 'utils/constants'
|
|
import { COLORS } from 'styles/colors'
|
|
import { useTranslation } from 'next-i18next'
|
|
import { notify } from 'utils/notifications'
|
|
import {
|
|
PerpMarket,
|
|
PerpOrder,
|
|
Serum3Market,
|
|
Serum3Side,
|
|
TokenConditionalSwap,
|
|
} from '@blockworks-foundation/mango-v4'
|
|
import { Order } from '@project-serum/serum/lib/market'
|
|
import { PublicKey } from '@solana/web3.js'
|
|
import useLocalStorageState from 'hooks/useLocalStorageState'
|
|
import {
|
|
floorToDecimal,
|
|
formatNumericValue,
|
|
getDecimalCount,
|
|
} from 'utils/numbers'
|
|
import { BN } from '@coral-xyz/anchor'
|
|
import Datafeed from 'apis/datafeed'
|
|
// import PerpDatafeed from 'apis/mngo/datafeed'
|
|
import { CombinedTradeHistory, isMangoError } from 'types'
|
|
import { formatPrice } from 'apis/birdeye/helpers'
|
|
import useTradeHistory from 'hooks/useTradeHistory'
|
|
import dayjs from 'dayjs'
|
|
import ModifyTvOrderModal from '@components/modals/ModifyTvOrderModal'
|
|
import { findSerum3MarketPkInOpenOrders } from './OpenOrders'
|
|
import { Transition } from '@headlessui/react'
|
|
import useThemeWrapper from 'hooks/useThemeWrapper'
|
|
import { handleCancelTriggerOrder } from '@components/swap/SwapTriggerOrders'
|
|
|
|
export interface ChartContainerProps {
|
|
container: ChartingLibraryWidgetOptions['container']
|
|
symbol: ChartingLibraryWidgetOptions['symbol']
|
|
interval: ChartingLibraryWidgetOptions['interval']
|
|
datafeedUrl: string
|
|
libraryPath: ChartingLibraryWidgetOptions['library_path']
|
|
chartsStorageUrl: ChartingLibraryWidgetOptions['charts_storage_url']
|
|
chartsStorageApiVersion: ChartingLibraryWidgetOptions['charts_storage_api_version']
|
|
clientId: ChartingLibraryWidgetOptions['client_id']
|
|
userId: ChartingLibraryWidgetOptions['user_id']
|
|
fullscreen: ChartingLibraryWidgetOptions['fullscreen']
|
|
autosize: ChartingLibraryWidgetOptions['autosize']
|
|
studiesOverrides: ChartingLibraryWidgetOptions['studies_overrides']
|
|
theme: string
|
|
}
|
|
|
|
function hexToRgb(hex: string) {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
|
return result
|
|
? `rgb(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(
|
|
result[3],
|
|
16,
|
|
)})`
|
|
: null
|
|
}
|
|
|
|
const getTriggerOrders = () => {
|
|
const mangoAccount = mangoStore.getState().mangoAccount.current
|
|
if (!mangoAccount) return []
|
|
const triggerOrders = mangoAccount.tokenConditionalSwaps.filter(
|
|
(tcs) => tcs.isConfigured,
|
|
)
|
|
return triggerOrders
|
|
}
|
|
|
|
const TradingViewChart = () => {
|
|
const { t } = useTranslation(['common', 'tv-chart', 'trade'])
|
|
const { theme } = useThemeWrapper()
|
|
const { isMobile } = useViewport()
|
|
const [chartReady, setChartReady] = useState(false)
|
|
const [orderToModify, setOrderToModify] = useState<Order | PerpOrder | null>(
|
|
null,
|
|
)
|
|
const [modifiedPrice, setModifiedPrice] = useState('')
|
|
const [showOrderLinesLocalStorage, toggleShowOrderLinesLocalStorage] =
|
|
useLocalStorageState(SHOW_ORDER_LINES_KEY, true)
|
|
const [showOrderLines, toggleShowOrderLines] = useState(
|
|
showOrderLinesLocalStorage,
|
|
)
|
|
const tradeExecutions = mangoStore((s) => s.tradingView.tradeExecutions)
|
|
const themeData = mangoStore((s) => s.themeData)
|
|
const { data: combinedTradeHistory, isLoading: loadingTradeHistory } =
|
|
useTradeHistory()
|
|
const [showTradeExecutions, toggleShowTradeExecutions] = useState(false)
|
|
const [showThemeEasterEgg, toggleShowThemeEasterEgg] = useState(false)
|
|
const [cachedTradeHistory, setCachedTradeHistory] =
|
|
useState(combinedTradeHistory)
|
|
const [userId] = useLocalStorageState(TV_USER_ID_KEY, '')
|
|
const selectedMarketName = mangoStore((s) => s.selectedMarket.current?.name)
|
|
|
|
const defaultProps = useMemo(() => {
|
|
const initialMktName = mangoStore.getState().selectedMarket.current?.name
|
|
return {
|
|
symbol: initialMktName,
|
|
interval: '60' as ResolutionString,
|
|
theme: 'Dark',
|
|
container: 'tv_chart_container',
|
|
libraryPath: '/charting_library/',
|
|
chartsStorageUrl: 'https://tv-backend-v4.herokuapp.com',
|
|
chartsStorageApiVersion: '1.1' as AvailableSaveloadVersions,
|
|
clientId: 'mango.markets',
|
|
fullscreen: false,
|
|
autosize: true,
|
|
studiesOverrides: {
|
|
'volume.volume.color.0': COLORS.DOWN[theme],
|
|
'volume.volume.color.1': COLORS.UP[theme],
|
|
'volume.precision': 4,
|
|
},
|
|
}
|
|
}, [theme])
|
|
|
|
const tvWidgetRef = useRef<IChartingLibraryWidget>()
|
|
const orderLinesButtonRef = useRef<HTMLElement>()
|
|
|
|
// Sets the "symbol" in trading view which is used to fetch chart data via the datafeed
|
|
useEffect(() => {
|
|
const group = mangoStore.getState().group
|
|
let mktAddress = 'So11111111111111111111111111111111111111112'
|
|
|
|
if (
|
|
group &&
|
|
selectedMarketName &&
|
|
!selectedMarketName?.toLowerCase().includes('perp')
|
|
) {
|
|
mktAddress = group
|
|
.getSerum3MarketByName(selectedMarketName)
|
|
.serumMarketExternal.toString()
|
|
} else if (group && selectedMarketName) {
|
|
mktAddress = group
|
|
.getPerpMarketByName(selectedMarketName)
|
|
.publicKey.toString()
|
|
}
|
|
|
|
if (tvWidgetRef.current && chartReady && mktAddress && group) {
|
|
try {
|
|
tvWidgetRef.current.setSymbol(
|
|
mktAddress,
|
|
tvWidgetRef.current.activeChart().resolution(),
|
|
() => {
|
|
if (showOrderLinesLocalStorage) {
|
|
const { openOrders } = mangoStore.getState().mangoAccount
|
|
deleteLines()
|
|
const triggerOrders = getTriggerOrders()
|
|
drawLinesForMarket(openOrders, triggerOrders)
|
|
}
|
|
return
|
|
},
|
|
)
|
|
} catch (e) {
|
|
console.warn('Trading View change symbol error: ', e)
|
|
}
|
|
}
|
|
}, [chartReady, selectedMarketName, showOrderLinesLocalStorage])
|
|
|
|
useEffect(() => {
|
|
if (showOrderLines !== showOrderLinesLocalStorage) {
|
|
toggleShowOrderLinesLocalStorage(showOrderLines)
|
|
}
|
|
}, [
|
|
showOrderLines,
|
|
showOrderLinesLocalStorage,
|
|
toggleShowOrderLinesLocalStorage,
|
|
theme,
|
|
])
|
|
|
|
const deleteLines = useCallback(() => {
|
|
const set = mangoStore.getState().set
|
|
const orderLines = mangoStore.getState().tradingView.orderLines
|
|
if (orderLines.size > 0) {
|
|
orderLines?.forEach((value: IOrderLineAdapter, key: string | BN) => {
|
|
orderLines.get(key)?.remove()
|
|
})
|
|
|
|
set((state) => {
|
|
state.tradingView.orderLines = new Map()
|
|
})
|
|
}
|
|
}, [])
|
|
|
|
const getOrderDecimals = useCallback(() => {
|
|
const selectedMarket = mangoStore.getState().selectedMarket.current
|
|
let minOrderDecimals = 4
|
|
let tickSizeDecimals = 2
|
|
if (!selectedMarket) return [minOrderDecimals, tickSizeDecimals]
|
|
if (selectedMarket instanceof PerpMarket) {
|
|
minOrderDecimals = getDecimalCount(selectedMarket.minOrderSize)
|
|
tickSizeDecimals = getDecimalCount(selectedMarket.tickSize)
|
|
} else {
|
|
const group = mangoStore.getState().group
|
|
const market = group?.getSerum3ExternalMarket(
|
|
selectedMarket.serumMarketExternal,
|
|
)
|
|
if (market) {
|
|
minOrderDecimals = getDecimalCount(market.minOrderSize)
|
|
tickSizeDecimals = getDecimalCount(market.tickSize)
|
|
}
|
|
}
|
|
return [minOrderDecimals, tickSizeDecimals]
|
|
}, [])
|
|
|
|
const cancelSpotOrder = useCallback(
|
|
async (o: Order) => {
|
|
const client = mangoStore.getState().client
|
|
const group = mangoStore.getState().group
|
|
const mangoAccount = mangoStore.getState().mangoAccount.current
|
|
const actions = mangoStore.getState().actions
|
|
if (!group || !mangoAccount) return
|
|
const marketPk = findSerum3MarketPkInOpenOrders(o)
|
|
if (!marketPk) return
|
|
const market = group.getSerum3MarketByExternalMarket(
|
|
new PublicKey(marketPk),
|
|
)
|
|
try {
|
|
const tx = await client.serum3CancelOrder(
|
|
group,
|
|
mangoAccount,
|
|
market!.serumMarketExternal,
|
|
o.side === 'buy' ? Serum3Side.bid : Serum3Side.ask,
|
|
o.orderId,
|
|
)
|
|
|
|
actions.fetchOpenOrders()
|
|
notify({
|
|
type: 'success',
|
|
title: 'Transaction successful',
|
|
txid: tx.signature,
|
|
})
|
|
} catch (e) {
|
|
console.error('Error canceling', e)
|
|
if (!isMangoError(e)) return
|
|
notify({
|
|
title: t('trade:cancel-order-error'),
|
|
description: e.message,
|
|
txid: e.txid,
|
|
type: 'error',
|
|
})
|
|
}
|
|
},
|
|
[t, findSerum3MarketPkInOpenOrders],
|
|
)
|
|
|
|
const cancelPerpOrder = useCallback(
|
|
async (o: PerpOrder) => {
|
|
const client = mangoStore.getState().client
|
|
const group = mangoStore.getState().group
|
|
const mangoAccount = mangoStore.getState().mangoAccount.current
|
|
const actions = mangoStore.getState().actions
|
|
if (!group || !mangoAccount) return
|
|
try {
|
|
const tx = await client.perpCancelOrder(
|
|
group,
|
|
mangoAccount,
|
|
o.perpMarketIndex,
|
|
o.orderId,
|
|
)
|
|
actions.fetchOpenOrders()
|
|
notify({
|
|
type: 'success',
|
|
title: 'Transaction successful',
|
|
txid: tx.signature,
|
|
})
|
|
} catch (e) {
|
|
console.error('Error canceling', e)
|
|
if (!isMangoError(e)) return
|
|
notify({
|
|
title: t('trade:cancel-order-error'),
|
|
description: e.message,
|
|
txid: e.txid,
|
|
type: 'error',
|
|
})
|
|
}
|
|
},
|
|
[t],
|
|
)
|
|
|
|
const drawLine = useCallback(
|
|
(order: Order | PerpOrder) => {
|
|
const side =
|
|
typeof order.side === 'string'
|
|
? t(order.side)
|
|
: 'bid' in order.side
|
|
? t('long')
|
|
: t('short')
|
|
const isLong =
|
|
side.toLowerCase() === 'buy' || side.toLowerCase() === 'long'
|
|
const isShort =
|
|
side.toLowerCase() === 'sell' || side.toLowerCase() === 'short'
|
|
const [minOrderDecimals, tickSizeDecimals] = getOrderDecimals()
|
|
const orderSizeUi: string = formatNumericValue(
|
|
order.size,
|
|
minOrderDecimals,
|
|
)
|
|
const sideColor = isLong ? COLORS.UP[theme] : COLORS.DOWN[theme]
|
|
if (!tvWidgetRef?.current?.chart()) return
|
|
return (
|
|
tvWidgetRef.current
|
|
.chart()
|
|
.createOrderLine({ disableUndo: false })
|
|
.onMove(function (this: IOrderLineAdapter) {
|
|
const currentOrderPrice = order.price
|
|
const updatedOrderPrice = this.getPrice()
|
|
const selectedMarketPrice =
|
|
mangoStore.getState().selectedMarket.markPrice
|
|
if (
|
|
(isLong && updatedOrderPrice > 1.05 * selectedMarketPrice) ||
|
|
(isShort && updatedOrderPrice < 0.95 * selectedMarketPrice)
|
|
) {
|
|
tvWidgetRef.current?.showNoticeDialog({
|
|
title: t('tv-chart:outside-range'),
|
|
body:
|
|
t('tv-chart:slippage-warning', {
|
|
updatedOrderPrice: updatedOrderPrice,
|
|
aboveBelow:
|
|
side == 'buy' || side === 'long'
|
|
? t('above')
|
|
: t('below'),
|
|
selectedMarketPrice: selectedMarketPrice,
|
|
}) +
|
|
'<p><p>' +
|
|
t('tv-chart:slippage-accept'),
|
|
callback: () => {
|
|
this.setPrice(currentOrderPrice)
|
|
},
|
|
})
|
|
} else {
|
|
setOrderToModify(order)
|
|
setModifiedPrice(
|
|
formatNumericValue(updatedOrderPrice, tickSizeDecimals),
|
|
)
|
|
}
|
|
})
|
|
.onCancel(function () {
|
|
tvWidgetRef.current?.showConfirmDialog({
|
|
title: t('tv-chart:cancel-order'),
|
|
body: t('tv-chart:cancel-order-details', {
|
|
marketName: selectedMarketName,
|
|
orderSize: orderSizeUi,
|
|
orderSide: side.toUpperCase(),
|
|
orderPrice: formatNumericValue(order.price, tickSizeDecimals),
|
|
}),
|
|
callback: (res) => {
|
|
if (res) {
|
|
if (order instanceof PerpOrder) {
|
|
cancelPerpOrder(order)
|
|
} else {
|
|
cancelSpotOrder(order)
|
|
}
|
|
}
|
|
},
|
|
})
|
|
})
|
|
.setPrice(order.price)
|
|
.setQuantity(orderSizeUi)
|
|
.setText(side.toUpperCase())
|
|
// .setTooltip(
|
|
// order.perpTrigger?.clientOrderId
|
|
// ? `${order.orderType} Order #: ${order.orderId}`
|
|
// : `Order #: ${order.orderId}`
|
|
// )
|
|
.setBodyTextColor(sideColor)
|
|
.setQuantityTextColor(sideColor)
|
|
.setCancelButtonIconColor(COLORS.FGD4[theme])
|
|
.setBodyBorderColor(sideColor)
|
|
.setQuantityBorderColor(sideColor)
|
|
.setCancelButtonBorderColor(sideColor)
|
|
.setBodyBackgroundColor(COLORS.BKG1[theme])
|
|
.setQuantityBackgroundColor(COLORS.BKG1[theme])
|
|
.setCancelButtonBackgroundColor(COLORS.BKG1[theme])
|
|
.setLineColor(sideColor)
|
|
.setLineLength(3)
|
|
.setLineWidth(1)
|
|
.setLineStyle(1)
|
|
)
|
|
},
|
|
[
|
|
cancelPerpOrder,
|
|
cancelSpotOrder,
|
|
selectedMarketName,
|
|
t,
|
|
theme,
|
|
getOrderDecimals,
|
|
],
|
|
)
|
|
|
|
const drawTriggerOrderLine = useCallback(
|
|
(order: TokenConditionalSwap) => {
|
|
const { group } = mangoStore.getState()
|
|
const selectedMarket = mangoStore.getState().selectedMarket.current
|
|
if (!group) return
|
|
const buyBank = group.getFirstBankByTokenIndex(order.buyTokenIndex)
|
|
const sellBank = group.getFirstBankByTokenIndex(order.sellTokenIndex)
|
|
const maxBuy = floorToDecimal(
|
|
order.getMaxBuyUi(group),
|
|
buyBank.mintDecimals,
|
|
).toNumber()
|
|
const maxSell = floorToDecimal(
|
|
order.getMaxSellUi(group),
|
|
sellBank.mintDecimals,
|
|
).toNumber()
|
|
let side: string
|
|
let orderSizeUi: number
|
|
if (maxBuy === 0 || maxBuy > maxSell) {
|
|
orderSizeUi = maxSell
|
|
side = 'sell'
|
|
} else {
|
|
orderSizeUi = maxBuy
|
|
side = 'buy'
|
|
}
|
|
const price = order.getThresholdPriceUi(group)
|
|
const isReducingShort = side.toLowerCase() === 'buy'
|
|
let orderType
|
|
if (selectedMarket && selectedMarket instanceof Serum3Market) {
|
|
const baseBank =
|
|
selectedMarket.baseTokenIndex === buyBank.tokenIndex
|
|
? buyBank
|
|
: sellBank
|
|
const quoteBank =
|
|
selectedMarket.quoteTokenIndex === buyBank.tokenIndex
|
|
? buyBank
|
|
: sellBank
|
|
const currentPrice = baseBank.uiPrice / quoteBank.uiPrice
|
|
orderType =
|
|
(isReducingShort && price > currentPrice) ||
|
|
(!isReducingShort && price < currentPrice)
|
|
? t('trade:stop-loss')
|
|
: t('trade:take-profit')
|
|
}
|
|
const [
|
|
// minOrderDecimals,
|
|
tickSizeDecimals,
|
|
] = getOrderDecimals()
|
|
const sideColor = isReducingShort ? COLORS.UP[theme] : COLORS.DOWN[theme]
|
|
|
|
if (!tvWidgetRef?.current?.chart()) return
|
|
return (
|
|
tvWidgetRef.current
|
|
.chart()
|
|
.createOrderLine({ disableUndo: false })
|
|
.onMove(function (this: IOrderLineAdapter) {
|
|
tvWidgetRef.current?.showNoticeDialog({
|
|
title: 'Edit trigger order',
|
|
body: 'Editing trigger orders is coming soon',
|
|
callback: () => this.setPrice(price),
|
|
})
|
|
})
|
|
.onCancel(function () {
|
|
tvWidgetRef.current?.showConfirmDialog({
|
|
title: t('tv-chart:cancel-order'),
|
|
body: t('tv-chart:cancel-order-details', {
|
|
marketName: selectedMarketName,
|
|
orderSize: orderSizeUi,
|
|
orderSide: side.toUpperCase(),
|
|
orderPrice: formatNumericValue(price, tickSizeDecimals),
|
|
}),
|
|
callback: (res) => {
|
|
if (res) {
|
|
handleCancelTriggerOrder(order.id)
|
|
}
|
|
},
|
|
})
|
|
})
|
|
.setPrice(price)
|
|
.setQuantity(orderSizeUi.toString())
|
|
.setText(orderType ? orderType.toUpperCase() : side.toUpperCase())
|
|
// .setTooltip(
|
|
// order.perpTrigger?.clientOrderId
|
|
// ? `${order.orderType} Order #: ${order.orderId}`
|
|
// : `Order #: ${order.orderId}`
|
|
// )
|
|
.setBodyTextColor(sideColor)
|
|
.setQuantityTextColor(sideColor)
|
|
.setCancelButtonIconColor(COLORS.FGD4[theme])
|
|
.setBodyBorderColor(sideColor)
|
|
.setQuantityBorderColor(sideColor)
|
|
.setCancelButtonBorderColor(sideColor)
|
|
.setBodyBackgroundColor(COLORS.BKG1[theme])
|
|
.setQuantityBackgroundColor(COLORS.BKG1[theme])
|
|
.setCancelButtonBackgroundColor(COLORS.BKG1[theme])
|
|
.setLineColor(sideColor)
|
|
.setLineLength(3)
|
|
.setLineWidth(1)
|
|
.setLineStyle(1)
|
|
)
|
|
},
|
|
[
|
|
cancelPerpOrder,
|
|
cancelSpotOrder,
|
|
selectedMarketName,
|
|
t,
|
|
theme,
|
|
getOrderDecimals,
|
|
],
|
|
)
|
|
|
|
const drawLinesForMarket = useCallback(
|
|
(
|
|
openOrders: Record<string, Order[] | PerpOrder[]>,
|
|
triggerOrders: TokenConditionalSwap[],
|
|
) => {
|
|
const set = mangoStore.getState().set
|
|
const newOrderLines = new Map()
|
|
const oOrders = Object.entries(openOrders).map(([marketPk, orders]) => ({
|
|
orders,
|
|
marketPk,
|
|
}))
|
|
const selectedMarket = mangoStore.getState().selectedMarket.current
|
|
if (oOrders?.length) {
|
|
const selectedMarketPk =
|
|
selectedMarket instanceof Serum3Market
|
|
? selectedMarket?.serumMarketExternal.toString()
|
|
: selectedMarket?.publicKey.toString()
|
|
for (const { orders, marketPk } of oOrders) {
|
|
if (marketPk === selectedMarketPk) {
|
|
for (const order of orders) {
|
|
newOrderLines.set(order.orderId.toString(), drawLine(order))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (triggerOrders.length && selectedMarket instanceof Serum3Market) {
|
|
const { baseTokenIndex, quoteTokenIndex } = selectedMarket
|
|
for (const triggerOrder of triggerOrders) {
|
|
const { buyTokenIndex, sellTokenIndex } = triggerOrder
|
|
|
|
if (
|
|
(baseTokenIndex === buyTokenIndex ||
|
|
quoteTokenIndex === buyTokenIndex) &&
|
|
(baseTokenIndex === sellTokenIndex ||
|
|
quoteTokenIndex === sellTokenIndex)
|
|
) {
|
|
newOrderLines.set(
|
|
triggerOrder.id.toString(),
|
|
drawTriggerOrderLine(triggerOrder),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
set((state) => {
|
|
state.tradingView.orderLines = newOrderLines
|
|
})
|
|
},
|
|
[drawLine],
|
|
)
|
|
|
|
const toggleOrderLines = useCallback(
|
|
(el: HTMLElement) => {
|
|
toggleShowOrderLines((prevState: boolean) => !prevState)
|
|
if (el.style.color === hexToRgb(COLORS.ACTIVE[theme])) {
|
|
deleteLines()
|
|
el.style.color = COLORS.FGD4[theme]
|
|
} else {
|
|
const openOrders = mangoStore.getState().mangoAccount.openOrders
|
|
const triggerOrders = getTriggerOrders()
|
|
drawLinesForMarket(openOrders, triggerOrders)
|
|
el.style.color = COLORS.ACTIVE[theme]
|
|
}
|
|
},
|
|
[drawLinesForMarket, deleteLines, theme],
|
|
)
|
|
|
|
const closeModifyOrderModal = useCallback(() => {
|
|
const openOrders = mangoStore.getState().mangoAccount.openOrders
|
|
const triggerOrders = getTriggerOrders()
|
|
setOrderToModify(null)
|
|
deleteLines()
|
|
drawLinesForMarket(openOrders, triggerOrders)
|
|
}, [deleteLines, drawLinesForMarket])
|
|
|
|
const toggleTradeExecutions = useCallback(
|
|
(el: HTMLElement) => {
|
|
toggleShowTradeExecutions((prevState) => !prevState)
|
|
if (el.style.color === hexToRgb(COLORS.ACTIVE[theme])) {
|
|
el.style.color = COLORS.FGD4[theme]
|
|
} else {
|
|
el.style.color = COLORS.ACTIVE[theme]
|
|
}
|
|
},
|
|
[theme],
|
|
)
|
|
|
|
const toggleThemeEasterEgg = useCallback(
|
|
(el: HTMLElement) => {
|
|
toggleShowThemeEasterEgg((prevState) => !prevState)
|
|
if (el.style.color === hexToRgb(COLORS.ACTIVE[theme])) {
|
|
el.style.color = COLORS.FGD4[theme]
|
|
} else {
|
|
el.style.color = COLORS.ACTIVE[theme]
|
|
}
|
|
},
|
|
[theme],
|
|
)
|
|
|
|
const createOLButton = useCallback(() => {
|
|
if (!tvWidgetRef?.current) return
|
|
const button = tvWidgetRef.current.createButton()
|
|
if (!button) {
|
|
return
|
|
}
|
|
button.textContent = 'OL'
|
|
button.setAttribute('title', t('tv-chart:toggle-order-line'))
|
|
button.addEventListener('click', () => toggleOrderLines(button))
|
|
if (showOrderLinesLocalStorage) {
|
|
button.style.color = COLORS.ACTIVE[theme]
|
|
} else {
|
|
button.style.color = COLORS.FGD4[theme]
|
|
}
|
|
orderLinesButtonRef.current = button
|
|
}, [t, toggleOrderLines, tvWidgetRef, showTradeExecutions, theme])
|
|
|
|
const createTEButton = useCallback(() => {
|
|
if (!tvWidgetRef?.current) return
|
|
const button = tvWidgetRef.current.createButton()
|
|
if (!button) {
|
|
return
|
|
}
|
|
button.textContent = 'TE'
|
|
button.setAttribute('title', t('tv-chart:toggle-trade-executions'))
|
|
button.addEventListener('click', () => toggleTradeExecutions(button))
|
|
if (showTradeExecutions) {
|
|
button.style.color = COLORS.ACTIVE[theme]
|
|
} else {
|
|
button.style.color = COLORS.FGD4[theme]
|
|
}
|
|
}, [t, toggleTradeExecutions, tvWidgetRef, showTradeExecutions, theme])
|
|
|
|
const createEasterEggButton = useCallback(() => {
|
|
if (!tvWidgetRef?.current) return
|
|
const button = tvWidgetRef.current.createButton()
|
|
if (!button) {
|
|
return
|
|
}
|
|
button.textContent = theme.toUpperCase()
|
|
button.addEventListener('click', () => toggleThemeEasterEgg(button))
|
|
if (showThemeEasterEgg) {
|
|
button.style.color = COLORS.ACTIVE[theme]
|
|
} else {
|
|
button.style.color = COLORS.FGD4[theme]
|
|
}
|
|
}, [toggleThemeEasterEgg, tvWidgetRef, showThemeEasterEgg, theme])
|
|
|
|
useEffect(() => {
|
|
if (window) {
|
|
let chartStyleOverrides = {
|
|
'paneProperties.background': COLORS.BKG1[theme],
|
|
'paneProperties.backgroundType': 'solid',
|
|
'paneProperties.legendProperties.showBackground': false,
|
|
'paneProperties.legendProperties.showStudyTitles': false,
|
|
'scalesProperties.showStudyLastValue': false,
|
|
'scalesProperties.fontSize': 11,
|
|
'scalesProperties.lineColor': COLORS.BKG4[theme],
|
|
}
|
|
|
|
const mainSeriesProperties = [
|
|
'candleStyle',
|
|
'hollowCandleStyle',
|
|
'haStyle',
|
|
'barStyle',
|
|
]
|
|
|
|
mainSeriesProperties.forEach((prop) => {
|
|
chartStyleOverrides = {
|
|
...chartStyleOverrides,
|
|
[`mainSeriesProperties.${prop}.barColorsOnPrevClose`]: true,
|
|
[`mainSeriesProperties.${prop}.drawWick`]: true,
|
|
[`mainSeriesProperties.${prop}.drawBorder`]: true,
|
|
[`mainSeriesProperties.${prop}.upColor`]: COLORS.UP[theme],
|
|
[`mainSeriesProperties.${prop}.downColor`]: COLORS.DOWN[theme],
|
|
[`mainSeriesProperties.${prop}.borderColor`]: COLORS.UP[theme],
|
|
[`mainSeriesProperties.${prop}.borderUpColor`]: COLORS.UP[theme],
|
|
[`mainSeriesProperties.${prop}.borderDownColor`]: COLORS.DOWN[theme],
|
|
[`mainSeriesProperties.${prop}.wickUpColor`]: COLORS.UP[theme],
|
|
[`mainSeriesProperties.${prop}.wickDownColor`]: COLORS.DOWN[theme],
|
|
}
|
|
})
|
|
const mkt = mangoStore.getState().selectedMarket.current
|
|
const marketAddress =
|
|
(mkt instanceof Serum3Market
|
|
? mkt?.serumMarketExternal.toString()
|
|
: mkt?.publicKey.toString()) || 'Loading'
|
|
|
|
const widgetOptions: ChartingLibraryWidgetOptions = {
|
|
// debug: true,
|
|
symbol: marketAddress,
|
|
datafeed: Datafeed,
|
|
interval:
|
|
defaultProps.interval as ChartingLibraryWidgetOptions['interval'],
|
|
container:
|
|
defaultProps.container as ChartingLibraryWidgetOptions['container'],
|
|
library_path: defaultProps.libraryPath as string,
|
|
locale: 'en',
|
|
enabled_features: [
|
|
'hide_left_toolbar_by_default',
|
|
// userId ? 'study_templates' : '',
|
|
],
|
|
disabled_features: [
|
|
'use_localstorage_for_settings',
|
|
'timeframes_toolbar',
|
|
isMobile ? 'left_toolbar' : '',
|
|
'show_logo_on_all_charts',
|
|
'caption_buttons_text_if_possible',
|
|
'header_settings',
|
|
// 'header_chart_type',
|
|
'header_compare',
|
|
'compare_symbol',
|
|
'header_screenshot',
|
|
// 'header_widget_dom_node',
|
|
// 'header_widget',
|
|
!userId ? 'header_saveload' : '',
|
|
'header_undo_redo',
|
|
'header_interval_dialog_button',
|
|
'show_interval_dialog_on_key_press',
|
|
'header_symbol_search',
|
|
'popup_hints',
|
|
],
|
|
// eslint-disable-next-line
|
|
// @ts-ignore
|
|
custom_formatters: {
|
|
priceFormatterFactory: () => {
|
|
return {
|
|
format: (price) => {
|
|
// return the appropriate format
|
|
return formatPrice(price)
|
|
},
|
|
}
|
|
},
|
|
},
|
|
charts_storage_url: defaultProps.chartsStorageUrl,
|
|
charts_storage_api_version: defaultProps.chartsStorageApiVersion,
|
|
client_id: defaultProps.clientId,
|
|
user_id: userId ? userId : undefined,
|
|
fullscreen: defaultProps.fullscreen,
|
|
autosize: defaultProps.autosize,
|
|
studies_overrides: defaultProps.studiesOverrides,
|
|
theme:
|
|
theme === 'Light' || theme === 'Banana' || theme === 'Lychee'
|
|
? 'Light'
|
|
: themeData.tvChartTheme,
|
|
custom_css_url: '/styles/tradingview.css',
|
|
loading_screen: {
|
|
backgroundColor: COLORS.BKG1[theme],
|
|
},
|
|
overrides: {
|
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
...chartStyleOverrides,
|
|
},
|
|
}
|
|
|
|
console.log('creating new chart')
|
|
const tvWidget = new widget(widgetOptions)
|
|
tvWidget.onChartReady(() => {
|
|
tvWidgetRef.current = tvWidget
|
|
setChartReady(true)
|
|
tvWidget.headerReady().then(() => {
|
|
createOLButton()
|
|
createTEButton()
|
|
if (themeData.tvImagePath) {
|
|
createEasterEggButton()
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}, [theme, themeData, defaultProps, isMobile, userId])
|
|
|
|
// set a limit price from right click context menu
|
|
useEffect(() => {
|
|
if (chartReady && tvWidgetRef.current) {
|
|
tvWidgetRef.current.onContextMenu(function (unixtime, price) {
|
|
return [
|
|
{
|
|
position: 'top',
|
|
text: `Set limit price (${formatPrice(price)})`,
|
|
click: function () {
|
|
const set = mangoStore.getState().set
|
|
set((s) => {
|
|
s.tradeForm.price = price.toFixed(12)
|
|
})
|
|
},
|
|
},
|
|
{
|
|
position: 'top',
|
|
text: '-',
|
|
click: function () {
|
|
return
|
|
},
|
|
},
|
|
]
|
|
})
|
|
}
|
|
}, [chartReady, tvWidgetRef])
|
|
|
|
// update order lines if a user's open orders change
|
|
useEffect(() => {
|
|
let subscription
|
|
if (chartReady && tvWidgetRef?.current) {
|
|
subscription = mangoStore.subscribe(
|
|
(state) => state.mangoAccount,
|
|
(account) => {
|
|
if (showOrderLines) {
|
|
const openOrders = account.openOrders
|
|
const triggerOrders =
|
|
account.current?.tokenConditionalSwaps.filter(
|
|
(tcs) => tcs.isConfigured,
|
|
) || []
|
|
const orderLines = mangoStore.getState().tradingView.orderLines
|
|
tvWidgetRef.current?.onChartReady(() => {
|
|
let matchingOrderLines = 0
|
|
let openOrdersForMarket = 0
|
|
|
|
const oOrders = Object.entries(openOrders).map(
|
|
([marketPk, orders]) => ({
|
|
orders,
|
|
marketPk,
|
|
}),
|
|
)
|
|
|
|
for (const [key] of orderLines) {
|
|
oOrders?.forEach(({ orders }) => {
|
|
for (const order of orders) {
|
|
if (order.orderId == key) {
|
|
matchingOrderLines += 1
|
|
}
|
|
}
|
|
})
|
|
triggerOrders?.forEach((order) => {
|
|
if (order.id.toString() == key) {
|
|
matchingOrderLines += 1
|
|
}
|
|
})
|
|
}
|
|
|
|
const selectedMarket =
|
|
mangoStore.getState().selectedMarket.current
|
|
const selectedMarketPk =
|
|
selectedMarket instanceof Serum3Market
|
|
? selectedMarket?.serumMarketExternal.toString()
|
|
: selectedMarket?.publicKey.toString()
|
|
|
|
let ordersForMarket = 0
|
|
oOrders?.forEach(({ marketPk, orders }) => {
|
|
if (marketPk === selectedMarketPk) {
|
|
ordersForMarket = orders.length
|
|
}
|
|
})
|
|
let triggerOrdersForMarket = 0
|
|
triggerOrders.forEach((order) => {
|
|
if (selectedMarket instanceof Serum3Market) {
|
|
const { baseTokenIndex, quoteTokenIndex } = selectedMarket
|
|
const { buyTokenIndex, sellTokenIndex } = order
|
|
if (
|
|
(baseTokenIndex === buyTokenIndex ||
|
|
quoteTokenIndex === buyTokenIndex) &&
|
|
(baseTokenIndex === sellTokenIndex ||
|
|
quoteTokenIndex === sellTokenIndex)
|
|
) {
|
|
triggerOrdersForMarket += 1
|
|
}
|
|
}
|
|
})
|
|
|
|
openOrdersForMarket = ordersForMarket + triggerOrdersForMarket
|
|
|
|
tvWidgetRef.current?.activeChart().dataReady(() => {
|
|
if (
|
|
matchingOrderLines !== openOrdersForMarket ||
|
|
orderLines?.size !== matchingOrderLines
|
|
) {
|
|
deleteLines()
|
|
// might need to change
|
|
const triggerOrders = getTriggerOrders()
|
|
drawLinesForMarket(openOrders, triggerOrders)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
},
|
|
)
|
|
}
|
|
return subscription
|
|
}, [chartReady, deleteLines, drawLinesForMarket, showOrderLines])
|
|
|
|
const drawTradeExecutions = useCallback(
|
|
(trades: CombinedTradeHistory) => {
|
|
const newTradeExecutions = new Map()
|
|
const filteredTrades = trades
|
|
.filter((trade) => {
|
|
return trade.market.name === selectedMarketName
|
|
})
|
|
.slice()
|
|
for (let i = 0; i < filteredTrades.length; i++) {
|
|
const trade = filteredTrades[i]
|
|
const { side, size, price, market, liquidity, time } = trade
|
|
let baseSymbol
|
|
let quoteSymbol
|
|
if (market instanceof Serum3Market) {
|
|
baseSymbol = market.name.split('/')[0]
|
|
quoteSymbol = market.name.split('/')[1]
|
|
} else {
|
|
baseSymbol = market.name.split('-')[0]
|
|
}
|
|
const orderType = liquidity === 'Taker' ? t('market') : t('trade:limit')
|
|
try {
|
|
const arrowID = tvWidgetRef
|
|
.current!.chart()
|
|
.createExecutionShape()
|
|
.setTime(dayjs(time).unix())
|
|
.setDirection(side as Direction)
|
|
.setArrowHeight(6)
|
|
.setArrowColor(
|
|
side === 'buy' ? COLORS.UP[theme] : COLORS.DOWN[theme],
|
|
)
|
|
.setTooltip(
|
|
`${t(side)} ${orderType} ${size} ${baseSymbol} @ ${price}${
|
|
quoteSymbol ? ` ${quoteSymbol}` : ''
|
|
}`,
|
|
)
|
|
if (arrowID) {
|
|
try {
|
|
newTradeExecutions.set(`${trade.time}${i}`, arrowID)
|
|
} catch (error) {
|
|
console.log('could not set newTradeExecution')
|
|
}
|
|
} else {
|
|
console.log(
|
|
`Could not create execution shape for trade ${trade.time}${i}`,
|
|
)
|
|
}
|
|
} catch (error) {
|
|
console.log(`could not draw arrow: ${error}`)
|
|
}
|
|
}
|
|
return newTradeExecutions
|
|
},
|
|
[selectedMarketName, t, theme],
|
|
)
|
|
|
|
const removeTradeExecutions = useCallback(
|
|
(tradeExecutions: Map<string, IExecutionLineAdapter>) => {
|
|
const set = mangoStore.getState().set
|
|
if (chartReady && tvWidgetRef?.current) {
|
|
for (const val of tradeExecutions.values()) {
|
|
try {
|
|
val.remove()
|
|
} catch (error) {
|
|
console.log(`arrow ${val} could not be removed`)
|
|
}
|
|
}
|
|
}
|
|
set((s) => {
|
|
s.tradingView.tradeExecutions = new Map()
|
|
})
|
|
},
|
|
[chartReady, tvWidgetRef?.current],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!loadingTradeHistory && showTradeExecutions) {
|
|
setCachedTradeHistory(combinedTradeHistory)
|
|
}
|
|
}, [loadingTradeHistory, showTradeExecutions])
|
|
|
|
useEffect(() => {
|
|
if (cachedTradeHistory.length !== combinedTradeHistory.length) {
|
|
setCachedTradeHistory(combinedTradeHistory)
|
|
}
|
|
}, [combinedTradeHistory])
|
|
|
|
useEffect(() => {
|
|
removeTradeExecutions(tradeExecutions)
|
|
if (
|
|
showTradeExecutions &&
|
|
tvWidgetRef &&
|
|
tvWidgetRef.current &&
|
|
chartReady
|
|
) {
|
|
const set = mangoStore.getState().set
|
|
set((s) => {
|
|
s.tradingView.tradeExecutions = drawTradeExecutions(cachedTradeHistory)
|
|
})
|
|
}
|
|
}, [cachedTradeHistory, selectedMarketName, showTradeExecutions])
|
|
|
|
return (
|
|
<>
|
|
<Transition
|
|
show={showThemeEasterEgg}
|
|
as={Fragment}
|
|
enter="transition ease-in duration-500"
|
|
enterFrom="scale-0 opacity-0"
|
|
enterTo="scale-100 rotate-[-370deg] opacity-100"
|
|
leave="transition ease-out duration-500"
|
|
leaveFrom="scale-100 opacity-100"
|
|
leaveTo="scale-0 opacity-0"
|
|
>
|
|
<img
|
|
className="absolute right-20 top-8 h-auto w-36"
|
|
src={themeData.tvImagePath}
|
|
/>
|
|
</Transition>
|
|
<div
|
|
id={defaultProps.container as string}
|
|
className="tradingview-chart"
|
|
/>
|
|
{orderToModify ? (
|
|
<ModifyTvOrderModal
|
|
isOpen={!!orderToModify}
|
|
onClose={closeModifyOrderModal}
|
|
price={modifiedPrice}
|
|
order={orderToModify}
|
|
/>
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default TradingViewChart
|