diff --git a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-fee-details.tsx b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-fee-details.tsx index 12b75efa5..e84646605 100644 --- a/libs/deal-ticket/src/components/deal-ticket/deal-ticket-fee-details.tsx +++ b/libs/deal-ticket/src/components/deal-ticket/deal-ticket-fee-details.tsx @@ -26,11 +26,7 @@ import { EST_TOTAL_MARGIN_TOOLTIP_TEXT, MARGIN_ACCOUNT_TOOLTIP_TEXT, } from '../../constants'; -import { - sumFees, - sumFeesDiscounts, - useEstimateFees, -} from '../../hooks/use-estimate-fees'; +import { useEstimateFees } from '../../hooks/use-estimate-fees'; import { KeyValue } from './key-value'; import { Accordion, @@ -44,6 +40,7 @@ import { import classNames from 'classnames'; import BigNumber from 'bignumber.js'; import { FeesBreakdown } from '../fees-breakdown'; +import { getTotalDiscountFactor, getDiscountedFee } from '../discounts'; const emptyValue = '-'; @@ -63,48 +60,49 @@ export const DealTicketFeeDetails = ({ const feeEstimate = useEstimateFees(order, isMarketInAuction); const asset = getAsset(market); const { decimals: assetDecimals, quantum } = asset; - const totalFees = feeEstimate?.fees && sumFees(feeEstimate?.fees); - const feesDiscounts = - feeEstimate?.fees && sumFeesDiscounts(feeEstimate?.fees); - const totalPercentageDiscount = - feesDiscounts && - totalFees && - feesDiscounts.total !== '0' && - totalFees !== '0' && - new BigNumber(feesDiscounts.total) - .dividedBy(BigNumber.sum(totalFees, feesDiscounts.total)) - .times(100); + const totalDiscountFactor = getTotalDiscountFactor(feeEstimate); + const totalDiscountedFeeAmount = + feeEstimate?.totalFeeAmount && + getDiscountedFee( + feeEstimate.totalFeeAmount, + feeEstimate.referralDiscountFactor, + feeEstimate.volumeDiscountFactor + ).discountedFee; + return ( - {totalPercentageDiscount && ( + {totalDiscountFactor && ( - -{formatNumberPercentage(totalPercentageDiscount, 2)} + - + {formatNumberPercentage( + new BigNumber(totalDiscountFactor).multipliedBy(100), + 2 + )} )} - {feeEstimate?.totalFeeAmount && - `~${formatValue( - feeEstimate?.totalFeeAmount, - assetDecimals, - quantum - )}`} + {totalDiscountedFeeAmount && + `~${formatValue(totalDiscountedFeeAmount, assetDecimals, quantum)}`} } labelDescription={ <> - +

{t( `An estimate of the most you would be expected to pay in fees, in the market's settlement asset ${assetSymbol}. Fees estimated are "taker" fees and will only be payable if the order trades aggressively. Rebate equal to the maker portion will be paid to the trader if the order trades passively.` )} - +

{ + it('calculates values if volumeDiscount or referralDiscount is undefined', () => { + expect(getDiscountedFee('100')).toEqual({ + discountedFee: '100', + volumeDiscount: '0', + referralDiscount: '0', + }); + expect(getDiscountedFee('100', undefined, '0.1')).toEqual({ + discountedFee: '90', + volumeDiscount: '10', + referralDiscount: '0', + }); + expect(getDiscountedFee('100', '0.1', undefined)).toEqual({ + discountedFee: '90', + volumeDiscount: '0', + referralDiscount: '10', + }); + }); + + it('calculates values using volumeDiscount or referralDiscount', () => { + expect(getDiscountedFee('', '0.1', '0.2')).toEqual({ + discountedFee: '', + volumeDiscount: '0', + referralDiscount: '0', + }); + }); +}); + +describe('getTotalDiscountFactor', () => { + it('returns 0 if discounts are 0', () => { + expect( + getTotalDiscountFactor({ + volumeDiscountFactor: '0', + referralDiscountFactor: '0', + }) + ).toEqual(0); + }); + + it('returns volumeDiscountFactor if referralDiscountFactor is 0', () => { + expect( + getTotalDiscountFactor({ + volumeDiscountFactor: '0.1', + referralDiscountFactor: '0', + }) + ).toEqual(0.1); + }); + it('returns referralDiscountFactor if volumeDiscountFactor is 0', () => { + expect( + getTotalDiscountFactor({ + volumeDiscountFactor: '0', + referralDiscountFactor: '0.1', + }) + ).toEqual(0.1); + }); + + it('calculates discount using referralDiscountFactor and volumeDiscountFactor', () => { + expect( + getTotalDiscountFactor({ + volumeDiscountFactor: '0.2', + referralDiscountFactor: '0.1', + }) + ).toBeCloseTo(0.28); + }); +}); diff --git a/libs/deal-ticket/src/components/discounts.ts b/libs/deal-ticket/src/components/discounts.ts new file mode 100644 index 000000000..452e193db --- /dev/null +++ b/libs/deal-ticket/src/components/discounts.ts @@ -0,0 +1,54 @@ +import BigNumber from 'bignumber.js'; + +export const getDiscountedFee = ( + feeAmount: string, + referralDiscountFactor?: string, + volumeDiscountFactor?: string +) => { + if ( + ((!referralDiscountFactor || referralDiscountFactor === '0') && + (!volumeDiscountFactor || volumeDiscountFactor === '0')) || + !feeAmount || + feeAmount === '0' + ) { + return { + discountedFee: feeAmount, + volumeDiscount: '0', + referralDiscount: '0', + }; + } + const referralDiscount = new BigNumber(referralDiscountFactor || '0') + .multipliedBy(feeAmount) + .toFixed(0, BigNumber.ROUND_FLOOR); + const volumeDiscount = new BigNumber(volumeDiscountFactor || '0') + .multipliedBy((BigInt(feeAmount) - BigInt(referralDiscount)).toString()) + .toFixed(0, BigNumber.ROUND_FLOOR); + const discountedFee = ( + BigInt(feeAmount || '0') - + BigInt(referralDiscount) - + BigInt(volumeDiscount) + ).toString(); + return { + referralDiscount, + volumeDiscount, + discountedFee, + }; +}; + +export const getTotalDiscountFactor = (feeEstimate?: { + volumeDiscountFactor?: string; + referralDiscountFactor?: string; +}) => { + if (!feeEstimate) { + return 0; + } + const volumeFactor = Number(feeEstimate?.volumeDiscountFactor) || 0; + const referralFactor = Number(feeEstimate?.referralDiscountFactor) || 0; + if (!volumeFactor) { + return referralFactor; + } + if (!referralFactor) { + return volumeFactor; + } + return 1 - (1 - volumeFactor) * (1 - referralFactor); +}; diff --git a/libs/deal-ticket/src/components/fees-breakdown/fees-breakdown.tsx b/libs/deal-ticket/src/components/fees-breakdown/fees-breakdown.tsx index 85000e49c..362447481 100644 --- a/libs/deal-ticket/src/components/fees-breakdown/fees-breakdown.tsx +++ b/libs/deal-ticket/src/components/fees-breakdown/fees-breakdown.tsx @@ -6,7 +6,7 @@ import { } from '@vegaprotocol/utils'; import { t } from '@vegaprotocol/i18n'; import BigNumber from 'bignumber.js'; -import { sumFees, sumFeesDiscounts } from '../../hooks'; +import { getDiscountedFee } from '../discounts'; const formatValue = ( value: string | number | null | undefined, @@ -24,7 +24,7 @@ const FeesBreakdownItem = ({ decimals, }: { label: string; - factor?: BigNumber; + factor?: string; value: string; symbol?: string; decimals: number; @@ -43,76 +43,86 @@ const FeesBreakdownItem = ({ ); export const FeesBreakdown = ({ + totalFeeAmount, fees, feeFactors, symbol, decimals, + referralDiscountFactor, + volumeDiscountFactor, }: { + totalFeeAmount?: string; fees?: TradeFee; feeFactors?: FeeFactors; symbol?: string; decimals: number; + referralDiscountFactor?: string; + volumeDiscountFactor?: string; }) => { - if (!fees) return null; - const totalFees = sumFees(fees); - const { - total: totalDiscount, - referral: referralDiscount, - volume: volumeDiscount, - } = sumFeesDiscounts(fees); - if (totalFees === '0') return null; + if (!fees || !totalFeeAmount || totalFeeAmount === '0') return null; + + const { discountedFee: discountedInfrastructureFee } = getDiscountedFee( + fees.infrastructureFee, + referralDiscountFactor, + volumeDiscountFactor + ); + + const { discountedFee: discountedLiquidityFee } = getDiscountedFee( + fees.liquidityFee, + referralDiscountFactor, + volumeDiscountFactor + ); + + const { discountedFee: discountedMakerFee } = getDiscountedFee( + fees.makerFee, + referralDiscountFactor, + volumeDiscountFactor + ); + + const { volumeDiscount, referralDiscount } = getDiscountedFee( + totalFeeAmount, + referralDiscountFactor, + volumeDiscountFactor + ); + return (
- {volumeDiscount && volumeDiscount !== '0' && ( + {volumeDiscountFactor && volumeDiscount !== '0' && ( )} - {referralDiscount && referralDiscount !== '0' && ( + {referralDiscountFactor && referralDiscount !== '0' && ( diff --git a/libs/deal-ticket/src/hooks/EstimateOrder.graphql b/libs/deal-ticket/src/hooks/EstimateOrder.graphql index 1632176d9..a300c9df4 100644 --- a/libs/deal-ticket/src/hooks/EstimateOrder.graphql +++ b/libs/deal-ticket/src/hooks/EstimateOrder.graphql @@ -31,4 +31,25 @@ query EstimateFees( } totalFeeAmount } + epoch { + id + } + volumeDiscountStats(partyId: $partyId, pagination: { last: 1 }) { + edges { + node { + atEpoch + discountFactor + runningVolume + } + } + } + referralSetStats(partyId: $partyId, pagination: { last: 1 }) { + edges { + node { + atEpoch + discountFactor + referralSetRunningNotionalTakerVolume + } + } + } } diff --git a/libs/deal-ticket/src/hooks/__generated__/EstimateOrder.ts b/libs/deal-ticket/src/hooks/__generated__/EstimateOrder.ts index 07a54d03d..50d3d5583 100644 --- a/libs/deal-ticket/src/hooks/__generated__/EstimateOrder.ts +++ b/libs/deal-ticket/src/hooks/__generated__/EstimateOrder.ts @@ -15,7 +15,7 @@ export type EstimateFeesQueryVariables = Types.Exact<{ }>; -export type EstimateFeesQuery = { __typename?: 'Query', estimateFees: { __typename?: 'FeeEstimate', totalFeeAmount: string, fees: { __typename?: 'TradeFee', makerFee: string, infrastructureFee: string, liquidityFee: string, makerFeeReferralDiscount?: string | null, makerFeeVolumeDiscount?: string | null, infrastructureFeeReferralDiscount?: string | null, infrastructureFeeVolumeDiscount?: string | null, liquidityFeeReferralDiscount?: string | null, liquidityFeeVolumeDiscount?: string | null } } }; +export type EstimateFeesQuery = { __typename?: 'Query', estimateFees: { __typename?: 'FeeEstimate', totalFeeAmount: string, fees: { __typename?: 'TradeFee', makerFee: string, infrastructureFee: string, liquidityFee: string, makerFeeReferralDiscount?: string | null, makerFeeVolumeDiscount?: string | null, infrastructureFeeReferralDiscount?: string | null, infrastructureFeeVolumeDiscount?: string | null, liquidityFeeReferralDiscount?: string | null, liquidityFeeVolumeDiscount?: string | null } }, epoch: { __typename?: 'Epoch', id: string }, volumeDiscountStats: { __typename?: 'VolumeDiscountStatsConnection', edges: Array<{ __typename?: 'VolumeDiscountStatsEdge', node: { __typename?: 'VolumeDiscountStats', atEpoch: number, discountFactor: string, runningVolume: string } } | null> }, referralSetStats: { __typename?: 'ReferralSetStatsConnection', edges: Array<{ __typename?: 'ReferralSetStatsEdge', node: { __typename?: 'ReferralSetStats', atEpoch: number, discountFactor: string, referralSetRunningNotionalTakerVolume: string } } | null> } }; export const EstimateFeesDocument = gql` @@ -43,6 +43,27 @@ export const EstimateFeesDocument = gql` } totalFeeAmount } + epoch { + id + } + volumeDiscountStats(partyId: $partyId, pagination: {last: 1}) { + edges { + node { + atEpoch + discountFactor + runningVolume + } + } + } + referralSetStats(partyId: $partyId, pagination: {last: 1}) { + edges { + node { + atEpoch + discountFactor + referralSetRunningNotionalTakerVolume + } + } + } } `; diff --git a/libs/deal-ticket/src/hooks/estimate-order.mock.ts b/libs/deal-ticket/src/hooks/estimate-order.mock.ts index b644f3bc1..303ea9fa2 100644 --- a/libs/deal-ticket/src/hooks/estimate-order.mock.ts +++ b/libs/deal-ticket/src/hooks/estimate-order.mock.ts @@ -6,6 +6,15 @@ export const estimateFeesQuery = ( override?: PartialDeep ): EstimateFeesQuery => { const defaultResult: EstimateFeesQuery = { + epoch: { + id: '1', + }, + referralSetStats: { + edges: [], + }, + volumeDiscountStats: { + edges: [], + }, estimateFees: { __typename: 'FeeEstimate', totalFeeAmount: '0.0006', diff --git a/libs/deal-ticket/src/hooks/use-estimate-fees.spec.tsx b/libs/deal-ticket/src/hooks/use-estimate-fees.spec.tsx index 139bb9286..6823bb299 100644 --- a/libs/deal-ticket/src/hooks/use-estimate-fees.spec.tsx +++ b/libs/deal-ticket/src/hooks/use-estimate-fees.spec.tsx @@ -5,6 +5,31 @@ import { Side, OrderTimeInForce, OrderType } from '@vegaprotocol/types'; import type { EstimateFeesQuery } from './__generated__/EstimateOrder'; const data: EstimateFeesQuery = { + epoch: { + id: '2', + }, + volumeDiscountStats: { + edges: [ + { + node: { + atEpoch: 1, + discountFactor: '0.1', + runningVolume: '100', + }, + }, + ], + }, + referralSetStats: { + edges: [ + { + node: { + atEpoch: 1, + discountFactor: '0.2', + referralSetRunningNotionalTakerVolume: '100', + }, + }, + ], + }, estimateFees: { totalFeeAmount: '120', fees: { @@ -54,6 +79,8 @@ describe('useEstimateFees', () => { liquidityFee: '0', makerFee: '0', }, + referralDiscountFactor: '0', + volumeDiscountFactor: '0', }); expect(mockUseEstimateFeesQuery.mock.lastCall?.[0].skip).toBeTruthy(); }); @@ -85,6 +112,46 @@ describe('useEstimateFees', () => { makerFeeReferralDiscount: '5', makerFeeVolumeDiscount: '6', }, + referralDiscountFactor: '0', + volumeDiscountFactor: '0', }); }); + + it('returns 0 discounts if discount stats are not at the current epoch', () => { + const { result } = renderHook(() => + useEstimateFees( + { + marketId: 'marketId', + side: Side.SIDE_BUY, + size: '1', + price: '1', + timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK, + type: OrderType.TYPE_LIMIT, + }, + true + ) + ); + expect(result.current?.referralDiscountFactor).toEqual('0'); + expect(result.current?.volumeDiscountFactor).toEqual('0'); + }); + + it('returns discounts', () => { + data.epoch.id = '1'; + const { result } = renderHook(() => + useEstimateFees( + { + marketId: 'marketId', + side: Side.SIDE_BUY, + size: '1', + price: '1', + timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK, + type: OrderType.TYPE_LIMIT, + }, + true + ) + ); + + expect(result.current?.referralDiscountFactor).toEqual('0.2'); + expect(result.current?.volumeDiscountFactor).toEqual('0.1'); + }); }); diff --git a/libs/deal-ticket/src/hooks/use-estimate-fees.tsx b/libs/deal-ticket/src/hooks/use-estimate-fees.tsx index a573001ae..18c693038 100644 --- a/libs/deal-ticket/src/hooks/use-estimate-fees.tsx +++ b/libs/deal-ticket/src/hooks/use-estimate-fees.tsx @@ -5,39 +5,22 @@ import type { EstimateFeesQuery } from './__generated__/EstimateOrder'; import { useEstimateFeesQuery } from './__generated__/EstimateOrder'; const divideByTwo = (n: string) => (BigInt(n) / BigInt(2)).toString(); -export const sumFeesDiscounts = ( - fees: EstimateFeesQuery['estimateFees']['fees'] -) => { - const volume = ( - BigInt(fees.makerFeeVolumeDiscount || '0') + - BigInt(fees.infrastructureFeeVolumeDiscount || '0') + - BigInt(fees.liquidityFeeVolumeDiscount || '0') - ).toString(); - const referral = ( - BigInt(fees.makerFeeReferralDiscount || '0') + - BigInt(fees.infrastructureFeeReferralDiscount || '0') + - BigInt(fees.liquidityFeeReferralDiscount || '0') - ).toString(); - return { - volume, - referral, - total: (BigInt(volume) + BigInt(referral)).toString(), - }; -}; - -export const sumFees = (fees: EstimateFeesQuery['estimateFees']['fees']) => - ( - BigInt(fees.makerFee || '0') + - BigInt(fees.infrastructureFee || '0') + - BigInt(fees.liquidityFee || '0') - ).toString(); export const useEstimateFees = ( order?: OrderSubmissionBody['orderSubmission'], isMarketInAuction?: boolean -): EstimateFeesQuery['estimateFees'] | undefined => { +): + | (EstimateFeesQuery['estimateFees'] & { + referralDiscountFactor: string; + volumeDiscountFactor: string; + }) + | undefined => { const { pubKey } = useVegaWallet(); - const { data } = useEstimateFeesQuery({ + const { + data: currentData, + previousData, + loading, + } = useEstimateFeesQuery({ variables: order && { marketId: order.marketId, partyId: pubKey || '', @@ -50,8 +33,21 @@ export const useEstimateFees = ( fetchPolicy: 'no-cache', skip: !pubKey || !order?.size || !order?.price || order.postOnly, }); + const data = loading ? currentData || previousData : currentData; + const volumeDiscountFactor = + (data?.volumeDiscountStats.edges[0]?.node.atEpoch.toString() === + data?.epoch.id && + data?.volumeDiscountStats.edges[0]?.node.discountFactor) || + '0'; + const referralDiscountFactor = + (data?.referralSetStats.edges[0]?.node.atEpoch.toString() === + data?.epoch.id && + data?.referralSetStats.edges[0]?.node.discountFactor) || + '0'; if (order?.postOnly) { return { + volumeDiscountFactor, + referralDiscountFactor, totalFeeAmount: '0', fees: { infrastructureFee: '0', @@ -60,8 +56,13 @@ export const useEstimateFees = ( }, }; } - return isMarketInAuction && data?.estimateFees + if (!data?.estimateFees) { + return undefined; + } + return isMarketInAuction ? { + volumeDiscountFactor, + referralDiscountFactor, totalFeeAmount: divideByTwo(data.estimateFees.totalFeeAmount), fees: { infrastructureFee: divideByTwo( @@ -91,5 +92,9 @@ export const useEstimateFees = ( divideByTwo(data.estimateFees.fees.makerFeeVolumeDiscount), }, } - : data?.estimateFees; + : { + volumeDiscountFactor, + referralDiscountFactor, + ...data.estimateFees, + }; }; diff --git a/libs/markets/src/lib/market-utils.spec.tsx b/libs/markets/src/lib/market-utils.spec.tsx index a3a197275..58020185e 100644 --- a/libs/markets/src/lib/market-utils.spec.tsx +++ b/libs/markets/src/lib/market-utils.spec.tsx @@ -3,6 +3,7 @@ import type { Market, MarketMaybeWithDataAndCandles } from './markets-provider'; import { calcTradedFactor, filterAndSortMarkets, + sumFeesFactors, totalFeesFactorsPercentage, } from './market-utils'; const { MarketState, MarketTradingMode } = Schema; @@ -132,3 +133,15 @@ describe('calcTradedFactor', () => { expect(fa > fb).toBeTruthy(); }); }); + +describe('sumFeesFactors', () => { + it('does not result in flop errors', () => { + expect( + sumFeesFactors({ + makerFee: '0.1', + infrastructureFee: '0.2', + liquidityFee: '0.3', + }) + ).toEqual(0.6); + }); +}); diff --git a/libs/markets/src/lib/market-utils.ts b/libs/markets/src/lib/market-utils.ts index 2be46e95f..dd21f2027 100644 --- a/libs/markets/src/lib/market-utils.ts +++ b/libs/markets/src/lib/market-utils.ts @@ -50,16 +50,19 @@ export const getQuoteName = (market: Partial) => { }; export const sumFeesFactors = (fees: Market['fees']['factors']) => { - return fees - ? new BigNumber(fees.makerFee) - .plus(fees.liquidityFee) - .plus(fees.infrastructureFee) - : undefined; + if (!fees) return; + + return new BigNumber(fees.makerFee) + .plus(fees.liquidityFee) + .plus(fees.infrastructureFee) + .toNumber(); }; export const totalFeesFactorsPercentage = (fees: Market['fees']['factors']) => { const total = fees && sumFeesFactors(fees); - return total ? formatNumberPercentage(total.times(100)) : undefined; + return total + ? formatNumberPercentage(new BigNumber(total).times(100)) + : undefined; }; export const filterAndSortMarkets = (markets: MarketMaybeWithData[]) => {