fix(trading): fills fees maker discounts (#5406)

This commit is contained in:
m.ray 2023-12-01 18:34:22 +02:00 committed by GitHub
parent 61471228aa
commit a59f7dfd29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 377 additions and 241 deletions

View File

@ -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<Trade> = {
@ -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()
);
});
});
});

View File

@ -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<Trade, 'market'>) => {
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"
>
<p className="mb-1 italic">
{t('If the market was %s', [
Schema.MarketStateMapping[marketState].toLowerCase(),
])}
</p>
{marketState && (
<p className="mb-1 italic">
{t('If the market was %s', [
Schema.MarketStateMapping[marketState].toLowerCase(),
])}
</p>
)}
{role === MAKER && (
<>
<p className="mb-1">{t('The maker will receive the maker fee.')}</p>
@ -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 (
<div
data-testid="fee-discount-breakdown-tooltip"
@ -477,58 +415,14 @@ export const FeesDiscountBreakdownTooltip = ({
label={t('Volume Discount')}
asset={asset}
/>
<dt className="col-span-2">{t('Total Fee Discount')}</dt>
<FeesDiscountBreakdownTooltipItem
value={fees.totalFeeDiscount}
label={''}
asset={asset}
/>
</dl>
</div>
);
};
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,
};
};

View File

@ -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());
});
});

View File

@ -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'
);
};