Merge pull request #68 from blockworks-foundation/trade-volume-alert

add recent trade volume alert
This commit is contained in:
tylersssss 2023-02-11 15:21:13 -05:00 committed by GitHub
commit 20ba84e88a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 309 additions and 131 deletions

View File

@ -1,5 +1,13 @@
const Label = ({ text, optional }: { text: string; optional?: boolean }) => (
<p className="mb-2 text-left text-sm text-th-fgd-3">
const Label = ({
text,
optional,
className,
}: {
text: string
optional?: boolean
className?: string
}) => (
<p className={`mb-2 text-left text-sm text-th-fgd-3 ${className}`}>
{text}{' '}
{optional ? (
<span className="ml-1 text-xs text-th-fgd-4">(Optional)</span>

View File

@ -0,0 +1,123 @@
import { ModalProps } from '../../types/modal'
import Modal from '../shared/Modal'
import Button, { LinkButton } from '../shared/Button'
import { useTranslation } from 'next-i18next'
import useLocalStorageState from 'hooks/useLocalStorageState'
import { SOUND_SETTINGS_KEY, TRADE_VOLUME_ALERT_KEY } from 'utils/constants'
import Label from '@components/forms/Label'
import { useState } from 'react'
import Switch from '@components/forms/Switch'
import { INITIAL_SOUND_SETTINGS } from '@components/settings/SoundSettings'
import NumberFormat, { NumberFormatValues } from 'react-number-format'
import { Howl } from 'howler'
import { PlayIcon } from '@heroicons/react/20/solid'
const volumeAlertSound = new Howl({
src: ['/sounds/trade-buy.mp3'],
volume: 0.8,
})
export const DEFAULT_VOLUME_ALERT_SETTINGS = { seconds: 30, value: 10000 }
const INPUT_CLASSES =
'h-12 w-full rounded-md border border-th-input-border bg-th-input-bkg px-3 font-mono text-base text-th-fgd-1 focus:border-th-input-border-hover focus:outline-none md:hover:border-th-input-border-hover'
const TradeVolumeAlertModal = ({ isOpen, onClose }: ModalProps) => {
const { t } = useTranslation(['common', 'trade'])
const [soundSettings, setSoundSettings] = useLocalStorageState(
SOUND_SETTINGS_KEY,
INITIAL_SOUND_SETTINGS
)
const [alertSettings, setAlertSettings] = useLocalStorageState(
TRADE_VOLUME_ALERT_KEY,
DEFAULT_VOLUME_ALERT_SETTINGS
)
const [formValues, setFormValues] = useState(alertSettings)
const handleSave = () => {
setAlertSettings(formValues)
onClose()
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
<h2 className="mb-2">{t('trade:volume-alert')}</h2>
<p className="mb-2">{t('trade:volume-alert-desc')}</p>
<LinkButton
className="mb-4 flex w-full items-center justify-center"
onClick={() => volumeAlertSound.play()}
>
<PlayIcon className="mr-1.5 h-4 w-4" />
<span>{t('trade:preview-sound')}</span>
</LinkButton>
<div className="flex items-center justify-between rounded-md bg-th-bkg-3 p-3">
<p>{t('trade:activate-volume-alert')}</p>
<Switch
className="text-th-fgd-3"
checked={soundSettings['recent-trades']}
onChange={() =>
setSoundSettings({
...soundSettings,
'recent-trades': !soundSettings['recent-trades'],
})
}
/>
</div>
{soundSettings['recent-trades'] ? (
<>
<div className="my-4">
<Label text={t('trade:interval-seconds')} />
<NumberFormat
name="seconds"
id="seconds"
inputMode="numeric"
thousandSeparator=","
allowNegative={false}
decimalScale={0}
isNumericString={true}
className={INPUT_CLASSES}
placeholder="e.g. 30"
value={formValues.seconds}
onValueChange={(e: NumberFormatValues) =>
setFormValues({
...formValues,
seconds: e.value,
})
}
/>
</div>
<div className="mb-6">
<Label text={t('trade:notional-volume')} />
<NumberFormat
name="value"
id="value"
inputMode="numeric"
thousandSeparator=","
allowNegative={false}
isNumericString={true}
className={INPUT_CLASSES}
placeholder="e.g. 10,000"
value={formValues.value}
onValueChange={(e: NumberFormatValues) =>
setFormValues({
...formValues,
value: e.value,
})
}
/>
</div>
<Button
className="w-full"
disabled={!formValues.seconds || !formValues.value}
onClick={handleSave}
size="large"
>
{t('save')}
</Button>
</>
) : null}
</Modal>
)
}
export default TradeVolumeAlertModal

View File

@ -41,13 +41,13 @@ const SoundSettings = () => {
onChange={() => handleToggleSoundSetting('all')}
/>
</div>
<div className="flex items-center justify-between border-t border-th-bkg-3 p-4">
{/* <div className="flex items-center justify-between border-t border-th-bkg-3 p-4">
<p>{t('settings:recent-trades')}</p>
<Switch
checked={soundSettings['recent-trades']}
onChange={() => handleToggleSoundSetting('recent-trades')}
/>
</div>
</div> */}
<div className="flex items-center justify-between border-t border-th-bkg-3 p-4">
<p>{t('settings:swap-success')}</p>
<Switch

View File

@ -1,6 +1,6 @@
import useInterval from '@components/shared/useInterval'
import mangoStore from '@store/mangoStore'
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { formatNumericValue, getDecimalCount } from 'utils/numbers'
import { ChartTradeType } from 'types'
import { useTranslation } from 'next-i18next'
@ -8,56 +8,63 @@ import useSelectedMarket from 'hooks/useSelectedMarket'
import { Howl } from 'howler'
import { IconButton } from '@components/shared/Button'
import useLocalStorageState from 'hooks/useLocalStorageState'
import { SOUND_SETTINGS_KEY } from 'utils/constants'
import { SpeakerWaveIcon, SpeakerXMarkIcon } from '@heroicons/react/20/solid'
import { SOUND_SETTINGS_KEY, TRADE_VOLUME_ALERT_KEY } from 'utils/constants'
import { BellAlertIcon, BellSlashIcon } from '@heroicons/react/20/solid'
import Tooltip from '@components/shared/Tooltip'
import { INITIAL_SOUND_SETTINGS } from '@components/settings/SoundSettings'
import usePrevious from '@components/shared/usePrevious'
import TradeVolumeAlertModal, {
DEFAULT_VOLUME_ALERT_SETTINGS,
} from '@components/modals/TradeVolumeAlertModal'
import dayjs from 'dayjs'
const buySound = new Howl({
const volumeAlertSound = new Howl({
src: ['/sounds/trade-buy.mp3'],
volume: 0.5,
})
const sellSound = new Howl({
src: ['/sounds/trade-sell.mp3'],
volume: 0.5,
volume: 0.8,
})
const RecentTrades = () => {
const { t } = useTranslation(['common', 'trade'])
const fills = mangoStore((s) => s.selectedMarket.fills)
const [soundSettings, setSoundSettings] = useLocalStorageState(
const [latestFillId, setLatestFillId] = useState('')
const [soundSettings] = useLocalStorageState(
SOUND_SETTINGS_KEY,
INITIAL_SOUND_SETTINGS
)
const previousFills = usePrevious(fills)
useEffect(() => {
if (!soundSettings['recent-trades']) return
if (fills.length && previousFills && previousFills.length) {
const latestFill: ChartTradeType = fills[0]
const previousFill: ChartTradeType = previousFills[0]
if (previousFill.orderId?.toString() !== latestFill.orderId?.toString()) {
const side =
latestFill.side || (latestFill.takerSide === 1 ? 'bid' : 'ask')
if (['buy', 'bid'].includes(side)) {
buySound.play()
} else {
sellSound.play()
}
}
}
}, [fills, previousFills, soundSettings])
const [alertSettings] = useLocalStorageState(
TRADE_VOLUME_ALERT_KEY,
DEFAULT_VOLUME_ALERT_SETTINGS
)
const [showVolumeAlertModal, setShowVolumeAlertModal] = useState(false)
const {
selectedMarket,
serumOrPerpMarket: market,
baseSymbol,
quoteBank,
quoteSymbol,
} = useSelectedMarket()
useEffect(() => {
if (!fills.length) return
if (!latestFillId) {
setLatestFillId(fills[0].orderId.toString())
}
}, [fills])
useInterval(() => {
if (!soundSettings['recent-trades'] || !quoteBank) return
setLatestFillId(fills[0].orderId.toString())
const fillsLimitIndex = fills.findIndex(
(f) => f.orderId.toString() === latestFillId
)
const newFillsVolumeValue = fills
.slice(0, fillsLimitIndex)
.reduce((a, c) => a + c.size * c.price, 0)
if (newFillsVolumeValue * quoteBank.uiPrice > Number(alertSettings.value)) {
volumeAlertSound.play()
}
}, alertSettings.seconds * 1000)
// const fetchRecentTrades = useCallback(async () => {
// if (!market) return
@ -114,99 +121,102 @@ const RecentTrades = () => {
}, [fills])
return (
<div className="thin-scroll h-full overflow-y-scroll">
<div className="flex items-center justify-between border-b border-th-bkg-3 p-1 xl:px-2">
<Tooltip content={t('trade:trade-sounds-tooltip')} delay={250}>
<IconButton
onClick={() =>
setSoundSettings({
...soundSettings,
'recent-trades': !soundSettings['recent-trades'],
})
}
size="small"
hideBg
>
{soundSettings['recent-trades'] ? (
<SpeakerWaveIcon className="h-4 w-4 text-th-fgd-3" />
) : (
<SpeakerXMarkIcon className="h-4 w-4 text-th-fgd-3" />
)}
</IconButton>
</Tooltip>
<span className="text-xxs text-th-fgd-4 xl:text-xs">
{t('trade:buys')}:{' '}
<span className="text-th-up">{(buyRatio * 100).toFixed(1)}%</span>
<span className="px-2">|</span>
{t('trade:sells')}:{' '}
<span className="text-th-down">{(sellRatio * 100).toFixed(1)}%</span>
</span>
<>
<div className="thin-scroll h-full overflow-y-scroll">
<div className="flex items-center justify-between border-b border-th-bkg-3 py-1 px-2">
<Tooltip content={t('trade:tooltip-volume-alert')} delay={250}>
<IconButton
onClick={() => setShowVolumeAlertModal(true)}
size="small"
hideBg
>
{soundSettings['recent-trades'] ? (
<BellAlertIcon className="h-4 w-4 text-th-fgd-3" />
) : (
<BellSlashIcon className="h-4 w-4 text-th-fgd-3" />
)}
</IconButton>
</Tooltip>
<span className="text-xxs text-th-fgd-4 xl:text-xs">
{t('trade:buys')}:{' '}
<span className="text-th-up">{(buyRatio * 100).toFixed(1)}%</span>
<span className="px-2">|</span>
{t('trade:sells')}:{' '}
<span className="text-th-down">
{(sellRatio * 100).toFixed(1)}%
</span>
</span>
</div>
<div className="px-2">
<table className="min-w-full">
<thead>
<tr className="text-right text-xxs text-th-fgd-4">
<th className="py-2 font-normal">{`${t(
'price'
)} (${quoteSymbol})`}</th>
<th className="py-2 font-normal">
{t('trade:size')} ({baseSymbol})
</th>
<th className="py-2 font-normal">{t('time')}</th>
</tr>
</thead>
<tbody>
{!!fills.length &&
fills.map((trade: ChartTradeType, i: number) => {
const side =
trade.side || (trade.takerSide === 0 ? 'bid' : 'ask')
const formattedPrice =
market?.tickSize && trade.price
? formatNumericValue(
trade.price,
getDecimalCount(market.tickSize)
)
: trade?.price || 0
const formattedSize =
market?.minOrderSize && trade.size
? formatNumericValue(
trade.size,
getDecimalCount(market.minOrderSize)
)
: trade?.size || 0
return (
<tr className="font-mono text-xs" key={i}>
<td
className={`pb-1.5 text-right ${
['buy', 'bid'].includes(side)
? 'text-th-up'
: 'text-th-down'
}`}
>
{formattedPrice}
</td>
<td className="pb-1.5 text-right">{formattedSize}</td>
<td className="pb-1.5 text-right text-th-fgd-4">
{trade.time
? new Date(trade.time).toLocaleTimeString()
: trade.timestamp
? dayjs(trade.timestamp.toNumber() * 1000).format(
'hh:mma'
)
: '-'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
<div className="px-1 xl:px-2">
<table className="min-w-full">
<thead>
<tr className="text-right text-xxs text-th-fgd-4">
<th className="py-2 font-normal">{`${t(
'price'
)} (${quoteSymbol})`}</th>
<th className="py-2 font-normal">
{t('trade:size')} ({baseSymbol})
</th>
<th className="py-2 font-normal">{t('time')}</th>
</tr>
</thead>
<tbody>
{!!fills.length &&
fills.map((trade: ChartTradeType, i: number) => {
const side =
trade.side || (trade.takerSide === 0 ? 'bid' : 'ask')
const formattedPrice =
market?.tickSize && trade.price
? formatNumericValue(
trade.price,
getDecimalCount(market.tickSize)
)
: trade?.price || 0
const formattedSize =
market?.minOrderSize && trade.size
? formatNumericValue(
trade.size,
getDecimalCount(market.minOrderSize)
)
: trade?.size || 0
return (
<tr className="font-mono text-xs" key={i}>
<td
className={`pb-1.5 text-right ${
['buy', 'bid'].includes(side)
? 'text-th-up'
: 'text-th-down'
}`}
>
{formattedPrice}
</td>
<td className="pb-1.5 text-right text-th-fgd-3">
{formattedSize}
</td>
<td className="pb-1.5 text-right text-th-fgd-4">
{trade.time
? new Date(trade.time).toLocaleTimeString()
: trade.timestamp
? dayjs(trade.timestamp.toNumber() * 1000).format(
'h:mma'
)
: '-'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
{showVolumeAlertModal ? (
<TradeVolumeAlertModal
isOpen={showVolumeAlertModal}
onClose={() => setShowVolumeAlertModal(false)}
/>
) : null}
</>
)
}

View File

@ -1,4 +1,5 @@
{
"activate-volume-alert": "Activate Volume Alert",
"amount": "Amount",
"base": "Base",
"book": "Book",
@ -19,6 +20,7 @@
"hourly-funding": "Hourly Funding",
"in-orders": "In Orders",
"instantaneous-funding": "Instantaneous Funding",
"interval-seconds": "Interval (seconds)",
"limit-price": "Limit Price",
"margin": "Margin",
"no-balances": "No balances",
@ -26,6 +28,7 @@
"no-positions": "No perp positions",
"no-unsettled": "No unsettled funds",
"notional": "Notional",
"notional-volume": "Notional Volume ($)",
"open-interest": "Open Interest",
"oracle-price": "Oracle Price",
"order-error": "Failed to place order",
@ -36,6 +39,7 @@
"placing-order": "Placing Order",
"positions": "Positions",
"post": "Post",
"preview-sound": "Preview Sound",
"price-expect": "The price you receive may be worse than you expect and full execution is not guaranteed. Max slippage is 2.5% for your safety. The part of your position with slippage beyond 2.5% will not be closed.",
"quote": "Quote",
"reduce-only": "Reduce Only",
@ -52,8 +56,11 @@
"tooltip-ioc": "Immediate-Or-Cancel (IOC) orders are guaranteed to be the taker and must be executed immediately. Any portion of the order that can't be filled immediately will be cancelled",
"tooltip-post": "Post orders are guaranteed to be the maker or they will be canceled",
"tooltip-slippage": "An estimate of the difference between the current price and the price your trade will be executed at",
"tooltip-volume-alert": "Volume Alert Settings",
"tooltip-stable-price": "Stable price is used in a safety mechanism that limits a user's ability to enter risky positions when the oracle price is changing rapidly",
"trade-sounds-tooltip": "Play a sound alert for every new trade",
"trades": "Trades",
"unsettled": "Unsettled"
"unsettled": "Unsettled",
"volume-alert": "Volume Alert",
"volume-alert-desc": "Play a sound whenever volume exceeds your alert threshold"
}

View File

@ -1,4 +1,5 @@
{
"activate-volume-alert": "Activate Volume Alert",
"amount": "Amount",
"base": "Base",
"book": "Book",
@ -19,6 +20,7 @@
"hourly-funding": "Hourly Funding",
"in-orders": "In Orders",
"instantaneous-funding": "Instantaneous Funding",
"interval-seconds": "Interval (seconds)",
"limit-price": "Limit Price",
"margin": "Margin",
"no-balances": "No balances",
@ -26,6 +28,7 @@
"no-positions": "No perp positions",
"no-unsettled": "No unsettled funds",
"notional": "Notional",
"notional-volume": "Notional Volume ($)",
"open-interest": "Open Interest",
"oracle-price": "Oracle Price",
"order-error": "Failed to place order",
@ -36,6 +39,7 @@
"placing-order": "Placing Order",
"positions": "Positions",
"post": "Post",
"preview-sound": "Preview Sound",
"price-expect": "The price you receive may be worse than you expect and full execution is not guaranteed. Max slippage is 2.5% for your safety. The part of your position with slippage beyond 2.5% will not be closed.",
"quote": "Quote",
"reduce-only": "Reduce Only",
@ -52,8 +56,11 @@
"tooltip-ioc": "Immediate-Or-Cancel (IOC) orders are guaranteed to be the taker and must be executed immediately. Any portion of the order that can't be filled immediately will be cancelled",
"tooltip-post": "Post orders are guaranteed to be the maker or they will be canceled",
"tooltip-slippage": "An estimate of the difference between the current price and the price your trade will be executed at",
"tooltip-volume-alert": "Volume Alert Settings",
"tooltip-stable-price": "Stable price is used in a safety mechanism that limits a user's ability to enter risky positions when the oracle price is changing rapidly",
"trade-sounds-tooltip": "Play a sound alert for every new trade",
"trades": "Trades",
"unsettled": "Unsettled"
"unsettled": "Unsettled",
"volume-alert": "Volume Alert",
"volume-alert-desc": "Play a sound whenever volume exceeds your alert threshold"
}

View File

@ -1,4 +1,5 @@
{
"activate-volume-alert": "Activate Volume Alert",
"amount": "Amount",
"base": "Base",
"book": "Book",
@ -19,6 +20,7 @@
"hourly-funding": "Hourly Funding",
"in-orders": "In Orders",
"instantaneous-funding": "Instantaneous Funding",
"interval-seconds": "Interval (seconds)",
"limit-price": "Limit Price",
"margin": "Margin",
"no-balances": "No balances",
@ -26,6 +28,7 @@
"no-positions": "No perp positions",
"no-unsettled": "No unsettled funds",
"notional": "Notional",
"notional-volume": "Notional Volume ($)",
"open-interest": "Open Interest",
"oracle-price": "Oracle Price",
"order-error": "Failed to place order",
@ -36,6 +39,7 @@
"placing-order": "Placing Order",
"positions": "Positions",
"post": "Post",
"preview-sound": "Preview Sound",
"price-expect": "The price you receive may be worse than you expect and full execution is not guaranteed. Max slippage is 2.5% for your safety. The part of your position with slippage beyond 2.5% will not be closed.",
"quote": "Quote",
"reduce-only": "Reduce Only",
@ -52,8 +56,11 @@
"tooltip-ioc": "Immediate-Or-Cancel (IOC) orders are guaranteed to be the taker and must be executed immediately. Any portion of the order that can't be filled immediately will be cancelled",
"tooltip-post": "Post orders are guaranteed to be the maker or they will be canceled",
"tooltip-slippage": "An estimate of the difference between the current price and the price your trade will be executed at",
"tooltip-volume-alert": "Volume Alert Settings",
"tooltip-stable-price": "Stable price is used in a safety mechanism that limits a user's ability to enter risky positions when the oracle price is changing rapidly",
"trade-sounds-tooltip": "Play a sound alert for every new trade",
"trades": "Trades",
"unsettled": "Unsettled"
"unsettled": "Unsettled",
"volume-alert": "Volume Alert",
"volume-alert-desc": "Play a sound whenever volume exceeds your alert threshold"
}

View File

@ -1,4 +1,5 @@
{
"activate-volume-alert": "Activate Volume Alert",
"amount": "Amount",
"base": "Base",
"book": "Book",
@ -19,6 +20,7 @@
"hourly-funding": "Hourly Funding",
"in-orders": "In Orders",
"instantaneous-funding": "Instantaneous Funding",
"interval-seconds": "Interval (seconds)",
"limit-price": "Limit Price",
"margin": "Margin",
"no-balances": "No balances",
@ -26,6 +28,7 @@
"no-positions": "No perp positions",
"no-unsettled": "No unsettled funds",
"notional": "Notional",
"notional-volume": "Notional Volume ($)",
"open-interest": "Open Interest",
"oracle-price": "Oracle Price",
"order-error": "Failed to place order",
@ -36,6 +39,7 @@
"placing-order": "Placing Order",
"positions": "Positions",
"post": "Post",
"preview-sound": "Preview Sound",
"price-expect": "The price you receive may be worse than you expect and full execution is not guaranteed. Max slippage is 2.5% for your safety. The part of your position with slippage beyond 2.5% will not be closed.",
"quote": "Quote",
"sells": "Sells",
@ -51,8 +55,11 @@
"tooltip-ioc": "Immediate-Or-Cancel (IOC) orders are guaranteed to be the taker and must be executed immediately. Any portion of the order that can't be filled immediately will be cancelled",
"tooltip-post": "Post orders are guaranteed to be the maker or they will be canceled",
"tooltip-slippage": "An estimate of the difference between the current price and the price your trade will be executed at",
"tooltip-volume-alert": "Volume Alert Settings",
"tooltip-stable-price": "Stable price is used in a safety mechanism that limits a user's ability to enter risky positions when the oracle price is changing rapidly",
"trade-sounds-tooltip": "Play a sound alert for every new trade",
"trades": "Trades",
"unsettled": "Unsettled"
"unsettled": "Unsettled",
"volume-alert": "Volume Alert",
"volume-alert-desc": "Play a sound whenever volume exceeds your alert threshold"
}

View File

@ -1,4 +1,5 @@
{
"activate-volume-alert": "Activate Volume Alert",
"amount": "Amount",
"base": "Base",
"book": "Book",
@ -19,6 +20,7 @@
"hourly-funding": "Hourly Funding",
"in-orders": "In Orders",
"instantaneous-funding": "Instantaneous Funding",
"interval-seconds": "Interval (seconds)",
"limit-price": "Limit Price",
"margin": "Margin",
"no-balances": "No balances",
@ -26,6 +28,7 @@
"no-positions": "No perp positions",
"no-unsettled": "No unsettled funds",
"notional": "Notional",
"notional-volume": "Notional Volume ($)",
"open-interest": "Open Interest",
"oracle-price": "Oracle Price",
"order-error": "Failed to place order",
@ -36,6 +39,7 @@
"placing-order": "Placing Order",
"positions": "Positions",
"post": "Post",
"preview-sound": "Preview Sound",
"price-expect": "The price you receive may be worse than you expect and full execution is not guaranteed. Max slippage is 2.5% for your safety. The part of your position with slippage beyond 2.5% will not be closed.",
"quote": "Quote",
"sells": "Sells",
@ -51,8 +55,11 @@
"tooltip-ioc": "Immediate-Or-Cancel (IOC) orders are guaranteed to be the taker and must be executed immediately. Any portion of the order that can't be filled immediately will be cancelled",
"tooltip-post": "Post orders are guaranteed to be the maker or they will be canceled",
"tooltip-slippage": "An estimate of the difference between the current price and the price your trade will be executed at",
"tooltip-volume-alert": "Volume Alert Settings",
"tooltip-stable-price": "Stable price is used in a safety mechanism that limits a user's ability to enter risky positions when the oracle price is changing rapidly",
"trade-sounds-tooltip": "Play a sound alert for every new trade",
"trades": "Trades",
"unsettled": "Unsettled"
"unsettled": "Unsettled",
"volume-alert": "Volume Alert",
"volume-alert-desc": "Play a sound whenever volume exceeds your alert threshold"
}

View File

@ -72,4 +72,6 @@ export const ACCOUNT_ACTION_MODAL_HEIGHT = '506px'
export const ACCOUNT_ACTION_MODAL_INNER_HEIGHT = '444px'
export const TRADE_VOLUME_ALERT_KEY = 'tradeVolumeAlert-0.1'
export const PAGINATION_PAGE_LENGTH = 250