404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
import {
|
|
Group,
|
|
MangoAccount,
|
|
PerpMarket,
|
|
PerpOrderSide,
|
|
PerpOrderType,
|
|
Serum3Market,
|
|
Serum3OrderType,
|
|
Serum3SelfTradeBehavior,
|
|
Serum3Side,
|
|
} from '@blockworks-foundation/mango-v4'
|
|
import { HotKey } from '@components/settings/HotKeysSettings'
|
|
import mangoStore from '@store/mangoStore'
|
|
import { ReactNode, useCallback } from 'react'
|
|
import { GenericMarket, isMangoError } from 'types'
|
|
import { SOUND_SETTINGS_KEY } from 'utils/constants'
|
|
import { notify } from 'utils/notifications'
|
|
import { calculateLimitPriceForMarketOrder } from 'utils/tradeForm'
|
|
import { successSound } from './AdvancedTradeForm'
|
|
import useLocalStorageState from 'hooks/useLocalStorageState'
|
|
import { INITIAL_SOUND_SETTINGS } from '@components/settings/SoundSettings'
|
|
import useSelectedMarket from 'hooks/useSelectedMarket'
|
|
import { floorToDecimal, getDecimalCount } from 'utils/numbers'
|
|
import { useTranslation } from 'next-i18next'
|
|
import { useCustomHotkeys } from 'hooks/useCustomHotKeys'
|
|
import { HOTKEY_TEMPLATES } from '@components/modals/HotKeyModal'
|
|
import { handleCloseAll } from './CloseAllPositionsModal'
|
|
|
|
const set = mangoStore.getState().set
|
|
|
|
const calcBaseSize = (
|
|
orderDetails: HotKey,
|
|
maxSize: number,
|
|
oraclePrice: number,
|
|
quoteTokenIndex: number,
|
|
group: Group,
|
|
limitPrice?: number,
|
|
) => {
|
|
const { orderSize, orderSide, orderSizeType, orderType } = orderDetails
|
|
let baseSize: number
|
|
let quoteSize: number
|
|
if (orderSide === 'buy') {
|
|
// assumes USDC = $1 as tokenIndex is 0
|
|
if (!quoteTokenIndex) {
|
|
quoteSize =
|
|
orderSizeType === 'percentage'
|
|
? (Number(orderSize) / 100) * maxSize
|
|
: Number(orderSize)
|
|
} else {
|
|
// required for non USDC quote tokens
|
|
const quoteBank = group.getFirstBankByTokenIndex(quoteTokenIndex)
|
|
const quotePrice = quoteBank.uiPrice
|
|
const orderSizeInQuote = Number(orderSize) / quotePrice
|
|
quoteSize =
|
|
orderSizeType === 'percentage'
|
|
? (orderSizeInQuote / 100) * maxSize
|
|
: orderSizeInQuote
|
|
}
|
|
if (orderType === 'market') {
|
|
baseSize = quoteSize / oraclePrice
|
|
} else {
|
|
const price = limitPrice ? limitPrice : 0
|
|
baseSize = quoteSize / price
|
|
}
|
|
} else {
|
|
if (orderSizeType === 'percentage') {
|
|
baseSize = (Number(orderSize) / 100) * maxSize
|
|
} else {
|
|
if (orderType === 'market') {
|
|
baseSize = Number(orderSize) / oraclePrice
|
|
} else {
|
|
const price = limitPrice ? limitPrice : 0
|
|
baseSize = Number(orderSize) / price
|
|
}
|
|
}
|
|
}
|
|
return baseSize
|
|
}
|
|
|
|
const calcSpotMarketMax = (
|
|
mangoAccount: MangoAccount | undefined,
|
|
selectedMarket: GenericMarket | undefined,
|
|
side: string,
|
|
useMargin: boolean,
|
|
) => {
|
|
const spotBalances = mangoStore.getState().mangoAccount.spotBalances
|
|
const group = mangoStore.getState().group
|
|
if (!mangoAccount || !group || !selectedMarket) return 0
|
|
if (!(selectedMarket instanceof Serum3Market)) return 0
|
|
|
|
let leverageMax = 0
|
|
let spotMax = 0
|
|
try {
|
|
const isBuySide = side === 'buy'
|
|
leverageMax = mangoAccount[
|
|
isBuySide ? 'getMaxQuoteForSerum3BidUi' : 'getMaxBaseForSerum3AskUi'
|
|
](group, selectedMarket.serumMarketExternal)
|
|
const bank = group.getFirstBankByTokenIndex(
|
|
isBuySide
|
|
? selectedMarket.quoteTokenIndex
|
|
: selectedMarket.baseTokenIndex,
|
|
)
|
|
const balance = mangoAccount.getTokenBalanceUi(bank)
|
|
const unsettled = spotBalances[bank.mint.toString()]?.unsettled || 0
|
|
spotMax = balance + unsettled
|
|
return useMargin ? leverageMax : Math.max(spotMax, 0)
|
|
} catch (e) {
|
|
console.error('Error calculating max size: ', e)
|
|
return 0
|
|
}
|
|
}
|
|
|
|
const calcPerpMax = (
|
|
mangoAccount: MangoAccount,
|
|
selectedMarket: GenericMarket,
|
|
side: string,
|
|
) => {
|
|
const group = mangoStore.getState().group
|
|
if (
|
|
!mangoAccount ||
|
|
!group ||
|
|
!selectedMarket ||
|
|
selectedMarket instanceof Serum3Market
|
|
)
|
|
return 0
|
|
try {
|
|
if (side === 'buy') {
|
|
return mangoAccount.getMaxQuoteForPerpBidUi(
|
|
group,
|
|
selectedMarket.perpMarketIndex,
|
|
)
|
|
} else {
|
|
return mangoAccount.getMaxBaseForPerpAskUi(
|
|
group,
|
|
selectedMarket.perpMarketIndex,
|
|
)
|
|
}
|
|
} catch (e) {
|
|
console.error('Error calculating max leverage: ', e)
|
|
return 0
|
|
}
|
|
}
|
|
|
|
const TradeHotKeys = ({ children }: { children: ReactNode }) => {
|
|
const { t } = useTranslation(['common', 'settings', 'trade'])
|
|
const { price: oraclePrice, serumOrPerpMarket } = useSelectedMarket()
|
|
const [soundSettings] = useLocalStorageState(
|
|
SOUND_SETTINGS_KEY,
|
|
INITIAL_SOUND_SETTINGS,
|
|
)
|
|
|
|
const handleHotKeyPress = (hkOrder: HotKey) => {
|
|
if (hkOrder.custom === HOTKEY_TEMPLATES.CLOSE_ALL_PERP) {
|
|
notify({
|
|
type: 'info',
|
|
title: t('trade:placing-order'),
|
|
description: hkOrder.custom,
|
|
})
|
|
handleCloseAll()
|
|
} else {
|
|
handlePlaceOrder(hkOrder)
|
|
}
|
|
}
|
|
|
|
const handlePlaceOrder = useCallback(
|
|
async (hkOrder: HotKey) => {
|
|
const client = mangoStore.getState().client
|
|
const group = mangoStore.getState().group
|
|
const mangoAccount = mangoStore.getState().mangoAccount.current
|
|
const actions = mangoStore.getState().actions
|
|
const selectedMarket = mangoStore.getState().selectedMarket.current
|
|
const {
|
|
custom,
|
|
ioc,
|
|
orderPrice,
|
|
orderSide,
|
|
orderType,
|
|
postOnly,
|
|
reduceOnly,
|
|
margin,
|
|
} = hkOrder
|
|
|
|
if (!group || !mangoAccount || !serumOrPerpMarket || !selectedMarket)
|
|
return
|
|
try {
|
|
let baseSize: number
|
|
let price: number
|
|
|
|
if (
|
|
custom === HOTKEY_TEMPLATES.CLOSE_LONG ||
|
|
custom === HOTKEY_TEMPLATES.CLOSE_SHORT
|
|
) {
|
|
if (selectedMarket instanceof Serum3Market) {
|
|
const baseBank = group.getFirstBankByTokenIndex(
|
|
selectedMarket.baseTokenIndex,
|
|
)
|
|
const baseBalance = mangoAccount.getTokenBalanceUi(baseBank)
|
|
baseSize = baseBalance
|
|
} else {
|
|
const position = mangoAccount.getPerpPosition(
|
|
selectedMarket.perpMarketIndex,
|
|
)
|
|
const rawBaseSize = position?.getBasePositionUi(selectedMarket)
|
|
if (rawBaseSize) {
|
|
// increase size to make sure position is fully closed (reduceOnly is enabled)
|
|
baseSize = rawBaseSize * 2
|
|
} else {
|
|
notify({
|
|
type: 'error',
|
|
title: t('settings:error-no-position'),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// check correct side for close
|
|
if (custom === HOTKEY_TEMPLATES.CLOSE_LONG && baseSize < 0) {
|
|
notify({
|
|
type: 'error',
|
|
title: t('settings:error-no-long'),
|
|
})
|
|
return
|
|
}
|
|
if (custom === HOTKEY_TEMPLATES.CLOSE_SHORT && baseSize > 0) {
|
|
notify({
|
|
type: 'error',
|
|
title: t('settings:error-no-short'),
|
|
})
|
|
return
|
|
}
|
|
|
|
const orderbook = mangoStore.getState().selectedMarket.orderbook
|
|
price = calculateLimitPriceForMarketOrder(
|
|
orderbook,
|
|
baseSize,
|
|
orderSide,
|
|
)
|
|
} else {
|
|
const orderMax =
|
|
serumOrPerpMarket instanceof PerpMarket
|
|
? calcPerpMax(mangoAccount, selectedMarket, orderSide)
|
|
: calcSpotMarketMax(
|
|
mangoAccount,
|
|
selectedMarket,
|
|
orderSide,
|
|
margin,
|
|
)
|
|
const quoteTokenIndex =
|
|
selectedMarket instanceof PerpMarket
|
|
? 0
|
|
: selectedMarket.quoteTokenIndex
|
|
|
|
if (orderType === 'market') {
|
|
baseSize = calcBaseSize(
|
|
hkOrder,
|
|
orderMax,
|
|
oraclePrice,
|
|
quoteTokenIndex,
|
|
group,
|
|
)
|
|
const orderbook = mangoStore.getState().selectedMarket.orderbook
|
|
price = calculateLimitPriceForMarketOrder(
|
|
orderbook,
|
|
Math.abs(baseSize),
|
|
orderSide,
|
|
)
|
|
} else {
|
|
// change in price from oracle for limit order
|
|
const priceChange = (Number(orderPrice) / 100) * oraclePrice
|
|
// subtract price change for buy limit, add for sell limit
|
|
const rawPrice =
|
|
orderSide === 'buy'
|
|
? oraclePrice - priceChange
|
|
: oraclePrice + priceChange
|
|
price = floorToDecimal(
|
|
rawPrice,
|
|
getDecimalCount(serumOrPerpMarket.tickSize),
|
|
).toNumber()
|
|
baseSize = calcBaseSize(
|
|
hkOrder,
|
|
orderMax,
|
|
oraclePrice,
|
|
quoteTokenIndex,
|
|
group,
|
|
price,
|
|
)
|
|
}
|
|
}
|
|
|
|
const formattedSize = floorToDecimal(
|
|
Math.abs(baseSize),
|
|
getDecimalCount(serumOrPerpMarket.minOrderSize),
|
|
).toNumber()
|
|
|
|
// check if size > min order size
|
|
if (formattedSize < serumOrPerpMarket.minOrderSize) {
|
|
notify({
|
|
type: 'error',
|
|
title: t('settings:error-order-less-than-min'),
|
|
})
|
|
return
|
|
}
|
|
|
|
notify({
|
|
type: 'info',
|
|
title: t('trade:placing-order'),
|
|
description: custom
|
|
? custom
|
|
: `${t(orderSide)} ${formattedSize} ${selectedMarket.name} ${
|
|
orderType === 'limit'
|
|
? `${t('settings:at')} ${price}`
|
|
: `${t('settings:at')} ${t('market')}`
|
|
}`,
|
|
})
|
|
|
|
if (selectedMarket instanceof Serum3Market) {
|
|
const spotOrderType = ioc
|
|
? Serum3OrderType.immediateOrCancel
|
|
: postOnly && orderType !== 'market'
|
|
? Serum3OrderType.postOnly
|
|
: Serum3OrderType.limit
|
|
|
|
const tx = await client.serum3PlaceOrder(
|
|
group,
|
|
mangoAccount,
|
|
selectedMarket.serumMarketExternal,
|
|
orderSide === 'buy' ? Serum3Side.bid : Serum3Side.ask,
|
|
price,
|
|
formattedSize,
|
|
Serum3SelfTradeBehavior.decrementTake,
|
|
spotOrderType,
|
|
Date.now(),
|
|
10,
|
|
)
|
|
actions.fetchOpenOrders(true)
|
|
set((s) => {
|
|
s.successAnimation.trade = true
|
|
})
|
|
if (soundSettings['swap-success']) {
|
|
successSound.play()
|
|
}
|
|
notify({
|
|
type: 'success',
|
|
title: 'Transaction successful',
|
|
txid: tx.signature,
|
|
})
|
|
} else if (selectedMarket instanceof PerpMarket) {
|
|
const perpOrderType =
|
|
orderType === 'market'
|
|
? PerpOrderType.market
|
|
: ioc
|
|
? PerpOrderType.immediateOrCancel
|
|
: postOnly
|
|
? PerpOrderType.postOnly
|
|
: PerpOrderType.limit
|
|
|
|
const tx = await client.perpPlaceOrder(
|
|
group,
|
|
mangoAccount,
|
|
selectedMarket.perpMarketIndex,
|
|
orderSide === 'buy' ? PerpOrderSide.bid : PerpOrderSide.ask,
|
|
price,
|
|
formattedSize,
|
|
undefined, // maxQuoteQuantity
|
|
Date.now(),
|
|
perpOrderType,
|
|
selectedMarket.reduceOnly || reduceOnly,
|
|
undefined,
|
|
undefined,
|
|
)
|
|
actions.fetchOpenOrders(true)
|
|
set((s) => {
|
|
s.successAnimation.trade = true
|
|
})
|
|
if (soundSettings['swap-success']) {
|
|
successSound.play()
|
|
}
|
|
notify({
|
|
type: 'success',
|
|
title: 'Transaction successful',
|
|
txid: tx.signature,
|
|
})
|
|
}
|
|
} catch (e) {
|
|
console.error('Place trade error:', e)
|
|
if (!isMangoError(e)) return
|
|
notify({
|
|
title: 'There was an issue.',
|
|
description: e.message,
|
|
txid: e?.txid,
|
|
type: 'error',
|
|
})
|
|
}
|
|
},
|
|
[serumOrPerpMarket],
|
|
)
|
|
|
|
useCustomHotkeys(handleHotKeyPress)
|
|
|
|
return <>{children}</>
|
|
}
|
|
|
|
export default TradeHotKeys
|