From 52c96794f7c40d93cbd15883e56f47fc7085ddf4 Mon Sep 17 00:00:00 2001 From: Matthew Russell Date: Wed, 25 Oct 2023 14:59:30 -0700 Subject: [PATCH] feat(trading): fees page (#5055) --- apps/trading/client-pages/fees/fees.tsx | 11 + apps/trading/client-pages/fees/index.ts | 1 + .../client-pages/portfolio/portfolio.tsx | 4 +- .../components/fees-container/Fees.graphql | 58 +++ .../fees-container/__generated__/Fees.ts | 131 +++++ .../components/fees-container/fees-card.tsx | 36 ++ .../fees-container/fees-container.spec.tsx | 103 ++++ .../fees-container/fees-container.tsx | 490 ++++++++++++++++++ .../components/fees-container/index.ts | 1 + .../components/fees-container/market-fees.tsx | 90 ++++ .../components/fees-container/stat.tsx | 26 + .../components/fees-container/table.tsx | 40 ++ .../fees-container/use-referral-stats.spec.ts | 175 +++++++ .../fees-container/use-referral-stats.ts | 52 ++ .../fees-container/use-volume-stats.spec.ts | 95 ++++ .../fees-container/use-volume-stats.ts | 33 ++ .../components/fees-container/utils.ts | 101 ++++ .../layouts/layout-with-sidebar.tsx | 2 +- .../trading/components/navbar/navbar.spec.tsx | 2 + apps/trading/components/navbar/navbar.tsx | 5 + apps/trading/lib/links.ts | 2 + apps/trading/pages/client-router.tsx | 11 + apps/trading/pages/styles.css | 8 +- .../src/use-network-params.ts | 2 + 24 files changed, 1472 insertions(+), 7 deletions(-) create mode 100644 apps/trading/client-pages/fees/fees.tsx create mode 100644 apps/trading/client-pages/fees/index.ts create mode 100644 apps/trading/components/fees-container/Fees.graphql create mode 100644 apps/trading/components/fees-container/__generated__/Fees.ts create mode 100644 apps/trading/components/fees-container/fees-card.tsx create mode 100644 apps/trading/components/fees-container/fees-container.spec.tsx create mode 100644 apps/trading/components/fees-container/fees-container.tsx create mode 100644 apps/trading/components/fees-container/index.ts create mode 100644 apps/trading/components/fees-container/market-fees.tsx create mode 100644 apps/trading/components/fees-container/stat.tsx create mode 100644 apps/trading/components/fees-container/table.tsx create mode 100644 apps/trading/components/fees-container/use-referral-stats.spec.ts create mode 100644 apps/trading/components/fees-container/use-referral-stats.ts create mode 100644 apps/trading/components/fees-container/use-volume-stats.spec.ts create mode 100644 apps/trading/components/fees-container/use-volume-stats.ts create mode 100644 apps/trading/components/fees-container/utils.ts diff --git a/apps/trading/client-pages/fees/fees.tsx b/apps/trading/client-pages/fees/fees.tsx new file mode 100644 index 000000000..7ab99ae68 --- /dev/null +++ b/apps/trading/client-pages/fees/fees.tsx @@ -0,0 +1,11 @@ +import { t } from '@vegaprotocol/i18n'; +import { FeesContainer } from '../../components/fees-container'; + +export const Fees = () => { + return ( +
+

{t('Fees')}

+ +
+ ); +}; diff --git a/apps/trading/client-pages/fees/index.ts b/apps/trading/client-pages/fees/index.ts new file mode 100644 index 000000000..67bd84688 --- /dev/null +++ b/apps/trading/client-pages/fees/index.ts @@ -0,0 +1 @@ +export { Fees } from './fees'; diff --git a/apps/trading/client-pages/portfolio/portfolio.tsx b/apps/trading/client-pages/portfolio/portfolio.tsx index 31bc80def..854981fc8 100644 --- a/apps/trading/client-pages/portfolio/portfolio.tsx +++ b/apps/trading/client-pages/portfolio/portfolio.tsx @@ -32,7 +32,7 @@ const WithdrawalsIndicator = () => { return null; } return ( - + {ready.length} ); @@ -128,7 +128,7 @@ interface PortfolioGridChildProps { const PortfolioGridChild = ({ children }: PortfolioGridChildProps) => { return (
-
{children}
+
{children}
); }; diff --git a/apps/trading/components/fees-container/Fees.graphql b/apps/trading/components/fees-container/Fees.graphql new file mode 100644 index 000000000..0b058637d --- /dev/null +++ b/apps/trading/components/fees-container/Fees.graphql @@ -0,0 +1,58 @@ +query DiscountPrograms { + currentReferralProgram { + benefitTiers { + minimumEpochs + minimumRunningNotionalTakerVolume + referralDiscountFactor + } + windowLength + } + currentVolumeDiscountProgram { + benefitTiers { + minimumRunningNotionalTakerVolume + volumeDiscountFactor + } + windowLength + } +} + +query Fees( + $partyId: ID! + $volumeDiscountEpochs: Int! + $referralDiscountEpochs: Int! +) { + epoch { + id + } + volumeDiscountStats( + partyId: $partyId + pagination: { last: $volumeDiscountEpochs } + ) { + edges { + node { + atEpoch + discountFactor + runningVolume + } + } + } + referralSetReferees(referee: $partyId) { + edges { + node { + atEpoch + } + } + } + referralSetStats( + partyId: $partyId + pagination: { last: $referralDiscountEpochs } + ) { + edges { + node { + atEpoch + discountFactor + referralSetRunningNotionalTakerVolume + } + } + } +} diff --git a/apps/trading/components/fees-container/__generated__/Fees.ts b/apps/trading/components/fees-container/__generated__/Fees.ts new file mode 100644 index 000000000..064f75229 --- /dev/null +++ b/apps/trading/components/fees-container/__generated__/Fees.ts @@ -0,0 +1,131 @@ +import * as Types from '@vegaprotocol/types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type DiscountProgramsQueryVariables = Types.Exact<{ [key: string]: never; }>; + + +export type DiscountProgramsQuery = { __typename?: 'Query', currentReferralProgram?: { __typename?: 'CurrentReferralProgram', windowLength: number, benefitTiers: Array<{ __typename?: 'BenefitTier', minimumEpochs: number, minimumRunningNotionalTakerVolume: string, referralDiscountFactor: string }> } | null, currentVolumeDiscountProgram?: { __typename?: 'VolumeDiscountProgram', windowLength: number, benefitTiers: Array<{ __typename?: 'VolumeBenefitTier', minimumRunningNotionalTakerVolume: string, volumeDiscountFactor: string }> } | null }; + +export type FeesQueryVariables = Types.Exact<{ + partyId: Types.Scalars['ID']; + volumeDiscountEpochs: Types.Scalars['Int']; + referralDiscountEpochs: Types.Scalars['Int']; +}>; + + +export type FeesQuery = { __typename?: 'Query', epoch: { __typename?: 'Epoch', id: string }, volumeDiscountStats: { __typename?: 'VolumeDiscountStatsConnection', edges: Array<{ __typename?: 'VolumeDiscountStatsEdge', node: { __typename?: 'VolumeDiscountStats', atEpoch: number, discountFactor: string, runningVolume: string } } | null> }, referralSetReferees: { __typename?: 'ReferralSetRefereeConnection', edges: Array<{ __typename?: 'ReferralSetRefereeEdge', node: { __typename?: 'ReferralSetReferee', atEpoch: number } } | null> }, referralSetStats: { __typename?: 'ReferralSetStatsConnection', edges: Array<{ __typename?: 'ReferralSetStatsEdge', node: { __typename?: 'ReferralSetStats', atEpoch: number, discountFactor: string, referralSetRunningNotionalTakerVolume: string } } | null> } }; + + +export const DiscountProgramsDocument = gql` + query DiscountPrograms { + currentReferralProgram { + benefitTiers { + minimumEpochs + minimumRunningNotionalTakerVolume + referralDiscountFactor + } + windowLength + } + currentVolumeDiscountProgram { + benefitTiers { + minimumRunningNotionalTakerVolume + volumeDiscountFactor + } + windowLength + } +} + `; + +/** + * __useDiscountProgramsQuery__ + * + * To run a query within a React component, call `useDiscountProgramsQuery` and pass it any options that fit your needs. + * When your component renders, `useDiscountProgramsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useDiscountProgramsQuery({ + * variables: { + * }, + * }); + */ +export function useDiscountProgramsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(DiscountProgramsDocument, options); + } +export function useDiscountProgramsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(DiscountProgramsDocument, options); + } +export type DiscountProgramsQueryHookResult = ReturnType; +export type DiscountProgramsLazyQueryHookResult = ReturnType; +export type DiscountProgramsQueryResult = Apollo.QueryResult; +export const FeesDocument = gql` + query Fees($partyId: ID!, $volumeDiscountEpochs: Int!, $referralDiscountEpochs: Int!) { + epoch { + id + } + volumeDiscountStats( + partyId: $partyId + pagination: {last: $volumeDiscountEpochs} + ) { + edges { + node { + atEpoch + discountFactor + runningVolume + } + } + } + referralSetReferees(referee: $partyId) { + edges { + node { + atEpoch + } + } + } + referralSetStats(partyId: $partyId, pagination: {last: $referralDiscountEpochs}) { + edges { + node { + atEpoch + discountFactor + referralSetRunningNotionalTakerVolume + } + } + } +} + `; + +/** + * __useFeesQuery__ + * + * To run a query within a React component, call `useFeesQuery` and pass it any options that fit your needs. + * When your component renders, `useFeesQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useFeesQuery({ + * variables: { + * partyId: // value for 'partyId' + * volumeDiscountEpochs: // value for 'volumeDiscountEpochs' + * referralDiscountEpochs: // value for 'referralDiscountEpochs' + * }, + * }); + */ +export function useFeesQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(FeesDocument, options); + } +export function useFeesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(FeesDocument, options); + } +export type FeesQueryHookResult = ReturnType; +export type FeesLazyQueryHookResult = ReturnType; +export type FeesQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/apps/trading/components/fees-container/fees-card.tsx b/apps/trading/components/fees-container/fees-card.tsx new file mode 100644 index 000000000..48d773956 --- /dev/null +++ b/apps/trading/components/fees-container/fees-card.tsx @@ -0,0 +1,36 @@ +import classNames from 'classnames'; +import type { ReactNode } from 'react'; + +export const FeeCard = ({ + children, + title, + className, + loading = false, +}: { + children: ReactNode; + title: string; + className?: string; + loading?: boolean; +}) => { + return ( +
+

{title}

+ {loading ? : children} +
+ ); +}; + +export const FeeCardLoader = () => { + return ( +
+
+
+
+ ); +}; diff --git a/apps/trading/components/fees-container/fees-container.spec.tsx b/apps/trading/components/fees-container/fees-container.spec.tsx new file mode 100644 index 000000000..481c66cc3 --- /dev/null +++ b/apps/trading/components/fees-container/fees-container.spec.tsx @@ -0,0 +1,103 @@ +import { render, screen } from '@testing-library/react'; +import { formatNumber } from '@vegaprotocol/utils'; +import BigNumber from 'bignumber.js'; +import { CurrentVolume, TradingFees } from './fees-container'; +import { formatPercentage, getAdjustedFee } from './utils'; + +describe('TradingFees', () => { + it('renders correct fee data', () => { + const makerFee = 0.01; + const infraFee = 0.01; + const minLiqFee = 0.1; + const maxLiqFee = 0.3; + + const referralDiscount = 0.01; + const volumeDiscount = 0.01; + + const makerBigNum = new BigNumber(makerFee); + const infraBigNum = new BigNumber(infraFee); + const minLiqBigNum = new BigNumber(minLiqFee); + const maxLiqBigNum = new BigNumber(maxLiqFee); + const referralBigNum = new BigNumber(referralDiscount); + const volumeBigNum = new BigNumber(volumeDiscount); + + const props = { + params: { + market_fee_factors_makerFee: makerFee.toString(), + market_fee_factors_infrastructureFee: infraFee.toString(), + }, + markets: [ + { fees: { factors: { liquidityFee: minLiqFee.toString() } } }, + { fees: { factors: { liquidityFee: '0.2' } } }, + { fees: { factors: { liquidityFee: maxLiqFee.toString() } } }, + ], + referralDiscount, + volumeDiscount, + }; + render(); + + const minFee = formatPercentage( + makerBigNum.plus(infraFee).plus(minLiqFee).toNumber() + ); + const maxFee = formatPercentage( + makerBigNum.plus(infraFee).plus(maxLiqFee).toNumber() + ); + + expect( + screen.getByText('Total fee before discount').nextElementSibling + ).toHaveTextContent(`${minFee}%-${maxFee}%`); + expect( + screen.getByText('Infrastructure').nextElementSibling + ).toHaveTextContent(formatPercentage(infraFee) + '%'); + expect(screen.getByText('Maker').nextElementSibling).toHaveTextContent( + formatPercentage(makerFee) + '%' + ); + + const minAdjustedFees = formatPercentage( + getAdjustedFee( + [makerBigNum, infraBigNum, minLiqBigNum], + [referralBigNum, volumeBigNum] + ) + ); + + const maxAdjustedFees = formatPercentage( + getAdjustedFee( + [makerBigNum, infraBigNum, maxLiqBigNum], + [referralBigNum, volumeBigNum] + ) + ); + + expect(screen.getByTestId('adjusted-fees')).toHaveTextContent( + `${minAdjustedFees}%-${maxAdjustedFees}%` + ); + }); +}); + +describe('CurerntVolume', () => { + it('renders the required amount for the next tier', () => { + const windowLengthVolume = 1500; + const nextTierVolume = 2000; + + const props = { + tiers: [ + { minimumRunningNotionalTakerVolume: '1000' }, + { minimumRunningNotionalTakerVolume: nextTierVolume.toString() }, + { minimumRunningNotionalTakerVolume: '3000' }, + ], + tierIndex: 0, + windowLengthVolume, + epochs: 5, + }; + + render(); + + expect( + screen.getByText(formatNumber(windowLengthVolume)).nextElementSibling + ).toHaveTextContent(`Past ${props.epochs} epochs`); + + expect( + screen.getByText(formatNumber(nextTierVolume - windowLengthVolume)) + .nextElementSibling + ).toHaveTextContent('Required for next tier'); + }); +}); diff --git a/apps/trading/components/fees-container/fees-container.tsx b/apps/trading/components/fees-container/fees-container.tsx new file mode 100644 index 000000000..64f6bb018 --- /dev/null +++ b/apps/trading/components/fees-container/fees-container.tsx @@ -0,0 +1,490 @@ +import maxBy from 'lodash/maxBy'; +import minBy from 'lodash/minBy'; +import { t } from '@vegaprotocol/i18n'; +import { useVegaWallet } from '@vegaprotocol/wallet'; +import { + useNetworkParams, + NetworkParams, +} from '@vegaprotocol/network-parameters'; +import { useMarketList } from '@vegaprotocol/markets'; +import { formatNumber } from '@vegaprotocol/utils'; +import { useDiscountProgramsQuery, useFeesQuery } from './__generated__/Fees'; +import { FeeCard } from './fees-card'; +import { MarketFees } from './market-fees'; +import { Stat } from './stat'; +import { useVolumeStats } from './use-volume-stats'; +import { useReferralStats } from './use-referral-stats'; +import { formatPercentage, getAdjustedFee } from './utils'; +import { Table, Td, Th, THead, Tr } from './table'; +import BigNumber from 'bignumber.js'; + +export const FeesContainer = () => { + const { pubKey } = useVegaWallet(); + const { params, loading: paramsLoading } = useNetworkParams([ + NetworkParams.market_fee_factors_makerFee, + NetworkParams.market_fee_factors_infrastructureFee, + ]); + + const { data: markets, loading: marketsLoading } = useMarketList(); + + const { data: programData, loading: programLoading } = + useDiscountProgramsQuery(); + + const volumeDiscountEpochs = + programData?.currentVolumeDiscountProgram?.windowLength || 1; + const referralDiscountEpochs = + programData?.currentReferralProgram?.windowLength || 1; + + const { data: feesData, loading: feesLoading } = useFeesQuery({ + variables: { + partyId: pubKey || '', + volumeDiscountEpochs, + referralDiscountEpochs, + }, + skip: !pubKey || !programData, + }); + + const { volumeDiscount, volumeTierIndex, volumeInWindow, volumeTiers } = + useVolumeStats( + feesData?.volumeDiscountStats, + programData?.currentVolumeDiscountProgram + ); + + const { + referralDiscount, + referralVolumeInWindow, + referralTierIndex, + referralTiers, + epochsInSet, + } = useReferralStats( + feesData?.referralSetStats, + feesData?.referralSetReferees, + programData?.currentReferralProgram, + feesData?.epoch + ); + + const loading = paramsLoading || feesLoading || programLoading; + const isConnected = Boolean(pubKey); + + return ( +
+ {isConnected && ( + <> + + + + + + + + + + + + + + )} + + + + + + + + + +
+ ); +}; + +export const TradingFees = ({ + params, + markets, + referralDiscount, + volumeDiscount, +}: { + params: { + market_fee_factors_infrastructureFee: string; + market_fee_factors_makerFee: string; + }; + markets: Array<{ fees: { factors: { liquidityFee: string } } }> | null; + referralDiscount: number; + volumeDiscount: number; +}) => { + const referralDiscountBigNum = new BigNumber(referralDiscount); + const volumeDiscountBigNum = new BigNumber(volumeDiscount); + + // Show min and max liquidity fees from all markets + const minLiq = minBy(markets, (m) => Number(m.fees.factors.liquidityFee)); + const maxLiq = maxBy(markets, (m) => Number(m.fees.factors.liquidityFee)); + + const total = new BigNumber(params.market_fee_factors_makerFee).plus( + new BigNumber(params.market_fee_factors_infrastructureFee) + ); + + const adjustedTotal = getAdjustedFee( + [total], + [referralDiscountBigNum, volumeDiscountBigNum] + ); + + let minTotal; + let maxTotal; + + let minAdjustedTotal; + let maxAdjustedTotal; + + if (minLiq && maxLiq) { + const minLiqFee = new BigNumber(minLiq.fees.factors.liquidityFee); + const maxLiqFee = new BigNumber(maxLiq.fees.factors.liquidityFee); + + minTotal = total.plus(minLiqFee); + maxTotal = total.plus(maxLiqFee); + + minAdjustedTotal = getAdjustedFee( + [total, minLiqFee], + [referralDiscountBigNum, volumeDiscountBigNum] + ); + + maxAdjustedTotal = getAdjustedFee( + [total, maxLiqFee], + [referralDiscountBigNum, volumeDiscountBigNum] + ); + } + + return ( +
+
+

+ {minAdjustedTotal !== undefined && maxAdjustedTotal !== undefined + ? `${formatPercentage(minAdjustedTotal)}%-${formatPercentage( + maxAdjustedTotal + )}%` + : `${formatPercentage(adjustedTotal)}%`} +

+ + + + + + + + + + + + + + + {minLiq && maxLiq && ( + + + + + )} + +
+ {t('Total fee before discount')} + + {minTotal !== undefined && maxTotal !== undefined + ? `${formatPercentage( + minTotal.toNumber() + )}%-${formatPercentage(maxTotal.toNumber())}%` + : `${formatPercentage(total.toNumber())}%`} +
{t('Infrastructure')} + {formatPercentage( + Number(params.market_fee_factors_infrastructureFee) + )} + % +
{t('Maker')} + {formatPercentage(Number(params.market_fee_factors_makerFee))}% +
{t('Liquidity')} + {formatPercentage(Number(minLiq.fees.factors.liquidityFee))}% + {'-'} + {formatPercentage(Number(maxLiq.fees.factors.liquidityFee))}% +
+
+
+ ); +}; + +export const CurrentVolume = ({ + tiers, + tierIndex, + windowLengthVolume, + epochs, +}: { + tiers: Array<{ minimumRunningNotionalTakerVolume: string }>; + tierIndex: number; + windowLengthVolume: number; + epochs: number; +}) => { + const nextTier = tiers[tierIndex + 1]; + const requiredForNextTier = nextTier + ? Number(nextTier.minimumRunningNotionalTakerVolume) - windowLengthVolume + : 0; + + return ( +
+ + {requiredForNextTier > 0 && ( + + )} +
+ ); +}; + +const ReferralBenefits = ({ + epochsInSet, + setRunningNotionalTakerVolume, + epochs, +}: { + epochsInSet: number; + setRunningNotionalTakerVolume: number; + epochs: number; +}) => { + return ( +
+ + +
+ ); +}; + +const TotalDiscount = ({ + referralDiscount, + volumeDiscount, +}: { + referralDiscount: number; + volumeDiscount: number; +}) => { + return ( +
+ + + + + + + + + + + + +
{t('Volume discount')}{formatPercentage(volumeDiscount)}%
{t('Referral discount')} + {formatPercentage(referralDiscount)}% +
+
+ ); +}; + +const VolumeTiers = ({ + tiers, + tierIndex, + lastEpochVolume, +}: { + tiers: Array<{ + volumeDiscountFactor: string; + minimumRunningNotionalTakerVolume: string; + }>; + tierIndex: number; + lastEpochVolume: number; +}) => { + if (!tiers.length) { + return ( +

+ {t('No volume discount program active')} +

+ ); + } + + return ( +
+ + + + + + + + + + + {Array.from(tiers) + .reverse() + .map((tier, i) => { + const isUserTier = tiers.length - 1 - tierIndex === i; + + return ( + + + + + + + + ); + })} + +
{t('Tier')}{t('Discount')}{t('Min. trading volume')}{t('My volume (last epoch)')} +
{i + 1} + {formatPercentage(Number(tier.volumeDiscountFactor))}% + + {formatNumber(tier.minimumRunningNotionalTakerVolume)} + {isUserTier ? formatNumber(lastEpochVolume) : ''}{isUserTier ? : null}
+
+ ); +}; + +const ReferralTiers = ({ + tiers, + tierIndex, + epochsInSet, + referralVolumeInWindow, +}: { + tiers: Array<{ + referralDiscountFactor: string; + minimumRunningNotionalTakerVolume: string; + minimumEpochs: number; + }>; + tierIndex: number; + epochsInSet: number; + referralVolumeInWindow: number; +}) => { + if (!tiers.length) { + return ( +

{t('No referral program active')}

+ ); + } + + return ( +
+ + + + + + + + + + + {Array.from(tiers) + .reverse() + .map((t, i) => { + const isUserTier = tiers.length - 1 - tierIndex === i; + + const requiredVolume = Number( + t.minimumRunningNotionalTakerVolume + ); + let unlocksIn = null; + + if ( + referralVolumeInWindow >= requiredVolume && + epochsInSet < t.minimumEpochs + ) { + unlocksIn = ( + + Unlocks in {t.minimumEpochs - epochsInSet} epochs + + ); + } + + return ( + + + + + + + + ); + })} + +
{t('Tier')}{t('Discount')}{t('Min. trading volume')}{t('Required epochs')} +
{i + 1}{formatPercentage(Number(t.referralDiscountFactor))}%{formatNumber(t.minimumRunningNotionalTakerVolume)}{t.minimumEpochs}{isUserTier ? : unlocksIn}
+
+ ); +}; + +const YourTier = () => { + return ( + + {t('Your tier')} + + ); +}; diff --git a/apps/trading/components/fees-container/index.ts b/apps/trading/components/fees-container/index.ts new file mode 100644 index 000000000..63f9ce8ee --- /dev/null +++ b/apps/trading/components/fees-container/index.ts @@ -0,0 +1 @@ +export { FeesContainer } from './fees-container'; diff --git a/apps/trading/components/fees-container/market-fees.tsx b/apps/trading/components/fees-container/market-fees.tsx new file mode 100644 index 000000000..12b6cd646 --- /dev/null +++ b/apps/trading/components/fees-container/market-fees.tsx @@ -0,0 +1,90 @@ +import compact from 'lodash/compact'; +import type { MarketMaybeWithDataAndCandles } from '@vegaprotocol/markets'; +import { AgGrid } from '@vegaprotocol/datagrid'; +import { t } from '@vegaprotocol/i18n'; +import { formatPercentage, getAdjustedFee } from './utils'; +import { MarketCodeCell } from '../../client-pages/markets/market-code-cell'; +import BigNumber from 'bignumber.js'; + +const feesTableColumnDefs = [ + { field: 'code', cellRenderer: 'MarketCodeCell' }, + { + field: 'feeAfterDiscount', + headerName: t('Total fee after discount'), + valueFormatter: ({ value }: { value: number }) => value + '%', + }, + { + field: 'infraFee', + valueFormatter: ({ value }: { value: number }) => value + '%', + }, + { + field: 'makerFee', + valueFormatter: ({ value }: { value: number }) => value + '%', + }, + { + field: 'liquidityFee', + valueFormatter: ({ value }: { value: number }) => value + '%', + }, + { + field: 'totalFee', + headerName: t('Total fee before discount'), + valueFormatter: ({ value }: { value: number }) => value + '%', + }, +]; + +const feesTableDefaultColDef = { + flex: 1, + resizable: true, + sortable: true, +}; + +const components = { + MarketCodeCell, +}; + +export const MarketFees = ({ + markets, + referralDiscount, + volumeDiscount, +}: { + markets: MarketMaybeWithDataAndCandles[] | null; + referralDiscount: number; + volumeDiscount: number; +}) => { + const rows = compact(markets || []).map((m) => { + const infraFee = new BigNumber(m.fees.factors.infrastructureFee); + const makerFee = new BigNumber(m.fees.factors.makerFee); + const liquidityFee = new BigNumber(m.fees.factors.liquidityFee); + const totalFee = infraFee.plus(makerFee).plus(liquidityFee); + + const feeAfterDiscount = getAdjustedFee( + [infraFee, makerFee, liquidityFee], + [new BigNumber(referralDiscount), new BigNumber(volumeDiscount)] + ); + + return { + code: m.tradableInstrument.instrument.code, + productType: m.tradableInstrument.instrument.product.__typename, + infraFee: formatPercentage(infraFee.toNumber()), + makerFee: formatPercentage(makerFee.toNumber()), + liquidityFee: formatPercentage(liquidityFee.toNumber()), + totalFee: formatPercentage(totalFee.toNumber()), + feeAfterDiscount: formatPercentage(feeAfterDiscount), + parentMarketID: m.parentMarketID, + successorMarketID: m.successorMarketID, + }; + }); + + return ( +
+ +
+ ); +}; diff --git a/apps/trading/components/fees-container/stat.tsx b/apps/trading/components/fees-container/stat.tsx new file mode 100644 index 000000000..fe2784aae --- /dev/null +++ b/apps/trading/components/fees-container/stat.tsx @@ -0,0 +1,26 @@ +import classNames from 'classnames'; + +export const Stat = ({ + value, + text, + highlight, +}: { + value: string | number; + text?: string; + highlight?: boolean; +}) => { + return ( +

+ + {value} + + {text && ( + {text} + )} +

+ ); +}; diff --git a/apps/trading/components/fees-container/table.tsx b/apps/trading/components/fees-container/table.tsx new file mode 100644 index 000000000..76f795935 --- /dev/null +++ b/apps/trading/components/fees-container/table.tsx @@ -0,0 +1,40 @@ +import classNames from 'classnames'; +import type { ReactNode } from 'react'; + +const cellClass = 'px-4 py-2 text-xs font-normal text-left last:text-right'; + +export const Th = ({ children }: { children?: ReactNode }) => { + return ( + + {children} + + ); +}; + +export const Td = ({ children }: { children?: ReactNode }) => { + return {children}; +}; + +export const Tr = ({ children }: { children?: ReactNode }) => { + return ( + + {children} + + ); +}; + +export const Table = ({ children }: { children: ReactNode }) => { + return ( + + {children} +
+ ); +}; + +export const THead = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; diff --git a/apps/trading/components/fees-container/use-referral-stats.spec.ts b/apps/trading/components/fees-container/use-referral-stats.spec.ts new file mode 100644 index 000000000..ffd3c4a7c --- /dev/null +++ b/apps/trading/components/fees-container/use-referral-stats.spec.ts @@ -0,0 +1,175 @@ +import { renderHook } from '@testing-library/react'; +import { useReferralStats } from './use-referral-stats'; + +describe('useReferralStats', () => { + const setStats = { + edges: [ + { + __typename: 'ReferralSetStatsEdge' as const, + node: { + __typename: 'ReferralSetStats' as const, + atEpoch: 9, + discountFactor: '0.2', + referralSetRunningNotionalTakerVolume: '100', + }, + }, + { + __typename: 'ReferralSetStatsEdge' as const, + node: { + __typename: 'ReferralSetStats' as const, + atEpoch: 10, + discountFactor: '0.3', + referralSetRunningNotionalTakerVolume: '200', + }, + }, + ], + }; + + const sets = { + edges: [ + { + node: { + atEpoch: 3, + }, + }, + { + node: { + atEpoch: 4, + }, + }, + ], + }; + + const epoch = { + id: '10', + }; + + const program = { + windowLength: 5, + benefitTiers: [ + { + minimumEpochs: 4, + minimumRunningNotionalTakerVolume: '100', + referralDiscountFactor: '0.01', + }, + { + minimumEpochs: 6, + minimumRunningNotionalTakerVolume: '200', + referralDiscountFactor: '0.05', + }, + { + minimumEpochs: 8, + minimumRunningNotionalTakerVolume: '300', + referralDiscountFactor: '0.1', + }, + ], + }; + + it('returns correct default values', () => { + const { result } = renderHook(() => useReferralStats()); + expect(result.current).toEqual({ + referralDiscount: 0, + referralVolumeInWindow: 0, + referralTierIndex: -1, + referralTiers: [], + epochsInSet: 0, + }); + }); + + it('returns formatted data and tiers', () => { + const { result } = renderHook(() => + useReferralStats(setStats, sets, program, epoch) + ); + + // should use stats from latest epoch + const stats = setStats.edges[1].node; + const set = sets.edges[1].node; + + expect(result.current).toEqual({ + referralDiscount: Number(stats.discountFactor), + referralVolumeInWindow: Number( + stats.referralSetRunningNotionalTakerVolume + ), + referralTierIndex: 1, + referralTiers: program.benefitTiers, + epochsInSet: Number(epoch.id) - set.atEpoch, + }); + }); + + it.each([ + { joinedAt: 2, index: -1 }, + { joinedAt: 3, index: -1 }, + { joinedAt: 4, index: 0 }, + { joinedAt: 5, index: 0 }, + { joinedAt: 6, index: 1 }, + { joinedAt: 7, index: 1 }, + { joinedAt: 8, index: 2 }, + { joinedAt: 9, index: 2 }, + ])('joined at epoch: $joinedAt should be index: $index', (obj) => { + const statsA = { + edges: [ + { + __typename: 'ReferralSetStatsEdge' as const, + node: { + __typename: 'ReferralSetStats' as const, + atEpoch: 10, + discountFactor: '0.3', + referralSetRunningNotionalTakerVolume: '100000', + }, + }, + ], + }; + const setsA = { + edges: [ + { + node: { + atEpoch: Number(epoch.id) - obj.joinedAt, + }, + }, + ], + }; + const { result } = renderHook(() => + useReferralStats(statsA, setsA, program, epoch) + ); + + expect(result.current.referralTierIndex).toEqual(obj.index); + }); + + it.each([ + { volume: '50', index: -1 }, + { volume: '100', index: 0 }, + { volume: '150', index: 0 }, + { volume: '200', index: 1 }, + { volume: '250', index: 1 }, + { volume: '300', index: 2 }, + { volume: '999', index: 2 }, + ])('volume: $volume should be index: $index', (obj) => { + const statsA = { + edges: [ + { + __typename: 'ReferralSetStatsEdge' as const, + node: { + __typename: 'ReferralSetStats' as const, + atEpoch: 10, + discountFactor: '0.3', + referralSetRunningNotionalTakerVolume: obj.volume, + }, + }, + ], + }; + const setsA = { + edges: [ + { + node: { + atEpoch: 1, + }, + }, + ], + }; + const { result } = renderHook(() => + useReferralStats(statsA, setsA, program, epoch) + ); + + expect(result.current.referralTierIndex).toEqual(obj.index); + }); +}); diff --git a/apps/trading/components/fees-container/use-referral-stats.ts b/apps/trading/components/fees-container/use-referral-stats.ts new file mode 100644 index 000000000..dbdafb41b --- /dev/null +++ b/apps/trading/components/fees-container/use-referral-stats.ts @@ -0,0 +1,52 @@ +import compact from 'lodash/compact'; +import maxBy from 'lodash/maxBy'; +import { getReferralBenefitTier } from './utils'; +import type { DiscountProgramsQuery, FeesQuery } from './__generated__/Fees'; + +export const useReferralStats = ( + setStats?: FeesQuery['referralSetStats'], + setReferees?: FeesQuery['referralSetReferees'], + program?: DiscountProgramsQuery['currentReferralProgram'], + epoch?: FeesQuery['epoch'] +) => { + const referralTiers = program?.benefitTiers || []; + + if (!setStats || !setReferees || !program || !epoch) { + return { + referralDiscount: 0, + referralVolumeInWindow: 0, + referralTierIndex: -1, + referralTiers, + epochsInSet: 0, + }; + } + + const referralSetsStats = compact(setStats.edges).map((e) => e.node); + const referralSets = compact(setReferees.edges).map((e) => e.node); + + const referralSet = maxBy(referralSets, (s) => s.atEpoch); + const referralStats = maxBy(referralSetsStats, (s) => s.atEpoch); + + const epochsInSet = referralSet ? Number(epoch.id) - referralSet.atEpoch : 0; + + const referralDiscount = Number(referralStats?.discountFactor || 0); + const referralVolumeInWindow = Number( + referralStats?.referralSetRunningNotionalTakerVolume || 0 + ); + + const referralTierIndex = referralStats + ? getReferralBenefitTier( + epochsInSet, + Number(referralStats.referralSetRunningNotionalTakerVolume), + referralTiers + ) + : -1; + + return { + referralDiscount, + referralVolumeInWindow, + referralTierIndex, + referralTiers, + epochsInSet, + }; +}; diff --git a/apps/trading/components/fees-container/use-volume-stats.spec.ts b/apps/trading/components/fees-container/use-volume-stats.spec.ts new file mode 100644 index 000000000..b9c327054 --- /dev/null +++ b/apps/trading/components/fees-container/use-volume-stats.spec.ts @@ -0,0 +1,95 @@ +import { renderHook } from '@testing-library/react'; +import { useVolumeStats } from './use-volume-stats'; + +describe('useReferralStats', () => { + const statsList = { + edges: [ + { + __typename: 'VolumeDiscountStatsEdge' as const, + node: { + __typename: 'VolumeDiscountStats' as const, + atEpoch: 9, + discountFactor: '0.1', + runningVolume: '100', + }, + }, + { + __typename: 'VolumeDiscountStatsEdge' as const, + node: { + __typename: 'VolumeDiscountStats' as const, + atEpoch: 10, + discountFactor: '0.3', + runningVolume: '200', + }, + }, + ], + }; + + const program = { + windowLength: 5, + benefitTiers: [ + { + minimumRunningNotionalTakerVolume: '100', + volumeDiscountFactor: '0.01', + }, + { + minimumRunningNotionalTakerVolume: '200', + volumeDiscountFactor: '0.05', + }, + { + minimumRunningNotionalTakerVolume: '300', + volumeDiscountFactor: '0.1', + }, + ], + }; + + it('returns correct default values', () => { + const { result } = renderHook(() => useVolumeStats()); + expect(result.current).toEqual({ + volumeDiscount: 0, + volumeInWindow: 0, + volumeTierIndex: -1, + volumeTiers: [], + }); + }); + + it('returns formatted data and tiers', () => { + const { result } = renderHook(() => useVolumeStats(statsList, program)); + + // should use stats from latest epoch + const stats = statsList.edges[1].node; + + expect(result.current).toEqual({ + volumeDiscount: Number(stats.discountFactor), + volumeInWindow: Number(stats.runningVolume), + volumeTierIndex: 1, + volumeTiers: program.benefitTiers, + }); + }); + + it.each([ + { volume: '100', index: 0 }, + { volume: '150', index: 0 }, + { volume: '200', index: 1 }, + { volume: '250', index: 1 }, + { volume: '300', index: 2 }, + { volume: '350', index: 2 }, + ])('returns index: $index for the running volume: $volume', (obj) => { + const statsA = { + edges: [ + { + __typename: 'VolumeDiscountStatsEdge' as const, + node: { + __typename: 'VolumeDiscountStats' as const, + atEpoch: 10, + discountFactor: '0.3', + runningVolume: obj.volume, + }, + }, + ], + }; + + const { result } = renderHook(() => useVolumeStats(statsA, program)); + expect(result.current.volumeTierIndex).toBe(obj.index); + }); +}); diff --git a/apps/trading/components/fees-container/use-volume-stats.ts b/apps/trading/components/fees-container/use-volume-stats.ts new file mode 100644 index 000000000..39c1b787a --- /dev/null +++ b/apps/trading/components/fees-container/use-volume-stats.ts @@ -0,0 +1,33 @@ +import compact from 'lodash/compact'; +import maxBy from 'lodash/maxBy'; +import { getVolumeTier } from './utils'; +import type { DiscountProgramsQuery, FeesQuery } from './__generated__/Fees'; + +export const useVolumeStats = ( + stats?: FeesQuery['volumeDiscountStats'], + program?: DiscountProgramsQuery['currentVolumeDiscountProgram'] +) => { + const volumeTiers = program?.benefitTiers || []; + + if (!stats || !program) { + return { + volumeDiscount: 0, + volumeTierIndex: -1, + volumeInWindow: 0, + volumeTiers, + }; + } + + const volumeStats = compact(stats.edges).map((e) => e.node); + const lastEpochStats = maxBy(volumeStats, (s) => s.atEpoch); + const volumeDiscount = Number(lastEpochStats?.discountFactor || 0); + const volumeInWindow = Number(lastEpochStats?.runningVolume || 0); + const volumeTierIndex = getVolumeTier(volumeInWindow, volumeTiers); + + return { + volumeDiscount, + volumeTierIndex, + volumeInWindow, + volumeTiers, + }; +}; diff --git a/apps/trading/components/fees-container/utils.ts b/apps/trading/components/fees-container/utils.ts new file mode 100644 index 000000000..e0ce161c2 --- /dev/null +++ b/apps/trading/components/fees-container/utils.ts @@ -0,0 +1,101 @@ +import { getUserLocale } from '@vegaprotocol/utils'; +import BigNumber from 'bignumber.js'; + +/** + * Convert a number between 0-1 into a percentage value between 0-100 + * + * Not using formatNumberPercentage from vegaprotocol/utils as this + * returns a string and includes extra 0s on the end. We need these + * values in aggrid as numbers for sorting + */ +export const formatPercentage = (num: number) => { + const pct = new BigNumber(num).times(100); + const dps = pct.decimalPlaces(); + const formatter = new Intl.NumberFormat(getUserLocale(), { + minimumFractionDigits: dps || 0, + maximumFractionDigits: dps || 0, + }); + return formatter.format(parseFloat(pct.toFixed(5))); +}; + +/** + * Return the index of the benefit tier for volume discounts. A user + * only needs to fulfill a minimum volume requirement for the tier + */ +export const getVolumeTier = ( + volume: number, + tiers: Array<{ + minimumRunningNotionalTakerVolume: string; + }> +) => { + return tiers.findIndex((tier, i) => { + const nextTier = tiers[i + 1]; + const validVolume = + volume >= Number(tier.minimumRunningNotionalTakerVolume); + + if (nextTier) { + return ( + validVolume && + volume < Number(nextTier.minimumRunningNotionalTakerVolume) + ); + } + + return validVolume; + }); +}; + +/** + * Return the index of the benefit tiers for referrals. A user must + * fulfill both the minimum epochs in the referral set, and the set + * must reach the combined total volume + */ +export const getReferralBenefitTier = ( + epochsInSet: number, + volume: number, + tiers: Array<{ + minimumRunningNotionalTakerVolume: string; + minimumEpochs: number; + }> +) => { + const indexByEpoch = tiers.findIndex((tier, i) => { + const nextTier = tiers[i + 1]; + const validEpochs = epochsInSet >= tier.minimumEpochs; + + if (nextTier) { + return validEpochs && epochsInSet < nextTier.minimumEpochs; + } + + return validEpochs; + }); + const indexByVolume = tiers.findIndex((tier, i) => { + const nextTier = tiers[i + 1]; + const validVolume = + volume >= Number(tier.minimumRunningNotionalTakerVolume); + + if (nextTier) { + return ( + validVolume && + volume < Number(nextTier.minimumRunningNotionalTakerVolume) + ); + } + + return validVolume; + }); + + return Math.min(indexByEpoch, indexByVolume); +}; + +/** + * Given a set of fees and a set of discounts return + * the adjusted fee factor + */ +export const getAdjustedFee = (fees: BigNumber[], discounts: BigNumber[]) => { + const totalFee = fees.reduce((sum, f) => sum.plus(f), new BigNumber(0)); + const totalDiscount = discounts.reduce( + (sum, d) => sum.plus(d), + new BigNumber(0) + ); + return totalFee + .times(BigNumber.max(0, new BigNumber(1).minus(totalDiscount))) + .toNumber(); +}; diff --git a/apps/trading/components/layouts/layout-with-sidebar.tsx b/apps/trading/components/layouts/layout-with-sidebar.tsx index f1853f48f..eec996706 100644 --- a/apps/trading/components/layouts/layout-with-sidebar.tsx +++ b/apps/trading/components/layouts/layout-with-sidebar.tsx @@ -27,7 +27,7 @@ export const LayoutWithSidebar = ({
{header}
{ [`/markets/${marketId}`, 'Trading'], ['/portfolio', 'Portfolio'], ['/referrals', 'Referrals'], + ['/fees', 'Fees'], [expect.stringContaining('governance'), 'Governance'], ]; @@ -100,6 +101,7 @@ describe('Navbar', () => { [`/markets/${marketId}`, 'Trading'], ['/portfolio', 'Portfolio'], ['/referrals', 'Referrals'], + ['/fees', 'Fees'], [expect.stringContaining('governance'), 'Governance'], ]; const links = menu.getAllByRole('link'); diff --git a/apps/trading/components/navbar/navbar.tsx b/apps/trading/components/navbar/navbar.tsx index 1986f3001..72644837d 100644 --- a/apps/trading/components/navbar/navbar.tsx +++ b/apps/trading/components/navbar/navbar.tsx @@ -187,6 +187,11 @@ const NavbarMenu = ({ onClick }: { onClick: () => void }) => { )} + + + {t('Fees')} + + {t('Governance')} diff --git a/apps/trading/lib/links.ts b/apps/trading/lib/links.ts index 65da0c356..f48128ec3 100644 --- a/apps/trading/lib/links.ts +++ b/apps/trading/lib/links.ts @@ -17,6 +17,7 @@ export const Routes = { REFERRALS_APPLY_CODE: '/referrals/apply-code', REFERRALS_CREATE_CODE: '/referrals/create-code', TEAMS: '/teams', + FEES: '/fees', } as const; type ConsoleLinks = { @@ -40,4 +41,5 @@ export const Links: ConsoleLinks = { REFERRALS_APPLY_CODE: () => Routes.REFERRALS_APPLY_CODE, REFERRALS_CREATE_CODE: () => Routes.REFERRALS_CREATE_CODE, TEAMS: () => Routes.TEAMS, + FEES: () => Routes.FEES, }; diff --git a/apps/trading/pages/client-router.tsx b/apps/trading/pages/client-router.tsx index abc019cb6..52254d5d2 100644 --- a/apps/trading/pages/client-router.tsx +++ b/apps/trading/pages/client-router.tsx @@ -13,6 +13,7 @@ import { Assets } from '../client-pages/assets'; import { Deposit } from '../client-pages/deposit'; import { Withdraw } from '../client-pages/withdraw'; import { Transfer } from '../client-pages/transfer'; +import { Fees } from '../client-pages/fees'; import { Routes as AppRoutes } from '../lib/links'; import { LayoutWithSky } from '../client-pages/referrals/layout'; import { Referrals } from '../client-pages/referrals/referrals'; @@ -85,6 +86,16 @@ export const routerConfig: RouteObject[] = compact([ ], } : undefined, + { + path: 'fees/*', + element: } />, + children: [ + { + index: true, + element: , + }, + ], + }, { path: 'markets/*', element: ( diff --git a/apps/trading/pages/styles.css b/apps/trading/pages/styles.css index 88db4c68f..c1d09cceb 100644 --- a/apps/trading/pages/styles.css +++ b/apps/trading/pages/styles.css @@ -184,10 +184,10 @@ html [data-theme='dark'] { /* Light variables */ .ag-theme-balham { - --ag-background-color: theme(colors.white); + --ag-background-color: transparent; --ag-border-color: theme(colors.vega.clight.600); --ag-header-background-color: theme(colors.vega.clight.700); - --ag-odd-row-background-color: theme(colors.white); + --ag-odd-row-background-color: transparent; --ag-header-column-separator-color: theme(colors.vega.clight.500); --ag-row-border-color: theme(colors.vega.clight.600); --ag-row-hover-color: theme(colors.vega.clight.800); @@ -196,10 +196,10 @@ html [data-theme='dark'] { /* Dark variables */ .ag-theme-balham-dark { - --ag-background-color: theme(colors.vega.cdark.900); + --ag-background-color: transparent; --ag-border-color: theme(colors.vega.cdark.600); --ag-header-background-color: theme(colors.vega.cdark.700); - --ag-odd-row-background-color: theme(colors.vega.cdark.900); + --ag-odd-row-background-color: transparent; --ag-header-column-separator-color: theme(colors.vega.cdark.500); --ag-row-border-color: theme(colors.vega.cdark.600); --ag-row-hover-color: theme(colors.vega.cdark.800); diff --git a/libs/network-parameters/src/use-network-params.ts b/libs/network-parameters/src/use-network-params.ts index 2024c2944..fcbeffe92 100644 --- a/libs/network-parameters/src/use-network-params.ts +++ b/libs/network-parameters/src/use-network-params.ts @@ -153,6 +153,8 @@ export const NetworkParams = { 'spam_protection_minimumWithdrawalQuantumMultiple', spam_protection_voting_min_tokens: 'spam_protection_voting_min_tokens', spam_protection_proposal_min_tokens: 'spam_protection_proposal_min_tokens', + market_fee_factors_infrastructureFee: 'market_fee_factors_infrastructureFee', + market_fee_factors_makerFee: 'market_fee_factors_makerFee', market_liquidity_targetstake_triggering_ratio: 'market_liquidity_targetstake_triggering_ratio', market_liquidity_bondPenaltyParameter: