From 0ced41d04be392cc12afe2ce9d2ee917825e4ab7 Mon Sep 17 00:00:00 2001 From: chloe-tan <95644202+chloe-tan@users.noreply.github.com> Date: Fri, 24 Jan 2025 11:52:18 -0500 Subject: [PATCH 1/7] chore(funkit): bump funkit version 4.1.7 (#1464) --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 754516bc9..24d1a962e 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@dydxprotocol/v4-localization": "^1.1.259", "@dydxprotocol/v4-proto": "^7.0.0-dev.0", "@emotion/is-prop-valid": "^1.3.0", - "@funkit/connect": "^4.1.6", + "@funkit/connect": "^4.1.7", "@hugocxl/react-to-image": "^0.0.9", "@js-joda/core": "^5.5.3", "@keplr-wallet/types": "^0.12.121", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd1943a73..df748e5d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,8 +45,8 @@ dependencies: specifier: ^1.3.0 version: 1.3.0 '@funkit/connect': - specifier: ^4.1.6 - version: 4.1.6(@tanstack/react-query@5.37.1)(@types/react@18.3.3)(babel-plugin-macros@3.1.0)(immer@10.1.1)(react-dom@18.2.0)(react@18.2.0)(typescript@5.7.2)(viem@2.17.0)(wagmi@2.10.9) + specifier: ^4.1.7 + version: 4.1.7(@tanstack/react-query@5.37.1)(@types/react@18.3.3)(babel-plugin-macros@3.1.0)(immer@10.1.1)(react-dom@18.2.0)(react@18.2.0)(typescript@5.7.2)(viem@2.17.0)(wagmi@2.10.9) '@hugocxl/react-to-image': specifier: ^0.0.9 version: 0.0.9(html-to-image@1.11.11)(react@18.2.0) @@ -4403,8 +4403,8 @@ packages: viem: 2.17.0(typescript@5.7.2) dev: false - /@funkit/connect@4.1.6(@tanstack/react-query@5.37.1)(@types/react@18.3.3)(babel-plugin-macros@3.1.0)(immer@10.1.1)(react-dom@18.2.0)(react@18.2.0)(typescript@5.7.2)(viem@2.17.0)(wagmi@2.10.9): - resolution: {integrity: sha512-Cn3HM+or9yQRIEAaGTvTyYNAb7joIcWpynDmgrkjdIqgZedHOEIr1iQd9d6HN8+pSw4j0V8YW/3ApuBADvAHiQ==} + /@funkit/connect@4.1.7(@tanstack/react-query@5.37.1)(@types/react@18.3.3)(babel-plugin-macros@3.1.0)(immer@10.1.1)(react-dom@18.2.0)(react@18.2.0)(typescript@5.7.2)(viem@2.17.0)(wagmi@2.10.9): + resolution: {integrity: sha512-bM6adjE+BmKqvMxowwin797WU4aTsah5iq8sexgT640M3bRVCkKcDAKrwnTb7LIAXJtkzztD/MDdieSmYWW+/g==} engines: {node: '>=18'} peerDependencies: '@tanstack/react-query': '>=5.0.0' From b3f28f7282165c7ba051828df8bb6ae0f02a3c4f Mon Sep 17 00:00:00 2001 From: tyleroooo Date: Fri, 24 Jan 2025 12:07:15 -0500 Subject: [PATCH 2/7] fix: funding decimals (#1467) --- src/constants/numbers.ts | 2 +- src/views/MarketStatsDetails.tsx | 4 ++-- src/views/charts/FundingChart/Tooltip.tsx | 2 ++ src/views/charts/FundingChart/index.tsx | 7 ++++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/constants/numbers.ts b/src/constants/numbers.ts index 3198fb860..26dcec92a 100644 --- a/src/constants/numbers.ts +++ b/src/constants/numbers.ts @@ -10,7 +10,7 @@ export const LEVERAGE_DECIMALS = 2; export const TOKEN_DECIMALS = 4; export const LARGE_TOKEN_DECIMALS = 2; export const FEE_DECIMALS = 3; -export const FUNDING_DECIMALS = 4; +export const FUNDING_DECIMALS = 5; export const QUANTUM_MULTIPLIER = 1_000_000; diff --git a/src/views/MarketStatsDetails.tsx b/src/views/MarketStatsDetails.tsx index 65bf3d824..5dd7af234 100644 --- a/src/views/MarketStatsDetails.tsx +++ b/src/views/MarketStatsDetails.tsx @@ -4,7 +4,7 @@ import { BonsaiCore, BonsaiHelpers } from '@/abacus-ts/ontology'; import styled, { css } from 'styled-components'; import { STRING_KEYS } from '@/constants/localization'; -import { LARGE_TOKEN_DECIMALS, TINY_PERCENT_DECIMALS } from '@/constants/numbers'; +import { FUNDING_DECIMALS, LARGE_TOKEN_DECIMALS } from '@/constants/numbers'; import { TooltipStringKeys } from '@/constants/tooltips'; import { DisplayUnit } from '@/constants/trade'; @@ -275,7 +275,7 @@ const DetailsItem = ({ type={OutputType.Percent} value={value} color={color} - fractionDigits={TINY_PERCENT_DECIMALS} + fractionDigits={FUNDING_DECIMALS} /> ); diff --git a/src/views/charts/FundingChart/Tooltip.tsx b/src/views/charts/FundingChart/Tooltip.tsx index 59613d0be..68d686fc4 100644 --- a/src/views/charts/FundingChart/Tooltip.tsx +++ b/src/views/charts/FundingChart/Tooltip.tsx @@ -3,6 +3,7 @@ import type { RenderTooltipParams } from '@visx/xychart/lib/components/Tooltip'; import { FundingRateResolution, type FundingChartDatum } from '@/constants/charts'; import { STRING_KEYS } from '@/constants/localization'; import { FundingDirection } from '@/constants/markets'; +import { FUNDING_DECIMALS } from '@/constants/numbers'; import { useStringGetter } from '@/hooks/useStringGetter'; @@ -82,6 +83,7 @@ export const FundingChartTooltipContent = ({ value: ( { }, ]} tickFormatY={(value) => - `${(getAllFundingRates(value)[fundingRateView] * 100).toFixed(SMALL_PERCENT_DECIMALS)}%` + `${(getAllFundingRates(value)[fundingRateView] * 100).toFixed(FUNDING_DECIMALS)}%` } renderXAxisLabel={({ tooltipData }) => { const tooltipDatum = tooltipData!.nearestDatum?.datum ?? latestDatum; @@ -106,6 +106,7 @@ export const FundingChart = ({ selectedLocale }: ElementProps) => { return ( <$YAxisLabelOutput type={OutputType.SmallPercent} + fractionDigits={FUNDING_DECIMALS} value={getAllFundingRates(tooltipDatum?.fundingRate)[fundingRateView]} accentColor={ { @@ -181,7 +182,7 @@ export const FundingChart = ({ selectedLocale }: ElementProps) => { <$Output type={OutputType.SmallPercent} value={latestFundingRate} - fractionDigits={TINY_PERCENT_DECIMALS} + fractionDigits={FUNDING_DECIMALS} isNegative={(latestFundingRate ?? 0) < 0} /> From dc94239076c52c38156828ea73f95316e62328ed Mon Sep 17 00:00:00 2001 From: tyleroooo Date: Fri, 24 Jan 2025 12:47:05 -0500 Subject: [PATCH 3/7] fix: max leverage display type (#1468) --- src/views/MarketStatsDetails.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/views/MarketStatsDetails.tsx b/src/views/MarketStatsDetails.tsx index 5dd7af234..3a6dda199 100644 --- a/src/views/MarketStatsDetails.tsx +++ b/src/views/MarketStatsDetails.tsx @@ -27,7 +27,7 @@ import { useAppSelector } from '@/state/appTypes'; import { getSelectedDisplayUnit } from '@/state/appUiConfigsSelectors'; import { getCurrentMarketMidMarketPrice } from '@/state/perpetualsSelectors'; -import { BIG_NUMBERS, MustBigNumber } from '@/lib/numbers'; +import { BIG_NUMBERS, MaybeBigNumber, MustBigNumber } from '@/lib/numbers'; import { orEmptyObj } from '@/lib/typeUtils'; import { MidMarketPrice } from './MidMarketPrice'; @@ -319,7 +319,10 @@ const DetailsItem = ({ ? BIG_NUMBERS.ONE.div(effectiveInitialMarginFraction) : null } - withDiff={initialMarginFraction !== effectiveInitialMarginFraction} + withDiff={ + MaybeBigNumber(initialMarginFraction)?.toNumber() !== + (effectiveInitialMarginFraction ?? undefined) + } type={OutputType.Multiple} /> ); From a2c2c246655e8d43319587966d3b366f6b9df204 Mon Sep 17 00:00:00 2001 From: tyleroooo Date: Fri, 24 Jan 2025 13:54:21 -0500 Subject: [PATCH 4/7] fix: sparklines reverse (#1469) --- src/abacus-ts/calculators/markets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/abacus-ts/calculators/markets.ts b/src/abacus-ts/calculators/markets.ts index 722b83ae2..d82c92252 100644 --- a/src/abacus-ts/calculators/markets.ts +++ b/src/abacus-ts/calculators/markets.ts @@ -110,7 +110,7 @@ export function formatSparklineData(sparklines?: { if (sparklines == null) return sparklines; return mapValues(sparklines, (map) => { return mapValues(map, (sparkline) => { - return sparkline.map((point) => MustBigNumber(point).toNumber()); + return sparkline.map((point) => MustBigNumber(point).toNumber()).toReversed(); }); }); } From 733e758a8da7ce777311affca8c9593169af97ae Mon Sep 17 00:00:00 2001 From: tyleroooo Date: Fri, 24 Jan 2025 13:56:38 -0500 Subject: [PATCH 5/7] fix: 24h price change calculation (#1470) --- src/abacus-ts/calculators/markets.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/abacus-ts/calculators/markets.ts b/src/abacus-ts/calculators/markets.ts index d82c92252..43ba41b01 100644 --- a/src/abacus-ts/calculators/markets.ts +++ b/src/abacus-ts/calculators/markets.ts @@ -79,6 +79,18 @@ function calculateDerivedMarketDisplayItems(market: IndexerWsBaseMarketObject) { }; } +function calculatePriceChangePercent( + priceChange24H: string | null | undefined, + oraclePrice: string | null | undefined +): BigNumber | null { + if (priceChange24H == null || oraclePrice == null) { + return null; + } + + const price24hAgo = MustBigNumber(oraclePrice).minus(priceChange24H); + return price24hAgo.gt(0) ? MustBigNumber(priceChange24H).div(price24hAgo) : null; +} + function calculateDerivedMarketCore(market: IndexerWsBaseMarketObject) { return { effectiveInitialMarginFraction: @@ -86,11 +98,8 @@ function calculateDerivedMarketCore(market: IndexerWsBaseMarketObject) { openInterestUSDC: MustBigNumber(market.openInterest) .times(market.oraclePrice ?? 0) .toNumber(), - percentChange24h: MustBigNumber(market.oraclePrice).isZero() - ? null - : MustBigNumber(market.priceChange24H) - .div(market.oraclePrice ?? 0) - .toNumber(), + percentChange24h: + calculatePriceChangePercent(market.priceChange24H, market.oraclePrice)?.toNumber() ?? null, stepSizeDecimals: MaybeBigNumber(market.stepSize)?.decimalPlaces() ?? TOKEN_DECIMALS, tickSizeDecimals: MaybeBigNumber(market.tickSize)?.decimalPlaces() ?? USD_DECIMALS, }; From f7c3447e222aef622491bcc8b6416a77edad3c95 Mon Sep 17 00:00:00 2001 From: tyleroooo Date: Fri, 24 Jan 2025 15:33:24 -0500 Subject: [PATCH 6/7] feat(bonsai): add types for all ontology exports (#1471) --- src/abacus-ts/calculators/accountActions.ts | 25 ++-- src/abacus-ts/calculators/funding.ts | 9 +- src/abacus-ts/calculators/markets.ts | 4 +- src/abacus-ts/lib/loadable.ts | 1 + src/abacus-ts/ontology.ts | 133 +++++++++++++++++--- src/abacus-ts/rest/funding.ts | 9 +- src/abacus-ts/selectors/accountActions.ts | 18 +-- src/abacus-ts/selectors/summary.ts | 3 +- src/views/charts/FundingChart/index.tsx | 9 +- 9 files changed, 159 insertions(+), 52 deletions(-) diff --git a/src/abacus-ts/calculators/accountActions.ts b/src/abacus-ts/calculators/accountActions.ts index 499bb5e96..70514e0e7 100644 --- a/src/abacus-ts/calculators/accountActions.ts +++ b/src/abacus-ts/calculators/accountActions.ts @@ -77,15 +77,14 @@ function addUsdcAssetPosition( }); } +export type UsdcDepositArgs = { + subaccountNumber: number; + depositAmount: string; +}; + export function createUsdcDepositOperations( parentSubaccount: ParentSubaccountData, - { - subaccountNumber, - depositAmount, - }: { - subaccountNumber: number; - depositAmount: string; - } + { subaccountNumber, depositAmount }: UsdcDepositArgs ): SubaccountBatchedOperations { const updatedParentSubaccountData = addUsdcAssetPosition(parentSubaccount, { side: IndexerPositionSide.LONG, @@ -107,15 +106,13 @@ export function createUsdcDepositOperations( }; } +export type UsdcWithdrawArgs = { + subaccountNumber: number; + withdrawAmount: string; +}; export function createUsdcWithdrawalOperations( parentSubaccount: ParentSubaccountData, - { - subaccountNumber, - withdrawAmount, - }: { - subaccountNumber: number; - withdrawAmount: string; - } + { subaccountNumber, withdrawAmount }: UsdcWithdrawArgs ): SubaccountBatchedOperations { const updatedParentSubaccountData = addUsdcAssetPosition(parentSubaccount, { side: IndexerPositionSide.SHORT, diff --git a/src/abacus-ts/calculators/funding.ts b/src/abacus-ts/calculators/funding.ts index 44d0146fb..716bbd2f2 100644 --- a/src/abacus-ts/calculators/funding.ts +++ b/src/abacus-ts/calculators/funding.ts @@ -13,7 +13,14 @@ export const getDirectionFromFundingRate = (fundingRate: string) => { : FundingDirection.ToLong; }; -export const mapFundingChartObject = (funding: IndexerHistoricalFundingResponseObject) => ({ +export type HistoricalFundingObject = { + fundingRate: number; + time: number; + direction: FundingDirection; +}; +export const mapFundingChartObject = ( + funding: IndexerHistoricalFundingResponseObject +): HistoricalFundingObject => ({ fundingRate: MustBigNumber(funding.rate).toNumber(), time: new Date(funding.effectiveAt).getTime(), direction: getDirectionFromFundingRate(funding.rate), diff --git a/src/abacus-ts/calculators/markets.ts b/src/abacus-ts/calculators/markets.ts index 43ba41b01..65dd5e1ba 100644 --- a/src/abacus-ts/calculators/markets.ts +++ b/src/abacus-ts/calculators/markets.ts @@ -129,9 +129,9 @@ export function createMarketSummary( sparklines: PerpetualMarketSparklines | undefined, assetInfo: AllAssetData | undefined, listOfFavorites: string[] -): PerpetualMarketSummaries | null { +): PerpetualMarketSummaries | undefined { if (markets == null || assetInfo == null) { - return null; + return undefined; } return pickBy( diff --git a/src/abacus-ts/lib/loadable.ts b/src/abacus-ts/lib/loadable.ts index c0944f165..c77b6755f 100644 --- a/src/abacus-ts/lib/loadable.ts +++ b/src/abacus-ts/lib/loadable.ts @@ -3,6 +3,7 @@ export type LoadableSuccess = { status: 'success'; data: T }; export type LoadableIdle = { status: 'idle'; data: undefined }; export type LoadableError = { status: 'error'; data?: T; error: any }; export type Loadable = LoadableIdle | LoadableSuccess | LoadablePending | LoadableError; +export type LoadableStatus = Loadable['status']; export function loadablePending() { return { status: 'pending' } as LoadablePending; diff --git a/src/abacus-ts/ontology.ts b/src/abacus-ts/ontology.ts index aaf507680..b436a912c 100644 --- a/src/abacus-ts/ontology.ts +++ b/src/abacus-ts/ontology.ts @@ -1,6 +1,13 @@ +import { HeightResponse } from '@dydxprotocol/v4-client-js'; + +import { IndexerWsTradesUpdateObject } from '@/types/indexer/indexerManual'; + import { type RootState } from '@/state/_store'; import { getCurrentMarketId } from '@/state/perpetualsSelectors'; +import { UsdcDepositArgs, UsdcWithdrawArgs } from './calculators/accountActions'; +import { HistoricalFundingObject } from './calculators/funding'; +import { Loadable, LoadableStatus } from './lib/loadable'; import { useCurrentMarketHistoricalFunding } from './rest/funding'; import { getCurrentMarketAccountFills, @@ -40,20 +47,78 @@ import { selectAllMarketSummariesLoading, selectCurrentMarketInfo, selectCurrentMarketInfoStable, + StablePerpetualMarketSummary, } from './selectors/summary'; +import { + AllAssetData, + ApiState, + AssetData, + GroupedSubaccountSummary, + PendingIsolatedPosition, + PerpetualMarketSummaries, + PerpetualMarketSummary, + SubaccountFill, + SubaccountOrder, + SubaccountPosition, +} from './types/summaryTypes'; import { useCurrentMarketTradesValue } from './websocket/trades'; -// every leaf is a selector or a paramaterized selector -type NestedSelectors = { - [K: string]: - | NestedSelectors - | ((state: RootState) => any) - | (() => (state: RootState, ...other: any[]) => any); -}; +type BasicSelector = (state: RootState) => Result; +type ParameterizedSelector = () => ( + state: RootState, + ...args: Args +) => Result; // all data should be accessed via selectors in this file // no files outside abacus-ts should access anything within abacus-ts except this file -export const BonsaiCore = { +interface BonsaiCoreShape { + account: { + parentSubaccountSummary: { + data: BasicSelector; + loading: BasicSelector; + }; + parentSubaccountPositions: { + data: BasicSelector; + loading: BasicSelector; + }; + openOrders: { + data: BasicSelector; + loading: BasicSelector; + }; + orderHistory: { + data: BasicSelector; + loading: BasicSelector; + }; + fills: { + data: BasicSelector; + loading: BasicSelector; + }; + }; + markets: { + currentMarketId: BasicSelector; + markets: { + data: BasicSelector; + loading: BasicSelector; + }; + assets: { + data: BasicSelector; + loading: BasicSelector; + }; + }; + network: { + indexerHeight: { + data: BasicSelector; + loading: BasicSelector; + }; + validatorHeight: { + data: BasicSelector; + loading: BasicSelector; + }; + apiState: BasicSelector; + }; +} + +export const BonsaiCore: BonsaiCoreShape = { account: { parentSubaccountSummary: { data: selectParentSubaccountSummary, @@ -82,7 +147,10 @@ export const BonsaiCore = { data: selectAllMarketSummaries, loading: selectAllMarketSummariesLoading, }, - assets: { data: selectAllAssetsInfo, loading: selectAllAssetsInfoLoading }, + assets: { + data: selectAllAssetsInfo, + loading: selectAllAssetsInfoLoading, + }, }, network: { indexerHeight: { @@ -95,12 +163,42 @@ export const BonsaiCore = { }, apiState: selectApiState, }, -} as const satisfies NestedSelectors; +}; -export const BonsaiHelpers = { +interface BonsaiHelpersShape { currentMarket: { - marketInfo: selectCurrentMarketInfo, + marketInfo: BasicSelector; // marketInfo but with only the properties that rarely change, for fewer rerenders + stableMarketInfo: BasicSelector; + account: { + openOrders: BasicSelector; + orderHistory: BasicSelector; + fills: BasicSelector; + }; + }; + assets: { + createSelectAssetInfo: ParameterizedSelector; + }; + forms: { + deposit: { + createSelectParentSubaccountSummary: ParameterizedSelector< + GroupedSubaccountSummary | undefined, + [UsdcDepositArgs] + >; + }; + withdraw: { + createSelectParentSubaccountSummary: ParameterizedSelector< + GroupedSubaccountSummary | undefined, + [UsdcWithdrawArgs] + >; + }; + }; + unopenedIsolatedPositions: BasicSelector; +} + +export const BonsaiHelpers: BonsaiHelpersShape = { + currentMarket: { + marketInfo: selectCurrentMarketInfo, stableMarketInfo: selectCurrentMarketInfoStable, account: { openOrders: selectCurrentMarketOpenOrders, @@ -120,9 +218,14 @@ export const BonsaiHelpers = { }, }, unopenedIsolatedPositions: selectUnopenedIsolatedPositions, -} as const satisfies NestedSelectors; +}; + +interface BonsaiHooksShape { + useCurrentMarketHistoricalFunding: () => Loadable; + useCurrentMarketLiveTrades: () => Loadable; +} -export const BonsaiHooks = { +export const BonsaiHooks: BonsaiHooksShape = { useCurrentMarketHistoricalFunding, useCurrentMarketLiveTrades: useCurrentMarketTradesValue, -} as const; +}; diff --git a/src/abacus-ts/rest/funding.ts b/src/abacus-ts/rest/funding.ts index 337aeedee..0660a68d5 100644 --- a/src/abacus-ts/rest/funding.ts +++ b/src/abacus-ts/rest/funding.ts @@ -12,11 +12,16 @@ import { isTruthy } from '@/lib/isTruthy'; import { MustBigNumber } from '@/lib/numbers'; import { orEmptyObj } from '@/lib/typeUtils'; -import { getDirectionFromFundingRate, mapFundingChartObject } from '../calculators/funding'; +import { + getDirectionFromFundingRate, + HistoricalFundingObject, + mapFundingChartObject, +} from '../calculators/funding'; +import { Loadable } from '../lib/loadable'; import { selectCurrentMarketInfo } from '../selectors/summary'; import { useIndexerClient } from './lib/useIndexer'; -export const useCurrentMarketHistoricalFunding = () => { +export const useCurrentMarketHistoricalFunding = (): Loadable => { const { indexerClient, key: indexerKey } = useIndexerClient(); const currentMarketId = useAppSelector(getCurrentMarketIdIfTradeable); const { nextFundingRate } = orEmptyObj(useAppSelector(selectCurrentMarketInfo)); diff --git a/src/abacus-ts/selectors/accountActions.ts b/src/abacus-ts/selectors/accountActions.ts index 5ea144373..7a5381646 100644 --- a/src/abacus-ts/selectors/accountActions.ts +++ b/src/abacus-ts/selectors/accountActions.ts @@ -4,6 +4,8 @@ import { applyOperationsToSubaccount, createUsdcDepositOperations, createUsdcWithdrawalOperations, + UsdcDepositArgs, + UsdcWithdrawArgs, } from '../calculators/accountActions'; import { calculateParentSubaccountSummary } from '../calculators/subaccount'; import { selectRelevantMarketsData } from './account'; @@ -14,13 +16,7 @@ export const createSelectParentSubaccountSummaryDeposit = () => [ selectRawParentSubaccountData, selectRelevantMarketsData, - ( - _s, - input: { - subaccountNumber: number; - depositAmount: string; - } - ) => input, + (_s, input: UsdcDepositArgs) => input, ], (parentSubaccount, markets, depositInputs) => { if (parentSubaccount == null || markets == null) { @@ -39,13 +35,7 @@ export const createSelectParentSubaccountSummaryWithdrawal = () => [ selectRawParentSubaccountData, selectRelevantMarketsData, - ( - _s, - input: { - subaccountNumber: number; - withdrawAmount: string; - } - ) => input, + (_s, input: UsdcWithdrawArgs) => input, ], (parentSubaccount, markets, withdrawalInputs) => { if (parentSubaccount == null || markets == null) { diff --git a/src/abacus-ts/selectors/summary.ts b/src/abacus-ts/selectors/summary.ts index b270db85d..8f1795533 100644 --- a/src/abacus-ts/selectors/summary.ts +++ b/src/abacus-ts/selectors/summary.ts @@ -39,10 +39,11 @@ const unstablePaths = [ 'openInterestUSDC', ] satisfies Array; type UnstablePaths = (typeof unstablePaths)[number]; +export type StablePerpetualMarketSummary = Omit; export const selectCurrentMarketInfoStable = createAppSelector( [selectCurrentMarketInfo], - (market): undefined | Omit => + (market): undefined | StablePerpetualMarketSummary => market == null ? market : omit(market, ...unstablePaths), { memoizeOptions: { diff --git a/src/views/charts/FundingChart/index.tsx b/src/views/charts/FundingChart/index.tsx index fc36a49d1..3e3fe8159 100644 --- a/src/views/charts/FundingChart/index.tsx +++ b/src/views/charts/FundingChart/index.tsx @@ -10,6 +10,7 @@ import { FundingRateResolution, type FundingChartDatum } from '@/constants/chart import { STRING_KEYS } from '@/constants/localization'; import { FundingDirection } from '@/constants/markets'; import { FUNDING_DECIMALS } from '@/constants/numbers'; +import { EMPTY_ARR } from '@/constants/objects'; import { timeUnits } from '@/constants/time'; import { useBreakpoints } from '@/hooks/useBreakpoints'; @@ -47,9 +48,11 @@ export const FundingChart = ({ selectedLocale }: ElementProps) => { const stringGetter = useStringGetter(); // Chart data - const { data, isLoading, isError } = BonsaiHooks.useCurrentMarketHistoricalFunding(); + const { data, status } = BonsaiHooks.useCurrentMarketHistoricalFunding(); + const isLoading = status === 'pending'; + const isError = status === 'error'; - const latestDatum = data[data.length - 1]; + const latestDatum = data?.[data.length - 1]; // Chart state const [fundingRateView, setFundingRateView] = useState(FundingRateResolution.OneHour); @@ -63,7 +66,7 @@ export const FundingChart = ({ selectedLocale }: ElementProps) => { return ( Date: Mon, 27 Jan 2025 10:13:24 -0800 Subject: [PATCH 7/7] feat(bonsai-core): orderbook (#1466) --- src/abacus-ts/calculators/orderbook.ts | 155 +++++++++++++++++++++++++ src/abacus-ts/ontology.ts | 8 ++ src/abacus-ts/selectors/base.ts | 1 + src/abacus-ts/selectors/markets.ts | 43 ++++++- src/abacus-ts/types/rawTypes.ts | 4 +- src/abacus-ts/types/summaryTypes.ts | 17 +++ src/abacus-ts/websocket/orderbook.ts | 30 +++-- src/types/indexer/indexerChecks.ts | 6 + src/types/indexer/indexerManual.ts | 20 ++++ 9 files changed, 274 insertions(+), 10 deletions(-) create mode 100644 src/abacus-ts/calculators/orderbook.ts diff --git a/src/abacus-ts/calculators/orderbook.ts b/src/abacus-ts/calculators/orderbook.ts new file mode 100644 index 000000000..674c1201f --- /dev/null +++ b/src/abacus-ts/calculators/orderbook.ts @@ -0,0 +1,155 @@ +import BigNumber from 'bignumber.js'; +import { weakMapMemoize } from 'reselect'; + +import { isTruthy } from '@/lib/isTruthy'; +import { BIG_NUMBERS, MustBigNumber } from '@/lib/numbers'; +import { objectEntries } from '@/lib/objectHelpers'; + +import { OrderbookData } from '../types/rawTypes'; +import { OrderbookLine, OrderbookProcessedData } from '../types/summaryTypes'; + +type OrderbookLineBN = { + price: BigNumber; + size: BigNumber; + sizeCost: BigNumber; + offset: number; + depth?: BigNumber; + depthCost?: BigNumber; +}; + +export const calculateOrderbook = weakMapMemoize( + (orderbook: OrderbookData | undefined): OrderbookProcessedData | undefined => { + if (orderbook == null) { + return undefined; + } + + const { asks, bids } = orderbook; + + /** + * 1. Process raw orderbook data + * 2. filter out lines with size <= 0 + * 3. sort by price + */ + const asksBase: OrderbookLineBN[] = objectEntries(asks) + .map(mapRawOrderbookLineToBN) + .filter(isTruthy) + .sort((a, b) => a.price.minus(b.price).toNumber()); + + const bidsBase: OrderbookLineBN[] = objectEntries(bids) + .map(mapRawOrderbookLineToBN) + .filter(isTruthy) + .sort((a, b) => a.price.minus(b.price).toNumber()); + + // calculate depth and depthCost + const asksComposite = calculateDepthAndDepthCost(asksBase); + const bidsComposite = calculateDepthAndDepthCost(bidsBase); + + // un-cross orderbook + const uncrossedBook: { asks: OrderbookLineBN[]; bids: OrderbookLineBN[] } = uncrossOrderbook( + asksComposite, + bidsComposite + ); + + // calculate midPrice, spread, and spreadPercent + const lowestAsk = uncrossedBook.asks.at(0); + const highestBid = uncrossedBook.bids.at(-1); + const midPriceBN = lowestAsk && highestBid && lowestAsk.price.plus(highestBid.price).div(2); + const spreadBN = lowestAsk && highestBid && lowestAsk.price.minus(highestBid.price); + const spreadPercentBN = spreadBN && midPriceBN && spreadBN.div(midPriceBN).times(100); + + return { + bids: uncrossedBook.bids.map(mapOrderbookLineToNumber), + asks: uncrossedBook.asks.map(mapOrderbookLineToNumber), + midPrice: midPriceBN?.toNumber(), + spread: spreadBN?.toNumber(), + spreadPercent: spreadPercentBN?.toNumber(), + }; + } +); + +function uncrossOrderbook(asks: OrderbookLineBN[], bids: OrderbookLineBN[]) { + if (asks.length === 0 || bids.length === 0) { + return { asks, bids }; + } + + const asksCopy = [...asks]; + const bidsCopy = [...bids]; + + let ask = asksCopy.at(-1); + let bid = bidsCopy.at(0); + + while (ask && bid && isCrossed(ask, bid)) { + if (ask.offset === bid.offset) { + // If offsets are the same, give precedence to the larger size. In this case, + // one of the sizes "should" be zero, but we simply check for the larger size. + if (ask.size.gt(bid.size)) { + // remove the bid + bid = bidsCopy.shift(); + } else { + // remove the ask + ask = asksCopy.shift(); + } + } else { + // If offsets are different, remove the older offset. + if (ask.offset < bid.offset) { + // remove the ask + ask = asksCopy.shift(); + } else { + // remove the bid + bid = bidsCopy.shift(); + } + } + } + + return { asks: asksCopy, bids: bidsCopy }; +} + +function calculateDepthAndDepthCost(lines: OrderbookLineBN[]) { + let depth = BIG_NUMBERS.ZERO; + let depthCost = BIG_NUMBERS.ZERO; + + return lines.map((line) => { + depth = depth.plus(line.size); + depthCost = depthCost.plus(line.sizeCost); + + return { + ...line, + depth, + depthCost, + }; + }); +} + +function mapRawOrderbookLineToBN( + rawOrderbookLineEntry: [string, { size: string; offset: number }] +) { + const [price, { size, offset }] = rawOrderbookLineEntry; + const sizeBN = MustBigNumber(size); + const priceBN = MustBigNumber(price); + + if (sizeBN.isZero()) return undefined; + + return { + price: priceBN, + size: sizeBN, + sizeCost: priceBN.times(sizeBN), + offset, + }; +} + +function mapOrderbookLineToNumber(orderbookLineBN: OrderbookLineBN): OrderbookLine { + const { price, size, sizeCost, depth, depthCost, offset } = orderbookLineBN; + + return { + price: price.toNumber(), + size: size.toNumber(), + sizeCost: sizeCost.toNumber(), + offset, + depth: depth?.toNumber() ?? 0, + depthCost: depthCost?.toNumber() ?? 0, + }; +} + +function isCrossed(ask: OrderbookLineBN, bid: OrderbookLineBN) { + return ask.price.lte(bid.price); +} diff --git a/src/abacus-ts/ontology.ts b/src/abacus-ts/ontology.ts index b436a912c..dec113ea6 100644 --- a/src/abacus-ts/ontology.ts +++ b/src/abacus-ts/ontology.ts @@ -42,6 +42,10 @@ import { selectRawIndexerHeightDataLoading, selectRawValidatorHeightDataLoading, } from './selectors/base'; +import { + selectCurrentMarketOrderbookData, + selectCurrentMarketOrderbookLoading, +} from './selectors/markets'; import { selectAllMarketSummaries, selectAllMarketSummariesLoading, @@ -200,6 +204,10 @@ export const BonsaiHelpers: BonsaiHelpersShape = { currentMarket: { marketInfo: selectCurrentMarketInfo, stableMarketInfo: selectCurrentMarketInfoStable, + orderbook: { + data: selectCurrentMarketOrderbookData, + loading: selectCurrentMarketOrderbookLoading, + }, account: { openOrders: selectCurrentMarketOpenOrders, orderHistory: selectCurrentMarketOrderHistory, diff --git a/src/abacus-ts/selectors/base.ts b/src/abacus-ts/selectors/base.ts index e612cac25..b846882c4 100644 --- a/src/abacus-ts/selectors/base.ts +++ b/src/abacus-ts/selectors/base.ts @@ -10,6 +10,7 @@ export const selectRawAssetsData = (state: RootState) => state.raw.markets.asset export const selectRawAssets = (state: RootState) => state.raw.markets.assets; export const selectRawSparklines = (state: RootState) => state.raw.markets.sparklines; export const selectRawSparklinesData = (state: RootState) => state.raw.markets.sparklines.data; +export const selectRawOrderbooks = (state: RootState) => state.raw.markets.orderbooks; export const selectRawParentSubaccount = (state: RootState) => state.raw.account.parentSubaccount; export const selectRawParentSubaccountData = (state: RootState) => diff --git a/src/abacus-ts/selectors/markets.ts b/src/abacus-ts/selectors/markets.ts index 2485a126f..5e6b68307 100644 --- a/src/abacus-ts/selectors/markets.ts +++ b/src/abacus-ts/selectors/markets.ts @@ -1,8 +1,15 @@ import { createAppSelector } from '@/state/appTypes'; +import { getCurrentMarketId } from '@/state/perpetualsSelectors'; import { calculateAllMarkets, formatSparklineData } from '../calculators/markets'; +import { calculateOrderbook } from '../calculators/orderbook'; import { mergeLoadableStatus } from '../lib/mapLoadable'; -import { selectRawMarketsData, selectRawSparklines, selectRawSparklinesData } from './base'; +import { + selectRawMarketsData, + selectRawOrderbooks, + selectRawSparklines, + selectRawSparklinesData, +} from './base'; export const selectAllMarketsInfo = createAppSelector([selectRawMarketsData], (markets) => calculateAllMarkets(markets) @@ -16,3 +23,37 @@ export const selectSparklinesLoading = createAppSelector( export const selectSparkLinesData = createAppSelector([selectRawSparklinesData], (sparklines) => formatSparklineData(sparklines) ); + +export const selectCurrentMarketOrderbook = createAppSelector( + [selectRawOrderbooks, getCurrentMarketId], + (rawOrderbooks, currentMarketId) => { + if (!currentMarketId || !rawOrderbooks[currentMarketId]) { + return undefined; + } + + return rawOrderbooks[currentMarketId]; + } +); + +export const selectCurrentMarketOrderbookLoading = createAppSelector( + [selectCurrentMarketOrderbook], + (currentMarketOrderbook) => + currentMarketOrderbook ? mergeLoadableStatus(currentMarketOrderbook) : 'idle' +); + +export const selectCurrentMarketOrderbookData = createAppSelector( + [selectCurrentMarketOrderbook], + (currentMarketOrderbook) => calculateOrderbook(currentMarketOrderbook?.data) +); + +export const createSelectCurrentMarketOrderbook = createAppSelector( + [selectCurrentMarketOrderbook, (_s, groupingMultiplier?: number) => groupingMultiplier], + (currentMarketOrderbook, _groupingMultiplier) => { + if (currentMarketOrderbook?.data == null) { + return undefined; + } + + // TODO: groupMultiplier calculations on the orderbook + return calculateOrderbook(currentMarketOrderbook.data); + } +); diff --git a/src/abacus-ts/types/rawTypes.ts b/src/abacus-ts/types/rawTypes.ts index 98fb90336..8e0189819 100644 --- a/src/abacus-ts/types/rawTypes.ts +++ b/src/abacus-ts/types/rawTypes.ts @@ -15,8 +15,8 @@ export type MarketsData = { [marketId: string]: IndexerWsBaseMarketObject }; export type OrdersData = { [orderId: string]: IndexerCompositeOrderObject }; export type OrderbookData = { - bids: { [price: string]: string }; - asks: { [price: string]: string }; + bids: { [price: string]: { size: string; offset: number } }; + asks: { [price: string]: { size: string; offset: number } }; }; export interface ParentSubaccountData { diff --git a/src/abacus-ts/types/summaryTypes.ts b/src/abacus-ts/types/summaryTypes.ts index 6047f3e64..28f1252c2 100644 --- a/src/abacus-ts/types/summaryTypes.ts +++ b/src/abacus-ts/types/summaryTypes.ts @@ -261,3 +261,20 @@ export type ConfigTiers = { feeTiers: FeeTiers | undefined; equityTiers: EquityTiers | undefined; }; + +export type OrderbookLine = { + price: number; + size: number; + depth: number; + sizeCost: number; + depthCost: number; + offset: number; +}; + +export type OrderbookProcessedData = { + asks: OrderbookLine[]; + bids: OrderbookLine[]; + midPrice: number | undefined; + spread: number | undefined; + spreadPercent: number | undefined; +}; diff --git a/src/abacus-ts/websocket/orderbook.ts b/src/abacus-ts/websocket/orderbook.ts index e15bbf346..536d28e83 100644 --- a/src/abacus-ts/websocket/orderbook.ts +++ b/src/abacus-ts/websocket/orderbook.ts @@ -1,7 +1,12 @@ import { keyBy, mapValues, throttle } from 'lodash'; import { timeUnits } from '@/constants/time'; -import { isWsOrderbookResponse, isWsOrderbookUpdateResponses } from '@/types/indexer/indexerChecks'; +import { + isWsOrderbookChannelBatchDataMessage, + isWsOrderbookResponse, + isWsOrderbookSubscribedMessage, + isWsOrderbookUpdateResponses, +} from '@/types/indexer/indexerChecks'; import { type RootStore } from '@/state/_store'; import { createAppSelector } from '@/state/appTypes'; @@ -28,20 +33,25 @@ function orderbookWebsocketValueCreator( { channel: 'v4_orderbook', id: marketId, - handleBaseData: (baseMessage) => { + handleBaseData: (baseMessage, _, fullMessage) => { + const wsMessage = isWsOrderbookSubscribedMessage(fullMessage); const message = isWsOrderbookResponse(baseMessage); return loadableLoaded({ asks: mapValues( keyBy(message.asks, (a) => a.price), - (a) => a.size + (a) => ({ + size: a.size, + offset: wsMessage.message_id, + }) ), bids: mapValues( keyBy(message.bids, (a) => a.price), - (a) => a.size + (a) => ({ size: a.size, offset: wsMessage.message_id }) ), }); }, - handleUpdates: (baseUpdates, value) => { + handleUpdates: (baseUpdates, value, fullMessage) => { + const wsMessage = isWsOrderbookChannelBatchDataMessage(fullMessage); const updates = isWsOrderbookUpdateResponses(baseUpdates); let startingValue = value.data; if (startingValue == null) { @@ -53,10 +63,16 @@ function orderbookWebsocketValueCreator( startingValue = { asks: { ...startingValue.asks }, bids: { ...startingValue.bids } }; updates.forEach((update) => { if (update.asks) { - update.asks.forEach(([price, size]) => (startingValue.asks[price] = size)); + update.asks.forEach( + ([price, size]) => + (startingValue.asks[price] = { size, offset: wsMessage.message_id }) + ); } if (update.bids) { - update.bids.forEach(([price, size]) => (startingValue.bids[price] = size)); + update.bids.forEach( + ([price, size]) => + (startingValue.bids[price] = { size, offset: wsMessage.message_id }) + ); } }); return loadableLoaded(startingValue); diff --git a/src/types/indexer/indexerChecks.ts b/src/types/indexer/indexerChecks.ts index 7e33594e1..48100ecb5 100644 --- a/src/types/indexer/indexerChecks.ts +++ b/src/types/indexer/indexerChecks.ts @@ -13,6 +13,8 @@ import { IndexerWsCandleResponse, IndexerWsCandleResponseObject, IndexerWsMarketUpdateResponse, + IndexerWsOrderbookChannelBatchDataMessage, + IndexerWsOrderbookSubscribedMessage, IndexerWsOrderbookUpdateResponse, IndexerWsParentSubaccountSubscribedResponse, IndexerWsParentSubaccountUpdateObject, @@ -26,6 +28,10 @@ export const isWsParentSubaccountUpdates = typia.createAssert(); export const isWsPerpetualMarketResponse = typia.createAssert(); export const isWsMarketUpdateResponses = typia.createAssert(); +export const isWsOrderbookSubscribedMessage = + typia.createAssert(); +export const isWsOrderbookChannelBatchDataMessage = + typia.createAssert(); export const isWsOrderbookResponse = typia.createAssert(); export const isWsOrderbookUpdateResponses = typia.createAssert(); diff --git a/src/types/indexer/indexerManual.ts b/src/types/indexer/indexerManual.ts index d99fb2f9a..9c0d892ab 100644 --- a/src/types/indexer/indexerManual.ts +++ b/src/types/indexer/indexerManual.ts @@ -10,6 +10,7 @@ import { IndexerIsoString, IndexerLiquidity, IndexerMarketType, + IndexerOrderbookResponseObject, IndexerOrderResponseObject, IndexerOrderSide, IndexerOrderType, @@ -99,6 +100,25 @@ export interface IndexerWsPerpetualMarketResponse { markets: { [key: string]: IndexerWsBaseMarketObject }; } +export interface IndexerWsOrderbookSubscribedMessage { + channel: 'v4_orderbook'; + connection_id: string; + contents: IndexerOrderbookResponseObject; + id: string; + message_id: number; + type: 'subscribed'; +} + +export interface IndexerWsOrderbookChannelBatchDataMessage { + channel: 'v4_orderbook'; + connection_id: string; + contents: IndexerWsOrderbookUpdateResponse[]; + id: string; + message_id: number; + type: 'channel_batch_data'; + version: string; +} + export interface IndexerWsOrderbookUpdateResponse { asks?: IndexerWsOrderbookUpdateItem[]; bids?: IndexerWsOrderbookUpdateItem[];