diff --git a/src/abacus-ts/calculators/blockRewards.ts b/src/abacus-ts/calculators/blockRewards.ts new file mode 100644 index 000000000..09c9b6e4b --- /dev/null +++ b/src/abacus-ts/calculators/blockRewards.ts @@ -0,0 +1,21 @@ +import { IndexerHistoricalBlockTradingReward } from '@/types/indexer/indexerApiGen'; +import { keyBy, maxBy } from 'lodash'; + +import { MustBigNumber } from '@/lib/numbers'; + +import { Loadable } from '../lib/loadable'; +import { mapLoadableData } from '../lib/mapLoadable'; +import { mergeObjects } from '../lib/mergeObjects'; + +export function calculateBlockRewards( + liveTransfers: Loadable, + restTransfers: Loadable +) { + const getRewardsById = (data: Loadable) => + mapLoadableData(data, (d) => keyBy(d, (reward) => reward.createdAtHeight)); + return mergeObjects( + getRewardsById(liveTransfers).data ?? {}, + getRewardsById(restTransfers).data ?? {}, + (first, second) => maxBy([first, second], (f) => MustBigNumber(f.createdAtHeight).toNumber())! + ); +} diff --git a/src/abacus-ts/calculators/fills.ts b/src/abacus-ts/calculators/fills.ts new file mode 100644 index 000000000..97b893d07 --- /dev/null +++ b/src/abacus-ts/calculators/fills.ts @@ -0,0 +1,21 @@ +import { IndexerCompositeFillObject } from '@/types/indexer/indexerManual'; +import { keyBy, maxBy, orderBy } from 'lodash'; + +import { EMPTY_ARR } from '@/constants/objects'; + +import { MustBigNumber } from '@/lib/numbers'; + +import { mergeObjects } from '../lib/mergeObjects'; + +export function calculateFills( + liveFills: IndexerCompositeFillObject[] | undefined, + restFills: IndexerCompositeFillObject[] | undefined +) { + const getFillsById = (data: IndexerCompositeFillObject[]) => keyBy(data, (fill) => fill.id ?? ''); + const merged = mergeObjects( + getFillsById(liveFills ?? EMPTY_ARR), + getFillsById(restFills ?? EMPTY_ARR), + (first, second) => maxBy([first, second], (f) => MustBigNumber(f.createdAtHeight).toNumber())! + ); + return orderBy(Object.values(merged), [(f) => f.createdAtHeight], ['desc']); +} diff --git a/src/abacus-ts/calculators/markets.ts b/src/abacus-ts/calculators/markets.ts new file mode 100644 index 000000000..3e1c997c0 --- /dev/null +++ b/src/abacus-ts/calculators/markets.ts @@ -0,0 +1,25 @@ +import { IndexerPerpetualMarketResponseObject } from '@/types/indexer/indexerApiGen'; +import { mapValues } from 'lodash'; +import { weakMapMemoize } from 'reselect'; + +import { TOKEN_DECIMALS, USD_DECIMALS } from '@/constants/numbers'; + +import { MaybeBigNumber } from '@/lib/numbers'; + +import { MarketsData } from '../rawTypes'; +import { MarketInfo, MarketsInfo } from '../summaryTypes'; + +export function calculateAllMarkets(markets: MarketsData | undefined): MarketsInfo | undefined { + if (markets == null) { + return markets; + } + return mapValues(markets, calculateMarket); +} + +const calculateMarket = weakMapMemoize( + (market: IndexerPerpetualMarketResponseObject): MarketInfo => ({ + ...market, + stepSizeDecimals: MaybeBigNumber(market.stepSize)?.decimalPlaces() ?? TOKEN_DECIMALS, + tickSizeDecimals: MaybeBigNumber(market.tickSize)?.decimalPlaces() ?? USD_DECIMALS, + }) +); diff --git a/src/abacus-ts/calculators/orders.ts b/src/abacus-ts/calculators/orders.ts new file mode 100644 index 000000000..5df089765 --- /dev/null +++ b/src/abacus-ts/calculators/orders.ts @@ -0,0 +1,196 @@ +import { IndexerBestEffortOpenedStatus, IndexerOrderStatus } from '@/types/indexer/indexerApiGen'; +import { IndexerCompositeOrderObject } from '@/types/indexer/indexerManual'; +import { HeightResponse } from '@dydxprotocol/v4-client-js'; +import { mapValues, maxBy, orderBy } from 'lodash'; + +import { NUM_PARENT_SUBACCOUNTS } from '@/constants/account'; + +import { assertNever } from '@/lib/assertNever'; +import { getDisplayableTickerFromMarket } from '@/lib/assetUtils'; +import { mapIfPresent } from '@/lib/do'; +import { MaybeBigNumber, MustBigNumber } from '@/lib/numbers'; + +import { mergeObjects } from '../lib/mergeObjects'; +import { OrdersData } from '../rawTypes'; +import { OrderStatus, SubaccountOrder } from '../summaryTypes'; + +export function calculateOpenOrders(orders: SubaccountOrder[]) { + return orders.filter( + (order) => order.status == null || getSimpleOrderStatus(order.status) === OrderStatus.Open + ); +} + +export function calculateOrderHistory(orders: SubaccountOrder[]) { + return orders.filter( + (order) => order.status != null && getSimpleOrderStatus(order.status) !== OrderStatus.Open + ); +} + +export function calculateAllOrders( + liveOrders: OrdersData | undefined, + restOrders: OrdersData | undefined, + height: HeightResponse +): SubaccountOrder[] { + const actuallyMerged = calculateMergedOrders(liveOrders ?? {}, restOrders ?? {}); + const mapped = mapValues(actuallyMerged, (order) => calculateSubaccountOrder(order, height)); + return orderBy(Object.values(mapped), [(o) => o.updatedAtHeight], ['desc']); +} + +function calculateSubaccountOrder( + base: IndexerCompositeOrderObject, + protocolHeight: HeightResponse +): SubaccountOrder { + let order: SubaccountOrder = { + marketId: base.ticker, + status: calculateBaseOrderStatus(base), + displayId: getDisplayableTickerFromMarket(base.ticker), + expiresAtMilliseconds: mapIfPresent(base.goodTilBlockTime, (u) => new Date(u).valueOf()), + updatedAtMilliseconds: mapIfPresent(base.updatedAt, (u) => new Date(u).valueOf()), + updatedAtHeight: MaybeBigNumber(base.updatedAtHeight)?.toNumber(), + marginMode: base.subaccountNumber >= NUM_PARENT_SUBACCOUNTS ? 'ISOLATED' : 'CROSS', + subaccountNumber: base.subaccountNumber, + id: base.id, + clientId: base.clientId, + type: base.type, + side: base.side, + timeInForce: base.timeInForce, + clobPairId: MaybeBigNumber(base.clobPairId)?.toNumber(), + orderFlags: base.orderFlags, + price: MustBigNumber(base.price), + triggerPrice: MaybeBigNumber(base.triggerPrice), + size: MustBigNumber(base.size), + totalFilled: MustBigNumber(base.totalFilled), + goodTilBlock: MaybeBigNumber(base.goodTilBlock)?.toNumber(), + goodTilBlockTime: mapIfPresent(base.goodTilBlockTime, (u) => new Date(u).valueOf()), + createdAtHeight: MaybeBigNumber(base.createdAtHeight)?.toNumber(), + postOnly: !!base.postOnly, + reduceOnly: !!base.reduceOnly, + remainingSize: MustBigNumber(base.size).minus(MustBigNumber(base.totalFilled)), + removalReason: base.removalReason, + }; + order = maybeUpdateOrderIfExpired(order, protocolHeight); + return order; +} + +export function getSimpleOrderStatus(status: OrderStatus) { + switch (status) { + case OrderStatus.Open: + case OrderStatus.Pending: + case OrderStatus.PartiallyFilled: + case OrderStatus.Untriggered: + case OrderStatus.Canceling: + return OrderStatus.Open; + case OrderStatus.Canceled: + case OrderStatus.PartiallyCanceled: + return OrderStatus.Canceled; + case OrderStatus.Filled: + return OrderStatus.Filled; + default: + assertNever(status); + // should never happen since we made OrderStatus manually + return OrderStatus.Open; + } +} + +function maybeUpdateOrderIfExpired( + order: SubaccountOrder, + height: HeightResponse +): SubaccountOrder { + if (order.status == null) { + return order; + } + // todo: why not handle Open? + if ( + ![OrderStatus.Pending, OrderStatus.Canceling, OrderStatus.PartiallyFilled].includes( + order.status + ) + ) { + return order; + } + + // Check if order has expired based on goodTilBlock + if (order.goodTilBlock && order.goodTilBlock !== 0 && height.height >= order.goodTilBlock) { + let status = OrderStatus.Canceled; + + // Check for partial fills + if (order.totalFilled != null && order.totalFilled.gt(0)) { + const remainingSize = order.size.minus(order.totalFilled); + if (order.totalFilled.gt(0) && remainingSize.gt(0)) { + status = OrderStatus.PartiallyCanceled; + } + } + + return { + ...order, + status, + updatedAtMilliseconds: new Date(height.time).valueOf(), + updatedAtHeight: height.height, + }; + } + + return order; +} + +function calculateBaseOrderStatus(order: IndexerCompositeOrderObject): OrderStatus | undefined { + const status = order.status; + if (status == null) { + return undefined; + } + + if (status === IndexerBestEffortOpenedStatus.BESTEFFORTOPENED) { + return OrderStatus.Pending; + } + + // Calculate filled amounts + const size = MustBigNumber(order.size); + const totalFilled = MustBigNumber(order.totalFilled); + const remainingSize = size.minus(totalFilled); + const hasPartialFill = totalFilled.gt(0) && remainingSize.gt(0); + + // Handle partial fills + if (hasPartialFill) { + if (status === IndexerOrderStatus.OPEN) { + return OrderStatus.PartiallyFilled; + } + if (status === IndexerOrderStatus.CANCELED) { + return OrderStatus.PartiallyCanceled; + } + } + + // Handle short-term order edge case + const isShortTermOrder = order.orderFlags === '0'; + const isBestEffortCanceled = status === IndexerOrderStatus.BESTEFFORTCANCELED; + const isUserCanceled = + order.removalReason === 'USER_CANCELED' || + order.removalReason === 'ORDER_REMOVAL_REASON_USER_CANCELED'; + + if (isShortTermOrder && isBestEffortCanceled && !isUserCanceled) { + return OrderStatus.Pending; + } + + // Direct mapping for remaining cases + switch (status) { + case IndexerOrderStatus.OPEN: + return OrderStatus.Open; + case IndexerOrderStatus.FILLED: + return OrderStatus.Filled; + case IndexerOrderStatus.CANCELED: + return OrderStatus.Canceled; + case IndexerOrderStatus.BESTEFFORTCANCELED: + return OrderStatus.Canceling; + case IndexerOrderStatus.UNTRIGGERED: + return OrderStatus.Untriggered; + default: + assertNever(status); + return undefined; + } +} + +function calculateMergedOrders(liveData: OrdersData, restData: OrdersData) { + return mergeObjects( + liveData, + restData, + (a, b) => + maxBy([a, b], (o) => MustBigNumber(o.updatedAtHeight ?? o.createdAtHeight).toNumber())! + ); +} diff --git a/src/abacus-ts/calculators/subaccount.ts b/src/abacus-ts/calculators/subaccount.ts new file mode 100644 index 000000000..a54c66ec3 --- /dev/null +++ b/src/abacus-ts/calculators/subaccount.ts @@ -0,0 +1,324 @@ +import { + IndexerPerpetualMarketResponseObject, + IndexerPerpetualPositionResponseObject, + IndexerPerpetualPositionStatus, + IndexerPositionSide, +} from '@/types/indexer/indexerApiGen'; +import BigNumber from 'bignumber.js'; +import { mapValues, orderBy } from 'lodash'; + +import { NUM_PARENT_SUBACCOUNTS } from '@/constants/account'; + +import { getAssetFromMarketId } from '@/lib/assetUtils'; +import { calc } from '@/lib/do'; +import { BIG_NUMBERS, MaybeBigNumber, MustBigNumber, ToBigNumber } from '@/lib/numbers'; +import { isPresent } from '@/lib/typeUtils'; + +import { ChildSubaccountData, MarketsData, ParentSubaccountData } from '../rawTypes'; +import { + GroupedSubaccountSummary, + SubaccountPosition, + SubaccountPositionBase, + SubaccountPositionDerivedCore, + SubaccountPositionDerivedExtra, + SubaccountSummary, + SubaccountSummaryCore, + SubaccountSummaryDerived, +} from '../summaryTypes'; + +export function calculateParentSubaccountPositions( + parent: Omit, + markets: MarketsData +): SubaccountPosition[] { + return Object.values(parent.childSubaccounts) + .filter(isPresent) + .flatMap((child) => { + const subaccount = calculateSubaccountSummary(child, markets); + return orderBy( + Object.values(child.openPerpetualPositions) + .filter(isPresent) + .filter((p) => p.status === IndexerPerpetualPositionStatus.OPEN) + .map((perp) => calculateSubaccountPosition(subaccount, perp, markets[perp.market])), + [(f) => f.createdAt], + ['desc'] + ); + }); +} + +export function calculateParentSubaccountSummary( + parent: Omit, + markets: MarketsData +): GroupedSubaccountSummary { + const summaries = mapValues(parent.childSubaccounts, (subaccount) => + subaccount != null ? calculateSubaccountSummary(subaccount, markets) : subaccount + ); + const parentSummary = summaries[parent.parentSubaccount]; + if (parentSummary == null) { + throw new Error('Parent subaccount not found in ParentSubaccountData'); + } + return { + marginUsage: parentSummary.marginUsage, + leverage: parentSummary.leverage, + freeCollateral: parentSummary.freeCollateral, + equity: Object.values(summaries) + .filter(isPresent) + .map((s) => s.equity) + .reduce((a, b) => a.plus(b), BIG_NUMBERS.ZERO), + }; +} + +export function calculateMarketsNeededForSubaccount(parent: Omit) { + return Object.values(parent.childSubaccounts).flatMap((o) => + Object.values(o?.openPerpetualPositions ?? {}).map((p) => p.market) + ); +} + +function calculateSubaccountSummary( + subaccountData: ChildSubaccountData, + markets: MarketsData +): SubaccountSummary { + const core = calculateSubaccountSummaryCore(subaccountData, markets); + return { + ...core, + ...calculateSubaccountSummaryDerived(core), + }; +} + +function calculateSubaccountSummaryCore( + subaccountData: ChildSubaccountData, + markets: MarketsData +): SubaccountSummaryCore { + const quoteBalance = calc(() => { + const usdcPosition = subaccountData.assetPositions.USDC; + if (!usdcPosition?.size) return BIG_NUMBERS.ZERO; + + const size = MustBigNumber(usdcPosition.size); + return usdcPosition.side === IndexerPositionSide.LONG ? size : size.negated(); + }); + + // Calculate totals from perpetual positions + const { valueTotal, notionalTotal, initialRiskTotal, maintenanceRiskTotal } = Object.values( + subaccountData.openPerpetualPositions + ).reduce( + (acc, position) => { + const market = markets[position.market]; + if (market == null) { + return acc; + } + const { + value: positionValue, + notional: positionNotional, + initialRisk: positionInitialRisk, + maintenanceRisk: positionMaintenanceRisk, + } = calculateDerivedPositionCore(getBnPosition(position), market); + return { + valueTotal: acc.valueTotal.plus(positionValue), + notionalTotal: acc.notionalTotal.plus(positionNotional), + initialRiskTotal: acc.initialRiskTotal.plus(positionInitialRisk), + maintenanceRiskTotal: acc.maintenanceRiskTotal.plus(positionMaintenanceRisk), + }; + }, + { + valueTotal: BIG_NUMBERS.ZERO, + notionalTotal: BIG_NUMBERS.ZERO, + initialRiskTotal: BIG_NUMBERS.ZERO, + maintenanceRiskTotal: BIG_NUMBERS.ZERO, + } + ); + + return { + quoteBalance, + valueTotal, + notionalTotal, + initialRiskTotal, + maintenanceRiskTotal, + }; +} + +function calculateSubaccountSummaryDerived(core: SubaccountSummaryCore): SubaccountSummaryDerived { + const { initialRiskTotal, notionalTotal, quoteBalance, valueTotal } = core; + const equity = valueTotal.plus(quoteBalance); + + const freeCollateral = equity.minus(initialRiskTotal); + + let leverage = null; + let marginUsage = null; + + if (equity.gt(0)) { + leverage = notionalTotal.div(equity); + marginUsage = BIG_NUMBERS.ONE.minus(freeCollateral.div(equity)); + } + + return { + freeCollateral, + equity, + leverage, + marginUsage, + }; +} + +function calculateSubaccountPosition( + subaccountSummary: SubaccountSummary, + position: IndexerPerpetualPositionResponseObject, + market: IndexerPerpetualMarketResponseObject | undefined +): SubaccountPosition { + const bnPosition = getBnPosition(position); + const core = calculateDerivedPositionCore(bnPosition, market); + return { + ...bnPosition, + ...core, + ...calculatePositionDerivedExtra(core, subaccountSummary), + }; +} + +function getBnPosition(position: IndexerPerpetualPositionResponseObject): SubaccountPositionBase { + return { + ...position, + size: ToBigNumber(position.size), + maxSize: ToBigNumber(position.maxSize), + entryPrice: ToBigNumber(position.entryPrice), + realizedPnl: ToBigNumber(position.realizedPnl), + createdAtHeight: ToBigNumber(position.createdAtHeight), + sumOpen: ToBigNumber(position.sumOpen), + sumClose: ToBigNumber(position.sumClose), + netFunding: ToBigNumber(position.netFunding), + unrealizedPnl: ToBigNumber(position.unrealizedPnl), + exitPrice: position.exitPrice != null ? ToBigNumber(position.exitPrice) : position.exitPrice, + }; +} + +function calculateDerivedPositionCore( + position: SubaccountPositionBase, + market: IndexerPerpetualMarketResponseObject | undefined +): SubaccountPositionDerivedCore { + const marginMode = position.subaccountNumber < NUM_PARENT_SUBACCOUNTS ? 'CROSS' : 'ISOLATED'; + const effectiveImf = + market != null + ? getMarketEffectiveInitialMarginForMarket(market) ?? BIG_NUMBERS.ZERO + : BIG_NUMBERS.ZERO; + const effectiveMmf = MaybeBigNumber(market?.maintenanceMarginFraction) ?? BIG_NUMBERS.ZERO; + + // indexer position size is already signed I think but we will be extra sure + const unsignedSize = position.size.abs(); + const oracle = MaybeBigNumber(market?.oraclePrice) ?? BIG_NUMBERS.ZERO; + const signedSize = + position.side === IndexerPositionSide.SHORT ? unsignedSize.negated() : unsignedSize; + + const notional = unsignedSize.times(oracle); + const value = signedSize.times(oracle); + + return { + uniqueId: `${position.market}-${position.subaccountNumber}`, + assetId: getAssetFromMarketId(position.market), + marginMode, + unsignedSize, + signedSize, + value, + notional, + initialRisk: notional.times(effectiveImf), + maintenanceRisk: notional.times(effectiveMmf), + adjustedImf: effectiveImf, + adjustedMmf: effectiveMmf, + maxLeverage: calc(() => { + if (effectiveImf.isZero()) { + return null; + } + return BIG_NUMBERS.ONE.div(effectiveImf); + }), + baseEntryPrice: position.entryPrice, + baseNetFunding: position.netFunding, + }; +} + +function calculatePositionDerivedExtra( + position: SubaccountPositionDerivedCore, + subaccountSummary: SubaccountSummary +): SubaccountPositionDerivedExtra { + const { equity, maintenanceRiskTotal } = subaccountSummary; + const { signedSize, notional, value, marginMode, adjustedMmf, adjustedImf, maintenanceRisk } = + position; + + const leverage = equity.gt(0) ? notional.div(equity) : null; + + const marginValueMaintenance = marginMode === 'ISOLATED' ? equity : notional.times(adjustedMmf); + const marginValueInitial = marginMode === 'ISOLATED' ? equity : notional.times(adjustedImf); + + const liquidationPrice = calc(() => { + const otherPositionsRisk = maintenanceRiskTotal.minus(maintenanceRisk); + + // Calculate denominator based on position size + const denominator = signedSize.gt(0) + ? signedSize.minus(signedSize.times(adjustedMmf)) + : signedSize.plus(signedSize.times(adjustedMmf)); + + if (denominator.isZero()) { + return null; + } + + const liquidationPriceInner = otherPositionsRisk.plus(value).minus(equity).div(denominator); + + // Return null if liquidation price would be negative + return liquidationPriceInner.lt(0) ? null : liquidationPriceInner; + }); + + const { + unrealizedPnlInner: updatedUnrealizedPnl, + unrealizedPnlPercentInner: updatedUnrealizedPnlPercent, + } = calc(() => { + const entryValue = signedSize.multipliedBy(MustBigNumber(position.baseEntryPrice)); + const unrealizedPnlInner = value.minus(entryValue).plus(MustBigNumber(position.baseNetFunding)); + + const scaledLeverage = leverage + ? BigNumber.max(leverage.abs(), BIG_NUMBERS.ONE) + : BIG_NUMBERS.ONE; + + const unrealizedPnlPercentInner = !entryValue.isZero() + ? unrealizedPnlInner.dividedBy(entryValue.abs()).multipliedBy(scaledLeverage) + : null; + return { unrealizedPnlInner, unrealizedPnlPercentInner }; + }); + + return { + leverage, + marginValueMaintenance, + marginValueInitial, + liquidationPrice, + updatedUnrealizedPnl, + updatedUnrealizedPnlPercent, + }; +} + +function getMarketEffectiveInitialMarginForMarket(config: IndexerPerpetualMarketResponseObject) { + const initialMarginFraction = MaybeBigNumber(config.initialMarginFraction); + const openInterest = MaybeBigNumber(config.openInterest); + const openInterestLowerCap = MaybeBigNumber(config.openInterestLowerCap); + const openInterestUpperCap = MaybeBigNumber(config.openInterestUpperCap); + const oraclePrice = MaybeBigNumber(config.oraclePrice); + + if (initialMarginFraction == null) return null; + if ( + oraclePrice == null || + openInterest == null || + openInterestLowerCap == null || + openInterestUpperCap == null + ) { + return initialMarginFraction; + } + + // if these are equal we can throw an error from dividing by zero + if (openInterestUpperCap.eq(openInterestLowerCap)) { + return initialMarginFraction; + } + + const openNotional = openInterest.times(oraclePrice); + const scalingFactor = openNotional + .minus(openInterestLowerCap) + .div(openInterestUpperCap.minus(openInterestLowerCap)); + const imfIncrease = scalingFactor.times(MustBigNumber(1).minus(initialMarginFraction)); + + const effectiveIMF = BigNumber.minimum( + initialMarginFraction.plus(BigNumber.maximum(imfIncrease, 0.0)), + 1.0 + ); + return effectiveIMF; +} diff --git a/src/abacus-ts/calculators/transfers.ts b/src/abacus-ts/calculators/transfers.ts new file mode 100644 index 000000000..1fac414b0 --- /dev/null +++ b/src/abacus-ts/calculators/transfers.ts @@ -0,0 +1,21 @@ +import { IndexerTransferResponseObject } from '@/types/indexer/indexerApiGen'; +import { keyBy, maxBy } from 'lodash'; + +import { EMPTY_ARR } from '@/constants/objects'; + +import { MustBigNumber } from '@/lib/numbers'; + +import { mergeObjects } from '../lib/mergeObjects'; + +export function calculateTransfers( + liveTransfers: IndexerTransferResponseObject[] | undefined, + restTransfers: IndexerTransferResponseObject[] | undefined +) { + const getTransfersById = (data: IndexerTransferResponseObject[]) => + keyBy(data, (transfer) => transfer.id); + return mergeObjects( + getTransfersById(liveTransfers ?? EMPTY_ARR), + getTransfersById(restTransfers ?? EMPTY_ARR), + (first, second) => maxBy([first, second], (f) => MustBigNumber(f.createdAtHeight).toNumber())! + ); +} diff --git a/src/abacus-ts/lib/mapLoadable.ts b/src/abacus-ts/lib/mapLoadable.ts index b41c2e3ec..27cdfa162 100644 --- a/src/abacus-ts/lib/mapLoadable.ts +++ b/src/abacus-ts/lib/mapLoadable.ts @@ -6,3 +6,31 @@ export function mapLoadableData(load: Loadable, map: (obj: T) => R): Lo data: load.data != null ? map(load.data) : undefined, } as Loadable; } + +export function mergeLoadableData( + one: Loadable, + two: Loadable +): Loadable<[T | undefined, R | undefined]> { + const priority = ['pending', 'error', 'success', 'idle'] as const; + return { + status: priority[Math.min(priority.indexOf(one.status), priority.indexOf(two.status))]!, + error: (one as any).error ?? (two as any).error ?? undefined, + data: [one.data, two.data], + } as any; +} + +// converts idle to pending and if a status has valid data is counts as success +export function mergeLoadableStatus( + ...status: Array> +): Exclude['status'], 'idle'> { + if (status.some((s) => s.status === 'error' && s.data == null)) { + return 'error'; + } + if (status.some((s) => s.status === 'idle')) { + return 'pending'; + } + if (status.some((s) => s.status === 'pending' && s.data == null)) { + return 'pending'; + } + return 'success'; +} diff --git a/src/abacus-ts/lib/mergeObjects.ts b/src/abacus-ts/lib/mergeObjects.ts new file mode 100644 index 000000000..50f40ac13 --- /dev/null +++ b/src/abacus-ts/lib/mergeObjects.ts @@ -0,0 +1,21 @@ +type SimpleMap = { [key: string]: T }; +export function mergeObjects(one: SimpleMap, two: SimpleMap, merge: (a: T, b: T) => T) { + const finalObj: SimpleMap = {}; + + [...Object.keys(one), ...Object.keys(two)].forEach((key) => { + if (finalObj[key] != null) { + return; + } + const obj = one[key]; + const otherObj = two[key]; + if (obj != null && otherObj != null) { + finalObj[key] = merge(obj, otherObj); + } else if (obj == null && otherObj == null) { + // do nothing + } else { + // we know one of them is non-null + finalObj[key] = (obj ?? otherObj)!; + } + }); + return finalObj; +} diff --git a/src/abacus-ts/rawTypes.ts b/src/abacus-ts/rawTypes.ts index f4811418e..0d041efc6 100644 --- a/src/abacus-ts/rawTypes.ts +++ b/src/abacus-ts/rawTypes.ts @@ -13,6 +13,7 @@ import { import { MetadataServiceAssetInfo } from '@/constants/assetMetadata'; export type MarketsData = { [marketId: string]: IndexerPerpetualMarketResponseObject }; +export type OrdersData = { [orderId: string]: IndexerCompositeOrderObject }; export type OrderbookData = { bids: { [price: string]: string }; @@ -27,10 +28,10 @@ export interface ParentSubaccountData { // this data is lost on websocket reconnect, should never be trusted as the ONLY source for this information // it should be used to trigger a rest call refresh (debounced) and merged with the rest call result until the refresh completes - ephemeral: { + live: { tradingRewards?: IndexerHistoricalBlockTradingReward[]; fills?: IndexerCompositeFillObject[]; - orders?: { [orderId: string]: IndexerCompositeOrderObject }; + orders?: OrdersData; transfers?: IndexerTransferResponseObject[]; }; } diff --git a/src/abacus-ts/selectors/account.ts b/src/abacus-ts/selectors/account.ts new file mode 100644 index 000000000..b819ff01d --- /dev/null +++ b/src/abacus-ts/selectors/account.ts @@ -0,0 +1,173 @@ +import { IndexerPerpetualPositionStatus } from '@/types/indexer/indexerApiGen'; +import { pick } from 'lodash'; +import { shallowEqual } from 'react-redux'; + +import { EMPTY_ARR } from '@/constants/objects'; + +import { createAppSelector } from '@/state/appTypes'; +import { getCurrentMarketId } from '@/state/perpetualsSelectors'; + +import { calculateFills } from '../calculators/fills'; +import { + calculateAllOrders, + calculateOpenOrders, + calculateOrderHistory, +} from '../calculators/orders'; +import { + calculateMarketsNeededForSubaccount, + calculateParentSubaccountPositions, + calculateParentSubaccountSummary, +} from '../calculators/subaccount'; +import { calculateTransfers } from '../calculators/transfers'; +import { mergeLoadableStatus } from '../lib/mapLoadable'; +import { + selectRawFillsLiveData, + selectRawFillsRest, + selectRawFillsRestData, + selectRawIndexerHeight, + selectRawIndexerHeightData, + selectRawMarkets, + selectRawMarketsData, + selectRawOrdersLiveData, + selectRawOrdersRest, + selectRawOrdersRestData, + selectRawParentSubaccount, + selectRawParentSubaccountData, + selectRawTransfersLiveData, + selectRawTransfersRest, + selectRawTransfersRestData, + selectRawValidatorHeight, + selectRawValidatorHeightData, +} from './base'; + +const BACKUP_BLOCK_HEIGHT = { height: 0, time: '1971-01-01T00:00:00Z' }; + +const selectRelevantMarketsList = createAppSelector( + [selectRawParentSubaccountData], + (parentSubaccount) => { + if (parentSubaccount == null) { + return undefined; + } + return calculateMarketsNeededForSubaccount(parentSubaccount); + } +); + +const selectRelevantMarketsData = createAppSelector( + [selectRelevantMarketsList, selectRawMarketsData], + (marketIds, markets) => { + if (markets == null || marketIds == null) { + return undefined; + } + return pick(markets, ...marketIds); + }, + { + // use shallow equal for result so that we only update when these specific keys differ + memoizeOptions: { resultEqualityCheck: shallowEqual }, + } +); + +export const selectParentSubaccountSummary = createAppSelector( + [selectRawParentSubaccountData, selectRelevantMarketsData], + (parentSubaccount, markets) => { + if (parentSubaccount == null || markets == null) { + return undefined; + } + const result = calculateParentSubaccountSummary(parentSubaccount, markets); + return result; + } +); + +export const selectParentSubaccountPositions = createAppSelector( + [selectRawParentSubaccountData, selectRelevantMarketsData], + (parentSubaccount, markets) => { + if (parentSubaccount == null || markets == null) { + return undefined; + } + return calculateParentSubaccountPositions(parentSubaccount, markets); + } +); + +export const selectParentSubaccountSummaryLoading = createAppSelector( + [selectRawParentSubaccount, selectRawMarkets], + mergeLoadableStatus +); + +export const selectParentSubaccountOpenPositions = createAppSelector( + [selectParentSubaccountPositions], + (positions) => { + return positions?.filter((p) => p.status === IndexerPerpetualPositionStatus.OPEN); + } +); +export const selectParentSubaccountOpenPositionsLoading = selectParentSubaccountSummaryLoading; + +export const selectAccountOrders = createAppSelector( + [ + selectRawOrdersRestData, + selectRawOrdersLiveData, + selectRawValidatorHeightData, + selectRawIndexerHeightData, + ], + (rest, live, indexerHeight, validatorHeight) => { + return calculateAllOrders(rest, live, validatorHeight ?? indexerHeight ?? BACKUP_BLOCK_HEIGHT); + } +); + +export const selectOpenOrders = createAppSelector([selectAccountOrders], (orders) => { + return calculateOpenOrders(orders); +}); + +export const selectOrderHistory = createAppSelector([selectAccountOrders], (orders) => { + return calculateOrderHistory(orders); +}); + +export const selectCurrentMarketOpenOrders = createAppSelector( + [getCurrentMarketId, selectOpenOrders], + (currentMarketId, orders) => + !currentMarketId ? EMPTY_ARR : orders.filter((o) => o.marketId === currentMarketId) +); + +export const selectCurrentMarketOrderHistory = createAppSelector( + [getCurrentMarketId, selectOrderHistory], + (currentMarketId, orders) => + !currentMarketId ? EMPTY_ARR : orders.filter((o) => o.marketId === currentMarketId) +); + +export const selectAccountOrdersLoading = createAppSelector( + [ + selectRawOrdersRest, + selectRawParentSubaccount, + selectRawValidatorHeight, + selectRawIndexerHeight, + ], + mergeLoadableStatus +); + +export const selectAccountFills = createAppSelector( + [selectRawFillsRestData, selectRawFillsLiveData], + (rest, live) => { + return calculateFills(rest?.fills, live); + } +); + +export const getCurrentMarketAccountFills = createAppSelector( + [getCurrentMarketId, selectAccountFills], + (currentMarketId, fills) => + !currentMarketId ? EMPTY_ARR : fills.filter((f) => f.market === currentMarketId) +); + +export const selectAccountFillsLoading = createAppSelector( + [selectRawFillsRest, selectRawParentSubaccount], + mergeLoadableStatus +); + +export const selectAccountTransfers = createAppSelector( + [selectRawTransfersRestData, selectRawTransfersLiveData], + (rest, live) => { + return calculateTransfers(rest?.transfers, live); + } +); + +export const selectAccountTransfersLoading = createAppSelector( + [selectRawTransfersRest, selectRawParentSubaccount], + mergeLoadableStatus +); diff --git a/src/abacus-ts/selectors/base.ts b/src/abacus-ts/selectors/base.ts new file mode 100644 index 000000000..3087b2476 --- /dev/null +++ b/src/abacus-ts/selectors/base.ts @@ -0,0 +1,42 @@ +import { type RootState } from '@/state/_store'; + +export const selectRawState = (state: RootState) => state.raw; + +export const selectRawAccountState = (state: RootState) => state.raw.account; + +export const selectRawMarkets = (state: RootState) => state.raw.markets.allMarkets; +export const selectRawMarketsData = (state: RootState) => state.raw.markets.allMarkets.data; +export const selectRawAssetsData = (state: RootState) => state.raw.markets.assets.data; + +export const selectRawParentSubaccount = (state: RootState) => state.raw.account.parentSubaccount; +export const selectRawParentSubaccountData = (state: RootState) => + state.raw.account.parentSubaccount.data; + +export const selectRawFillsRestData = (state: RootState) => state.raw.account.fills.data; +export const selectRawOrdersRestData = (state: RootState) => state.raw.account.orders.data; +export const selectRawTransfersRestData = (state: RootState) => state.raw.account.transfers.data; +export const selectRawBlockTradingRewardsRestData = (state: RootState) => + state.raw.account.blockTradingRewards.data; + +export const selectRawFillsLiveData = (state: RootState) => + state.raw.account.parentSubaccount.data?.live.fills; +export const selectRawOrdersLiveData = (state: RootState) => + state.raw.account.parentSubaccount.data?.live.orders; +export const selectRawTransfersLiveData = (state: RootState) => + state.raw.account.parentSubaccount.data?.live.transfers; +export const selectRawBlockTradingRewardsLiveData = (state: RootState) => + state.raw.account.parentSubaccount.data?.live.tradingRewards; + +export const selectRawIndexerHeightData = (state: RootState) => + state.raw.heights.indexerHeight.data; +export const selectRawValidatorHeightData = (state: RootState) => + state.raw.heights.validatorHeight.data; + +export const selectRawFillsRest = (state: RootState) => state.raw.account.fills; +export const selectRawOrdersRest = (state: RootState) => state.raw.account.orders; +export const selectRawTransfersRest = (state: RootState) => state.raw.account.transfers; +export const selectRawBlockTradingRewardsRest = (state: RootState) => + state.raw.account.blockTradingRewards; + +export const selectRawIndexerHeight = (state: RootState) => state.raw.heights.indexerHeight; +export const selectRawValidatorHeight = (state: RootState) => state.raw.heights.validatorHeight; diff --git a/src/abacus-ts/selectors/markets.ts b/src/abacus-ts/selectors/markets.ts new file mode 100644 index 000000000..e8baba619 --- /dev/null +++ b/src/abacus-ts/selectors/markets.ts @@ -0,0 +1,8 @@ +import { createAppSelector } from '@/state/appTypes'; + +import { calculateAllMarkets } from '../calculators/markets'; +import { selectRawMarketsData } from './base'; + +export const selectAllMarketsInfo = createAppSelector([selectRawMarketsData], (markets) => + calculateAllMarkets(markets) +); diff --git a/src/abacus-ts/summaryTypes.ts b/src/abacus-ts/summaryTypes.ts new file mode 100644 index 000000000..e4da33b1e --- /dev/null +++ b/src/abacus-ts/summaryTypes.ts @@ -0,0 +1,139 @@ +import { + IndexerAPITimeInForce, + IndexerOrderSide, + IndexerOrderType, + IndexerPerpetualMarketResponseObject, + IndexerPerpetualPositionResponseObject, +} from '@/types/indexer/indexerApiGen'; +import { type BigNumber } from 'bignumber.js'; + +type ReplaceBigNumberInUnion = T extends string ? BigNumber : T; + +// Helper type to select properties that can be strings (including in unions) +type SelectStringProperties = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [P in keyof T]: NonNullable extends string | infer U ? P : never; +}[keyof T]; + +// Main type that converts specified properties from string to BigNumber +type ConvertStringToBigNumber> = { + [P in keyof T]: P extends K ? ReplaceBigNumberInUnion : T[P]; +}; + +export type MarketInfo = IndexerPerpetualMarketResponseObject & { + stepSizeDecimals: number; + tickSizeDecimals: number; +}; +export type MarketsInfo = { [marketId: string]: MarketInfo }; + +export type SubaccountSummaryCore = { + quoteBalance: BigNumber; + valueTotal: BigNumber; + notionalTotal: BigNumber; + initialRiskTotal: BigNumber; + maintenanceRiskTotal: BigNumber; +}; + +export type SubaccountSummaryDerived = { + freeCollateral: BigNumber; + equity: BigNumber; + + leverage: BigNumber | null; + marginUsage: BigNumber | null; +}; + +export type SubaccountSummary = SubaccountSummaryCore & SubaccountSummaryDerived; +export type GroupedSubaccountSummary = SubaccountSummaryDerived; + +export type SubaccountPositionBase = ConvertStringToBigNumber< + IndexerPerpetualPositionResponseObject, + | 'size' + | 'maxSize' + | 'entryPrice' + | 'realizedPnl' + | 'createdAtHeight' + | 'sumOpen' + | 'sumClose' + | 'netFunding' + | 'unrealizedPnl' + | 'exitPrice' +>; + +export type MarginMode = 'ISOLATED' | 'CROSS'; + +export type SubaccountPositionDerivedCore = { + uniqueId: string; + assetId: string; + marginMode: MarginMode; + + signedSize: BigNumber; // indexer size is signed by default but we make it obvious here + unsignedSize: BigNumber; // always positive + notional: BigNumber; // always positive + value: BigNumber; // can be negative + + adjustedImf: BigNumber; + adjustedMmf: BigNumber; + + initialRisk: BigNumber; + maintenanceRisk: BigNumber; + maxLeverage: BigNumber | null; + + // these are just copied from the perpetual position for aesthetic reasons honestly + baseEntryPrice: BigNumber; + baseNetFunding: BigNumber; +}; + +export type SubaccountPositionDerivedExtra = { + // all these depend on the subaccount being calculated + leverage: BigNumber | null; + marginValueMaintenance: BigNumber; + marginValueInitial: BigNumber; + liquidationPrice: BigNumber | null; + + updatedUnrealizedPnl: BigNumber; + updatedUnrealizedPnlPercent: BigNumber | null; +}; + +export type SubaccountPosition = Omit & + SubaccountPositionDerivedCore & + SubaccountPositionDerivedExtra; + +export enum OrderStatus { + Canceled = 'CANCELED', + Canceling = 'BEST_EFFORT_CANCELED', + Filled = 'FILLED', + Open = 'OPEN', + Pending = 'PENDING', + Untriggered = 'UNTRIGGERED', + PartiallyFilled = 'PARTIALLY_FILLED', + PartiallyCanceled = 'PARTIALLY_CANCELED', +} + +export type SubaccountOrder = { + subaccountNumber: number; + id: string; + clientId: string | undefined; + type: IndexerOrderType; + side: IndexerOrderSide; + status: OrderStatus | undefined; + timeInForce: IndexerAPITimeInForce | undefined; + marketId: string; + displayId: string; + clobPairId: number | undefined; + orderFlags: string | undefined; + price: BigNumber; + triggerPrice: BigNumber | undefined; + size: BigNumber; + remainingSize: BigNumber | undefined; + totalFilled: BigNumber | undefined; + goodTilBlock: number | undefined; + goodTilBlockTime: number | undefined; + createdAtHeight: number | undefined; + expiresAtMilliseconds: number | undefined; + updatedAtMilliseconds: number | undefined; + updatedAtHeight: number | undefined; + postOnly: boolean; + reduceOnly: boolean; + removalReason: string | undefined; + marginMode: MarginMode | undefined; +}; diff --git a/src/abacus-ts/websocket/parentSubaccount.ts b/src/abacus-ts/websocket/parentSubaccount.ts index 7c4ed94ed..d2984e88f 100644 --- a/src/abacus-ts/websocket/parentSubaccount.ts +++ b/src/abacus-ts/websocket/parentSubaccount.ts @@ -87,7 +87,7 @@ function accountWebsocketValue( .map(convertToStoredChildSubaccount), (c) => c.subaccountNumber ), - ephemeral: { + live: { orders: keyBy(message.orders, (o) => o.id), }, }); @@ -150,16 +150,16 @@ function accountWebsocketValue( }); } if (update.tradingReward != null) { - returnValue.ephemeral.tradingRewards ??= []; - returnValue.ephemeral.tradingRewards = [ - ...returnValue.ephemeral.tradingRewards, + returnValue.live.tradingRewards ??= []; + returnValue.live.tradingRewards = [ + ...returnValue.live.tradingRewards, update.tradingReward, ]; } if (update.fills != null) { - returnValue.ephemeral.fills ??= []; - returnValue.ephemeral.fills = [ - ...returnValue.ephemeral.fills, + returnValue.live.fills ??= []; + returnValue.live.fills = [ + ...returnValue.live.fills, ...update.fills.map((f) => ({ ...f, subaccountNumber, @@ -169,8 +169,8 @@ function accountWebsocketValue( ]; } if (update.orders != null) { - returnValue.ephemeral.orders = { ...(returnValue.ephemeral.orders ?? {}) }; - const allOrders = returnValue.ephemeral.orders; + returnValue.live.orders = { ...(returnValue.live.orders ?? {}) }; + const allOrders = returnValue.live.orders; update.orders.forEach((o) => { const previousOrder = allOrders[o.id]; if (previousOrder == null) { @@ -188,11 +188,8 @@ function accountWebsocketValue( }); } if (update.transfers != null) { - returnValue.ephemeral.transfers ??= []; - returnValue.ephemeral.transfers = [ - ...returnValue.ephemeral.transfers, - update.transfers, - ]; + returnValue.live.transfers ??= []; + returnValue.live.transfers = [...returnValue.live.transfers, update.transfers]; } }); }); diff --git a/src/lib/do.ts b/src/lib/do.ts index 16375f8c6..10a5470ce 100644 --- a/src/lib/do.ts +++ b/src/lib/do.ts @@ -1,6 +1,7 @@ export function runFn(fn: () => T): T { return fn(); } +export const calc = runFn; type NonNullableArray = { [K in keyof T]: NonNullable; diff --git a/src/lib/numbers.ts b/src/lib/numbers.ts index 9b2b6b6bd..7723ad7d3 100644 --- a/src/lib/numbers.ts +++ b/src/lib/numbers.ts @@ -10,10 +10,21 @@ export const BIG_NUMBERS = { ONE: new BigNumber(1), }; +// defaults to zero if null or empty export const MustBigNumber = (amount?: BigNumberish | null): BigNumber => // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing new BigNumber(amount || 0); +// undefined if falsey otherwise a valid bignumber +export const MaybeBigNumber = (amount?: BigNumberish | null): BigNumber | undefined => + amount ? MustBigNumber(amount) : undefined; + +// doesnt allow null, always returns big number +// empty string becomes null though +export const ToBigNumber = (amount: BigNumberish): BigNumber => { + return MustBigNumber(amount); +}; + /** * @description Rounds the input to the nearest multiple of `factor`, which must be non-zero. */ diff --git a/src/state/raw.ts b/src/state/raw.ts index 651a81466..2132af23a 100644 --- a/src/state/raw.ts +++ b/src/state/raw.ts @@ -1,13 +1,16 @@ import { Loadable, loadableIdle } from '@/abacus-ts/lib/loadable'; -import { AssetInfos, MarketsData, OrderbookData, ParentSubaccountData } from '@/abacus-ts/rawTypes'; +import { + AssetInfos, + MarketsData, + OrderbookData, + OrdersData, + ParentSubaccountData, +} from '@/abacus-ts/rawTypes'; import { IndexerHistoricalBlockTradingRewardsResponse, IndexerParentSubaccountTransferResponse, } from '@/types/indexer/indexerApiGen'; -import { - IndexerCompositeFillResponse, - IndexerCompositeOrderObject, -} from '@/types/indexer/indexerManual'; +import { IndexerCompositeFillResponse } from '@/types/indexer/indexerManual'; import { HeightResponse } from '@dydxprotocol/v4-client-js'; import { createSlice, PayloadAction } from '@reduxjs/toolkit'; @@ -27,7 +30,7 @@ export interface RawDataState { account: { parentSubaccount: Loadable; fills: Loadable; - orders: Loadable<{ [id: string]: IndexerCompositeOrderObject }>; + orders: Loadable; transfers: Loadable; blockTradingRewards: Loadable; }; @@ -90,10 +93,7 @@ export const rawSlice = createSlice({ ) => { state.account.blockTradingRewards = action.payload; }, - setAccountOrdersRaw: ( - state, - action: PayloadAction> - ) => { + setAccountOrdersRaw: (state, action: PayloadAction>) => { state.account.orders = action.payload; }, setNetworkStateRaw: ( diff --git a/src/types/indexer/indexerManual.ts b/src/types/indexer/indexerManual.ts index f7d4dc28a..4d58adedc 100644 --- a/src/types/indexer/indexerManual.ts +++ b/src/types/indexer/indexerManual.ts @@ -29,11 +29,11 @@ export interface IndexerCompositeOrderObject { subaccountId?: string; clientId?: string; clobPairId?: string; - side?: IndexerOrderSide; - size?: string; - totalFilled?: string; - price?: string; - type?: IndexerOrderType; + side: IndexerOrderSide; + size: string; + totalFilled: string; + price: string; + type: IndexerOrderType; reduceOnly?: boolean; orderFlags?: string; goodTilBlock?: string | null; @@ -44,10 +44,10 @@ export interface IndexerCompositeOrderObject { timeInForce?: IndexerAPITimeInForce; status?: IndexerAPIOrderStatus; postOnly?: boolean; - ticker?: string; + ticker: string; updatedAt?: IndexerIsoString | null; updatedAtHeight?: string | null; - subaccountNumber?: number; + subaccountNumber: number; removalReason?: string; totalOptimisticFilled?: string; }