From a59f7dfd295c9bcc374e28ae4dd3dcccd11ca053 Mon Sep 17 00:00:00 2001 From: "m.ray" <16125548+MadalinaRaicu@users.noreply.github.com> Date: Fri, 1 Dec 2023 18:34:22 +0200 Subject: [PATCH] fix(trading): fills fees maker discounts (#5406) --- libs/fills/src/lib/fills-table.spec.tsx | 111 +------------- libs/fills/src/lib/fills-table.tsx | 160 ++++----------------- libs/fills/src/lib/fills-utils.spec.ts | 183 ++++++++++++++++++++++++ libs/fills/src/lib/fills-utils.ts | 164 +++++++++++++++++++++ 4 files changed, 377 insertions(+), 241 deletions(-) create mode 100644 libs/fills/src/lib/fills-utils.spec.ts create mode 100644 libs/fills/src/lib/fills-utils.ts diff --git a/libs/fills/src/lib/fills-table.spec.tsx b/libs/fills/src/lib/fills-table.spec.tsx index f2b7216617..81136946b2 100644 --- a/libs/fills/src/lib/fills-table.spec.tsx +++ b/libs/fills/src/lib/fills-table.spec.tsx @@ -4,14 +4,8 @@ import { getDateTimeFormat } from '@vegaprotocol/utils'; import * as Schema from '@vegaprotocol/types'; import type { PartialDeep } from 'type-fest'; import type { Trade } from './fills-data-provider'; -import { - FeesDiscountBreakdownTooltip, - FillsTable, - getFeesBreakdown, - getTotalFeesDiscounts, -} from './fills-table'; +import { FeesDiscountBreakdownTooltip, FillsTable } from './fills-table'; import { generateFill } from './test-helpers'; -import type { TradeFeeFieldsFragment } from './__generated__/Fills'; const partyId = 'party-id'; const defaultFill: PartialDeep = { @@ -66,7 +60,7 @@ describe('FillsTable', () => { expect(headers.map((h) => h.textContent?.trim())).toEqual(expectedHeaders); }); - it('formats cells correctly for buyer fill', async () => { + it('formats cells correctly for buyer fill for maker', async () => { const buyerFill = generateFill({ ...defaultFill, buyer: { @@ -90,7 +84,7 @@ describe('FillsTable', () => { '3.00 BTC', 'Maker', '2.00 BTC', - '0.27 BTC', + '0.09 BTC', getDateTimeFormat().format(new Date(buyerFill.createdAt)), '', // action column ]; @@ -316,103 +310,4 @@ describe('FillsTable', () => { }); }); }); - - describe('getFeesBreakdown', () => { - it('should return correct fees breakdown for a taker', () => { - const fees = { - makerFee: '1000', - infrastructureFee: '2000', - liquidityFee: '3000', - }; - const expectedBreakdown = { - infrastructureFee: '2000', - liquidityFee: '3000', - makerFee: '1000', - totalFee: '6000', - }; - expect(getFeesBreakdown('Taker', fees)).toEqual(expectedBreakdown); - }); - - it('should return correct fees breakdown for a maker if market', () => { - const fees = { - makerFee: '1000', - infrastructureFee: '2000', - liquidityFee: '3000', - }; - const expectedBreakdown = { - infrastructureFee: '0', - liquidityFee: '0', - makerFee: '-1000', - totalFee: '-1000', - }; - expect(getFeesBreakdown('Maker', fees)).toEqual(expectedBreakdown); - }); - - it('should return correct fees breakdown for a maker if market is active', () => { - const fees = { - makerFee: '1000', - infrastructureFee: '2000', - liquidityFee: '3000', - }; - const expectedBreakdown = { - infrastructureFee: '0', - liquidityFee: '0', - makerFee: '-1000', - totalFee: '-1000', - }; - expect( - getFeesBreakdown('Maker', fees, Schema.MarketState.STATE_ACTIVE) - ).toEqual(expectedBreakdown); - }); - - it('should return correct fees breakdown for a maker if the market is suspended', () => { - const fees = { - infrastructureFee: '2000', - liquidityFee: '3000', - makerFee: '0', - }; - const expectedBreakdown = { - infrastructureFee: '1000', - liquidityFee: '1500', - makerFee: '0', - totalFee: '2500', - }; - expect( - getFeesBreakdown('Maker', fees, Schema.MarketState.STATE_SUSPENDED) - ).toEqual(expectedBreakdown); - }); - - it('should return correct fees breakdown for a taker if the market is suspended', () => { - const fees = { - infrastructureFee: '2000', - liquidityFee: '3000', - makerFee: '0', - }; - const expectedBreakdown = { - infrastructureFee: '1000', - liquidityFee: '1500', - makerFee: '0', - totalFee: '2500', - }; - expect( - getFeesBreakdown('Taker', fees, Schema.MarketState.STATE_SUSPENDED) - ).toEqual(expectedBreakdown); - }); - }); - - describe('getTotalFeesDiscounts', () => { - it('should return correct total value', () => { - const fees = { - infrastructureFeeReferralDiscount: '1', - infrastructureFeeVolumeDiscount: '2', - liquidityFeeReferralDiscount: '3', - liquidityFeeVolumeDiscount: '4', - makerFeeReferralDiscount: '5', - makerFeeVolumeDiscount: '6', - }; - expect(getTotalFeesDiscounts(fees as TradeFeeFieldsFragment)).toEqual( - (1 + 2 + 3 + 4 + 5 + 6).toString() - ); - }); - }); }); diff --git a/libs/fills/src/lib/fills-table.tsx b/libs/fills/src/lib/fills-table.tsx index 10fe626440..1ca38cc908 100644 --- a/libs/fills/src/lib/fills-table.tsx +++ b/libs/fills/src/lib/fills-table.tsx @@ -30,19 +30,11 @@ import type { import { forwardRef } from 'react'; import BigNumber from 'bignumber.js'; import type { Trade } from './fills-data-provider'; -import type { - FillFieldsFragment, - TradeFeeFieldsFragment, -} from './__generated__/Fills'; import { FillActionsDropdown } from './fill-actions-dropdown'; import { getAsset } from '@vegaprotocol/markets'; +import { MAKER, TAKER, getFeesBreakdown, getRoleAndFees } from './fills-utils'; -const TAKER = 'Taker'; -const MAKER = 'Maker'; - -export type Role = typeof TAKER | typeof MAKER | '-'; - -export type Props = (AgGridReactProps | AgReactUiProps) & { +type Props = (AgGridReactProps | AgReactUiProps) & { partyId: string; onMarketClick?: (marketId: string, metaKey?: boolean) => void; }; @@ -262,73 +254,13 @@ const formatFeeDiscount = (partyId: string) => { }: VegaValueFormatterParams) => { if (!market || !data) return '-'; const asset = getAsset(market); - const { fees } = getRoleAndFees({ data, partyId }); - if (!fees) return '-'; - - const total = getTotalFeesDiscounts(fees); - return addDecimalsFormatNumber(total, asset.decimals); + const { fees: roleFees, role } = getRoleAndFees({ data, partyId }); + if (!roleFees) return '-'; + const { totalFeeDiscount } = getFeesBreakdown(role, roleFees); + return addDecimalsFormatNumber(totalFeeDiscount, asset.decimals); }; }; -export const isEmptyFeeObj = (feeObj: Schema.TradeFee) => { - if (!feeObj) return true; - return ( - feeObj.liquidityFee === '0' && - feeObj.makerFee === '0' && - feeObj.infrastructureFee === '0' - ); -}; - -export const getRoleAndFees = ({ - data, - partyId, -}: { - data: Pick< - FillFieldsFragment, - 'buyerFee' | 'sellerFee' | 'buyer' | 'seller' | 'aggressor' - >; - partyId?: string; -}) => { - let role: Role; - let fees; - - if (data?.buyer.id === partyId) { - if (data.aggressor === Schema.Side.SIDE_BUY) { - role = TAKER; - fees = data?.buyerFee; - } else if (data.aggressor === Schema.Side.SIDE_SELL) { - role = MAKER; - fees = data?.sellerFee; - } else { - role = '-'; - fees = !isEmptyFeeObj(data?.buyerFee) ? data.buyerFee : data.sellerFee; - } - } else if (data?.seller.id === partyId) { - if (data.aggressor === Schema.Side.SIDE_SELL) { - role = TAKER; - fees = data?.sellerFee; - } else if (data.aggressor === Schema.Side.SIDE_BUY) { - role = MAKER; - fees = data?.buyerFee; - } else { - role = '-'; - fees = !isEmptyFeeObj(data.sellerFee) ? data.sellerFee : data.buyerFee; - } - } else { - return { role: '-', fees: undefined }; - } - - // We make the assumption that the market state is active if the maker fee is zero on both sides - // This needs to be updated when we have a way to get the correct market state when that fill happened from the API - // because the maker fee factor can be set to 0 via governance - const marketState = - data?.buyerFee.makerFee === data.sellerFee.makerFee && - new BigNumber(data?.buyerFee.makerFee).isZero() - ? Schema.MarketState.STATE_SUSPENDED - : Schema.MarketState.STATE_ACTIVE; - return { role, fees, marketState }; -}; - const FeesBreakdownTooltip = ({ data, value: market, @@ -350,11 +282,13 @@ const FeesBreakdownTooltip = ({ data-testid="fee-breakdown-tooltip" className="z-20 max-w-sm px-4 py-2 text-xs text-black border rounded bg-vega-light-100 dark:bg-vega-dark-100 border-vega-light-200 dark:border-vega-dark-200 break-word dark:text-white" > -

- {t('If the market was %s', [ - Schema.MarketStateMapping[marketState].toLowerCase(), - ])} -

+ {marketState && ( +

+ {t('If the market was %s', [ + Schema.MarketStateMapping[marketState].toLowerCase(), + ])} +

+ )} {role === MAKER && ( <>

{t('The maker will receive the maker fee.')}

@@ -425,9 +359,13 @@ export const FeesDiscountBreakdownTooltip = ({ } const asset = getAsset(data.market); - const { fees } = getRoleAndFees({ data, partyId }) ?? {}; - if (!fees) return null; - + const { + fees: roleFees, + marketState, + role, + } = getRoleAndFees({ data, partyId }) ?? {}; + if (!roleFees) return null; + const fees = getFeesBreakdown(role, roleFees, marketState); return (
+ +
{t('Total Fee Discount')}
+
); }; - -export const getTotalFeesDiscounts = (fees: TradeFeeFieldsFragment) => { - return ( - BigInt(fees.infrastructureFeeReferralDiscount || '0') + - BigInt(fees.infrastructureFeeVolumeDiscount || '0') + - BigInt(fees.liquidityFeeReferralDiscount || '0') + - BigInt(fees.liquidityFeeVolumeDiscount || '0') + - BigInt(fees.makerFeeReferralDiscount || '0') + - BigInt(fees.makerFeeVolumeDiscount || '0') - ).toString(); -}; - -export const getFeesBreakdown = ( - role: Role, - feesObj: TradeFeeFieldsFragment, - marketState: Schema.MarketState = Schema.MarketState.STATE_ACTIVE -) => { - // If market is in auction we assume maker fee is zero - const isMarketActive = marketState === Schema.MarketState.STATE_ACTIVE; - - // If role is taker, then these are the fees to be paid - let { makerFee, infrastructureFee, liquidityFee } = feesObj; - - if (isMarketActive) { - if (role === MAKER) { - makerFee = new BigNumber(feesObj.makerFee).times(-1).toString(); - infrastructureFee = '0'; - liquidityFee = '0'; - } - } else { - // If market is suspended (in monitoring auction), then half of the fees are paid - infrastructureFee = new BigNumber(infrastructureFee) - .dividedBy(2) - .toString(); - liquidityFee = new BigNumber(liquidityFee).dividedBy(2).toString(); - // maker fee is already zero - makerFee = '0'; - } - - const totalFee = new BigNumber(infrastructureFee) - .plus(makerFee) - .plus(liquidityFee) - .toString(); - - return { - infrastructureFee, - liquidityFee, - makerFee, - totalFee, - }; -}; diff --git a/libs/fills/src/lib/fills-utils.spec.ts b/libs/fills/src/lib/fills-utils.spec.ts new file mode 100644 index 0000000000..fd62ebd180 --- /dev/null +++ b/libs/fills/src/lib/fills-utils.spec.ts @@ -0,0 +1,183 @@ +import { getFeesBreakdown } from './fills-utils'; +import * as Schema from '@vegaprotocol/types'; + +describe('getFeesBreakdown', () => { + it('should return correct fees breakdown for a taker', () => { + const fees = { + makerFee: '1000', + infrastructureFee: '2000', + liquidityFee: '3000', + }; + const expectedBreakdown = { + infrastructureFee: '2000', + liquidityFee: '3000', + makerFee: '1000', + totalFee: '6000', + totalFeeDiscount: '0', + }; + expect(getFeesBreakdown('Taker', fees)).toEqual(expectedBreakdown); + }); + + it('should return correct fees breakdown for a maker if market is active', () => { + const fees = { + makerFee: '1000', + infrastructureFee: '2000', + liquidityFee: '3000', + }; + const expectedBreakdown = { + infrastructureFee: '0', + liquidityFee: '0', + makerFee: '-1000', + totalFee: '-1000', + totalFeeDiscount: '0', + }; + expect( + getFeesBreakdown('Maker', fees, Schema.MarketState.STATE_ACTIVE) + ).toEqual(expectedBreakdown); + }); + + it('should return correct fees breakdown for a maker if the market is suspended', () => { + const fees = { + infrastructureFee: '2000', + liquidityFee: '3000', + makerFee: '0', + }; + const expectedBreakdown = { + infrastructureFee: '1000', + liquidityFee: '1500', + makerFee: '0', + totalFee: '2500', + totalFeeDiscount: '0', + }; + expect( + getFeesBreakdown('Maker', fees, Schema.MarketState.STATE_SUSPENDED) + ).toEqual(expectedBreakdown); + }); + + it('should return correct fees breakdown for a taker if the market is suspended', () => { + const fees = { + infrastructureFee: '2000', + liquidityFee: '3000', + makerFee: '0', + }; + const expectedBreakdown = { + infrastructureFee: '1000', + liquidityFee: '1500', + makerFee: '0', + totalFee: '2500', + totalFeeDiscount: '0', + }; + expect( + getFeesBreakdown('Taker', fees, Schema.MarketState.STATE_SUSPENDED) + ).toEqual(expectedBreakdown); + }); + + it('should return correct fees breakdown for a taker if market is active', () => { + const fees = { + makerFee: '1000', + infrastructureFee: '2000', + liquidityFee: '3000', + }; + const expectedBreakdown = { + infrastructureFee: '2000', + liquidityFee: '3000', + makerFee: '1000', + totalFee: '6000', + totalFeeDiscount: '0', + }; + expect( + getFeesBreakdown('Taker', fees, Schema.MarketState.STATE_ACTIVE) + ).toEqual(expectedBreakdown); + }); + + it('should return correct fees breakdown for a maker', () => { + const fees = { + makerFee: '1000', + infrastructureFee: '2000', + liquidityFee: '3000', + }; + const expectedBreakdown = { + infrastructureFee: '0', + liquidityFee: '0', + makerFee: '-1000', + totalFee: '-1000', + totalFeeDiscount: '0', + }; + expect(getFeesBreakdown('Maker', fees)).toEqual(expectedBreakdown); + }); + + it('should return correct total fees discount value for a taker (if the market is active - default)', () => { + const fees = { + infrastructureFeeReferralDiscount: '1', + infrastructureFeeVolumeDiscount: '2', + liquidityFeeReferralDiscount: '3', + liquidityFeeVolumeDiscount: '4', + makerFeeReferralDiscount: '5', + makerFeeVolumeDiscount: '6', + infrastructureFee: '1000', + liquidityFee: '2000', + makerFee: '3000', + }; + const { totalFeeDiscount } = getFeesBreakdown('Taker', fees); + expect(totalFeeDiscount).toEqual((1 + 2 + 3 + 4 + 5 + 6).toString()); + }); + + it('should return correct total fees discount value for a maker (if the market is active - default)', () => { + const fees = { + infrastructureFeeReferralDiscount: '1', + infrastructureFeeVolumeDiscount: '2', + liquidityFeeReferralDiscount: '3', + liquidityFeeVolumeDiscount: '4', + makerFeeReferralDiscount: '5', + makerFeeVolumeDiscount: '6', + infrastructureFee: '1000', + liquidityFee: '2000', + makerFee: '3000', + }; + const { totalFeeDiscount } = getFeesBreakdown('Maker', fees); + // makerFeeReferralDiscount and makerFeeVolumeDiscount are added, infra and liq. fees are zeroed + expect(totalFeeDiscount).toEqual((5 + 6).toString()); + }); + + it('should return correct total fees discount value for a maker (if the market is suspended)', () => { + const fees = { + infrastructureFeeReferralDiscount: '1', + infrastructureFeeVolumeDiscount: '2', + liquidityFeeReferralDiscount: '3', + liquidityFeeVolumeDiscount: '4', + makerFeeReferralDiscount: '5', + makerFeeVolumeDiscount: '6', + infrastructureFee: '1000', + liquidityFee: '2000', + makerFee: '3000', + }; + const { totalFeeDiscount } = getFeesBreakdown( + 'Maker', + fees, + Schema.MarketState.STATE_SUSPENDED + ); + // makerFeeReferralDiscount and makerFeeVolumeDiscount are zeroed, infra and liq. fees are halved + expect(totalFeeDiscount).toEqual(((1 + 2 + 3 + 4) / 2).toString()); + }); + + it('should return correct total fees discount value for a taker (if the market is suspended)', () => { + const fees = { + infrastructureFeeReferralDiscount: '1', + infrastructureFeeVolumeDiscount: '2', + liquidityFeeReferralDiscount: '3', + liquidityFeeVolumeDiscount: '4', + makerFeeReferralDiscount: '5', + makerFeeVolumeDiscount: '6', + infrastructureFee: '1000', + liquidityFee: '2000', + makerFee: '3000', + }; + const { totalFeeDiscount } = getFeesBreakdown( + 'Taker', + fees, + Schema.MarketState.STATE_SUSPENDED + ); + // makerFeeReferralDiscount and makerFeeVolumeDiscount are zeroed, infra and liq. fees are halved + expect(totalFeeDiscount).toEqual(((1 + 2 + 3 + 4) / 2).toString()); + }); +}); diff --git a/libs/fills/src/lib/fills-utils.ts b/libs/fills/src/lib/fills-utils.ts new file mode 100644 index 0000000000..d5fc0b47a7 --- /dev/null +++ b/libs/fills/src/lib/fills-utils.ts @@ -0,0 +1,164 @@ +import BigNumber from 'bignumber.js'; +import type { + FillFieldsFragment, + TradeFeeFieldsFragment, +} from './__generated__/Fills'; +import * as Schema from '@vegaprotocol/types'; + +export const TAKER = 'Taker'; +export const MAKER = 'Maker'; + +export type Role = typeof TAKER | typeof MAKER | '-'; + +export const getRoleAndFees = ({ + data, + partyId, +}: { + data: Pick< + FillFieldsFragment, + 'buyerFee' | 'sellerFee' | 'buyer' | 'seller' | 'aggressor' + >; + partyId?: string; +}): { + role: Role; + fees?: TradeFeeFieldsFragment; + marketState?: Schema.MarketState; +} => { + let role: Role; + let fees; + + if (data?.buyer.id === partyId) { + if (data.aggressor === Schema.Side.SIDE_BUY) { + role = TAKER; + fees = data?.buyerFee; + } else if (data.aggressor === Schema.Side.SIDE_SELL) { + role = MAKER; + fees = data?.sellerFee; + } else { + role = '-'; + fees = !isEmptyFeeObj(data?.buyerFee) ? data.buyerFee : data.sellerFee; + } + } else if (data?.seller.id === partyId) { + if (data.aggressor === Schema.Side.SIDE_SELL) { + role = TAKER; + fees = data?.sellerFee; + } else if (data.aggressor === Schema.Side.SIDE_BUY) { + role = MAKER; + fees = data?.buyerFee; + } else { + role = '-'; + fees = !isEmptyFeeObj(data.sellerFee) ? data.sellerFee : data.buyerFee; + } + } else { + return { role: '-', fees: undefined }; + } + + // We make the assumption that the market state is active if the maker fee is zero on both sides + // This needs to be updated when we have a way to get the correct market state when that fill happened from the API + // because the maker fee factor can be set to 0 via governance + const marketState = + data?.buyerFee.makerFee === data.sellerFee.makerFee && + new BigNumber(data?.buyerFee.makerFee).isZero() + ? Schema.MarketState.STATE_SUSPENDED + : Schema.MarketState.STATE_ACTIVE; + return { role, fees, marketState }; +}; + +export const getFeesBreakdown = ( + role: Role, + fees: TradeFeeFieldsFragment, + marketState: Schema.MarketState = Schema.MarketState.STATE_ACTIVE +) => { + // If market is in auction we assume maker fee is zero + const isMarketActive = marketState === Schema.MarketState.STATE_ACTIVE; + + // If role is taker, then these are the fees to be paid + let { makerFee, infrastructureFee, liquidityFee } = fees; + // If role is taker, then these are the fees discounts to be applied + let { + makerFeeVolumeDiscount, + makerFeeReferralDiscount, + infrastructureFeeVolumeDiscount, + infrastructureFeeReferralDiscount, + liquidityFeeVolumeDiscount, + liquidityFeeReferralDiscount, + } = fees; + + if (isMarketActive) { + if (role === MAKER) { + makerFee = new BigNumber(fees.makerFee).times(-1).toString(); + infrastructureFee = '0'; + liquidityFee = '0'; + + // discounts are also zero or we can leave them undefined + infrastructureFeeReferralDiscount = + infrastructureFeeReferralDiscount && '0'; + infrastructureFeeVolumeDiscount = infrastructureFeeVolumeDiscount && '0'; + liquidityFeeReferralDiscount = liquidityFeeReferralDiscount && '0'; + liquidityFeeVolumeDiscount = liquidityFeeVolumeDiscount && '0'; + + // we leave maker discount fees as they are defined + } + } else { + // If market is suspended (in monitoring auction), then half of the fees are paid + infrastructureFee = new BigNumber(infrastructureFee) + .dividedBy(2) + .toString(); + liquidityFee = new BigNumber(liquidityFee).dividedBy(2).toString(); + // maker fee is already zero + makerFee = '0'; + + // discounts are also halved + infrastructureFeeReferralDiscount = + infrastructureFeeReferralDiscount && + new BigNumber(infrastructureFeeReferralDiscount).dividedBy(2).toString(); + infrastructureFeeVolumeDiscount = + infrastructureFeeVolumeDiscount && + new BigNumber(infrastructureFeeVolumeDiscount).dividedBy(2).toString(); + liquidityFeeReferralDiscount = + liquidityFeeReferralDiscount && + new BigNumber(liquidityFeeReferralDiscount).dividedBy(2).toString(); + liquidityFeeVolumeDiscount = + liquidityFeeVolumeDiscount && + new BigNumber(liquidityFeeVolumeDiscount).dividedBy(2).toString(); + // maker discount fees should already be zero + makerFeeReferralDiscount = makerFeeReferralDiscount && '0'; + makerFeeVolumeDiscount = makerFeeVolumeDiscount && '0'; + } + + const totalFee = new BigNumber(infrastructureFee) + .plus(makerFee) + .plus(liquidityFee) + .toString(); + + const totalFeeDiscount = new BigNumber(makerFeeVolumeDiscount || '0') + .plus(makerFeeReferralDiscount || '0') + .plus(infrastructureFeeReferralDiscount || '0') + .plus(infrastructureFeeVolumeDiscount || '0') + .plus(liquidityFeeReferralDiscount || '0') + .plus(liquidityFeeVolumeDiscount || '0') + .toString(); + + return { + infrastructureFee, + infrastructureFeeReferralDiscount, + infrastructureFeeVolumeDiscount, + liquidityFee, + liquidityFeeReferralDiscount, + liquidityFeeVolumeDiscount, + makerFee, + makerFeeReferralDiscount, + makerFeeVolumeDiscount, + totalFee, + totalFeeDiscount, + }; +}; + +export const isEmptyFeeObj = (feeObj: Schema.TradeFee) => { + if (!feeObj) return true; + return ( + feeObj.liquidityFee === '0' && + feeObj.makerFee === '0' && + feeObj.infrastructureFee === '0' + ); +};