From c9d77e368c7fd55c55e30a403d32910e09983fdb Mon Sep 17 00:00:00 2001 From: Erwan Or Date: Mon, 13 Jan 2025 14:28:29 -0800 Subject: [PATCH] fix: rework position rendering logic (#242) * math(positions): add comment about asset id encoding * ui(positions): don't render trailing zeroes * model(positions): fix `DisplayPosition` rendering Co-Authored-By: Jason M. Hasperhoven <13964126+JasonMHasperhoven@users.noreply.github.com> * math(tests): scaffolding for rendering calculation tests Co-Authored-By: Jason M. Hasperhoven <13964126+JasonMHasperhoven@users.noreply.github.com> * trade: fix current value rendering Co-Authored-By: Jason M. Hasperhoven <13964126+JasonMHasperhoven@users.noreply.github.com> * chore: run linter * Add tests for getOrdersByBaseQuoteAssets * Remove eslint disable comment * Add canonical order assertion before all position tests * Add comment to ExecutionPosition interface * Refactor getDirectionalOrders & getOrderValueViews * Add tests for getOrderValueViews and convert units to use BigNumber * Fix unit conversions in current positions * Add tests for getDirectionalOrders --------- Co-authored-by: Jason M. Hasperhoven <13964126+JasonMHasperhoven@users.noreply.github.com> Co-authored-by: Jason M. Hasperhoven --- src/pages/trade/model/positions.test.ts | 301 ++++++++++++++ src/pages/trade/model/positions.ts | 379 +++++++++++------- .../trade/ui/positions-current-value.tsx | 34 +- src/pages/trade/ui/positions.tsx | 24 +- src/shared/math/position.test.ts | 65 +++ src/shared/math/position.ts | 1 + 6 files changed, 634 insertions(+), 170 deletions(-) create mode 100644 src/pages/trade/model/positions.test.ts diff --git a/src/pages/trade/model/positions.test.ts b/src/pages/trade/model/positions.test.ts new file mode 100644 index 00000000..185f9684 --- /dev/null +++ b/src/pages/trade/model/positions.test.ts @@ -0,0 +1,301 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { + Position, + PositionState_PositionStateEnum, +} from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { ExecutedPosition, positionsStore } from './positions'; +import { + Metadata, + AssetId, + DenomUnit, +} from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { compareAssetId } from '@/shared/math/position'; +import { pnum } from '@penumbra-zone/types/pnum'; +import { BigNumber } from 'bignumber.js'; + +describe('positionsStore', () => { + const id1 = new Uint8Array(Array(32).fill(0xaa)); + const id2 = new Uint8Array(Array(32).fill(0xbb)); + const id3 = new Uint8Array(Array(32).fill(0xcc)); + const id4 = new Uint8Array(Array(32).fill(0xdd)); + const stableId = new Uint8Array(Array(32).fill(0xee)); + const p1 = 2; + const p2 = 1; + const exponent1 = 6; + const exponent2 = 9; + + const createMetaData = (id: Uint8Array, display: string, exponent: number) => { + return new Metadata({ + penumbraAssetId: new AssetId({ inner: id }), + symbol: display, + display, + denomUnits: [new DenomUnit({ denom: display, exponent })], + }); + }; + + const metadataWithId1 = createMetaData(id1, 'asset1', exponent1); + const metadataWithId2 = createMetaData(id2, 'asset2', exponent2); + const metadataWithId3 = createMetaData(id3, 'asset2', exponent2); + const metadataWithId4 = createMetaData(id4, 'asset2', exponent2); + const metadataWithStableCoin = createMetaData(stableId, 'USDC', exponent2); + + const feeBps = 25; + + const createPosition = ({ + r1, + r2, + asset1Id = id1, + asset2Id = id2, + }: { + r1: bigint; + r2: bigint; + asset1Id?: Uint8Array; + asset2Id?: Uint8Array; + }) => { + return new Position({ + phi: { + component: { + fee: feeBps, + p: { + lo: BigInt(p1), + hi: 0n, + }, + q: { + lo: BigInt(p2), + hi: 0n, + }, + }, + pair: { + asset1: { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + inner: asset1Id ?? id1, + }, + asset2: { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + inner: asset2Id ?? id2, + }, + }, + }, + nonce: new Uint8Array(Array(32).fill(0xcc)), + state: { + state: PositionState_PositionStateEnum.OPENED, + sequence: 0n, + }, + reserves: { + r1: { + lo: r1, + hi: 0n, + }, + r2: { + lo: r2, + hi: 0n, + }, + }, + closeOnFill: false, + }); + }; + + beforeAll(() => { + positionsStore.setAssets([ + metadataWithId1, + metadataWithId2, + metadataWithId3, + metadataWithId4, + metadataWithStableCoin, + ]); + + // Assert that id1 and id2 are in canonical order + expect( + compareAssetId(new AssetId({ inner: id1 }), new AssetId({ inner: id2 })), + ).toBeLessThanOrEqual(0); + }); + + describe('getOrdersByBaseQuoteAssets', () => { + it('should return a buy and sell order when both assets have reserves', () => { + const position = createPosition({ + r1: 100n, + r2: 100n, + }); + + const [asset1, asset2] = positionsStore.getCalculatedAssets(position as ExecutedPosition); + const orders = positionsStore.getOrdersByBaseQuoteAssets(asset1, asset2); + expect(orders[0]?.direction).toEqual('Buy'); + expect(orders[1]?.direction).toEqual('Sell'); + }); + + it('should return a buy order when only the quote asset has reserves', () => { + const position = createPosition({ + r1: 0n, + r2: 100n, + }); + + const [asset1, asset2] = positionsStore.getCalculatedAssets(position as ExecutedPosition); + const orders = positionsStore.getOrdersByBaseQuoteAssets(asset1, asset2); + expect(orders[0]?.direction).toEqual('Buy'); + expect(orders[1]).toEqual(undefined); + }); + + it('should return a sell order when only the base asset has reserves', () => { + const position = createPosition({ + r1: 100n, + r2: 0n, + }); + + const [asset1, asset2] = positionsStore.getCalculatedAssets(position as ExecutedPosition); + const orders = positionsStore.getOrdersByBaseQuoteAssets(asset1, asset2); + expect(orders[0]?.direction).toEqual('Sell'); + expect(orders[1]).toEqual(undefined); + }); + }); + + describe('getOrderValueViews', () => { + it('should return the correct value views for a buy order', () => { + const position = createPosition({ + r1: 0n, + r2: pnum(12.123, exponent2).toBigInt(), + }); + + const [asset1, asset2] = positionsStore.getCalculatedAssets(position as ExecutedPosition); + const orders = positionsStore.getOrdersByBaseQuoteAssets(asset1, asset2); + const buyOrder = orders[0]; + + const valueViews = positionsStore.getOrderValueViews(buyOrder!); + + const basePrice = (p1 / p2) * 10 ** (exponent1 - exponent2); + const effectivePrice = BigNumber(basePrice) + .minus(BigNumber(basePrice).times(feeBps).div(10000)) + .toNumber(); + + expect(pnum(valueViews.amount).toNumber()).toEqual( + Number( + ( + buyOrder!.quoteAsset.amount.toNumber() * + buyOrder!.quoteAsset.effectivePrice.toNumber() * + 10 ** (exponent2 - exponent1) + ).toFixed(exponent1), + ), + ); + expect(pnum(valueViews.basePrice).toNumber()).toEqual(basePrice); + expect(pnum(valueViews.effectivePrice).toNumber()).toEqual(effectivePrice); + }); + + it('should return the correct value views for a sell order', () => { + const position = createPosition({ + r1: pnum(4.567, exponent1).toBigInt(), + r2: 0n, + }); + + const [asset1, asset2] = positionsStore.getCalculatedAssets(position as ExecutedPosition); + const orders = positionsStore.getOrdersByBaseQuoteAssets(asset1, asset2); + const sellOrder = orders[0]; + + const valueViews = positionsStore.getOrderValueViews(sellOrder!); + const basePrice = (p1 / p2) * 10 ** (exponent1 - exponent2); + const effectivePrice = BigNumber(basePrice) + .minus(BigNumber(basePrice).times(feeBps).div(10000)) + .toNumber(); + + expect(pnum(valueViews.amount).toNumber()).toEqual(4.567); + expect(pnum(valueViews.basePrice).toNumber()).toEqual(basePrice); + expect(pnum(valueViews.effectivePrice).toNumber()).toEqual(effectivePrice); + }); + }); + + describe('getDirectionalOrders', () => { + it('should return with asset 1/2 as base/quote asset when its the provided base/quote assets', () => { + const position = createPosition({ + r1: 0n, + r2: 100n, + }); + + const [asset1, asset2] = positionsStore.getCalculatedAssets(position as ExecutedPosition); + const directionalOrders = positionsStore.getDirectionalOrders({ + asset1, + asset2, + baseAsset: metadataWithId1, + quoteAsset: metadataWithId2, + }); + const positionOrder = directionalOrders[0]!; + + expect( + positionOrder.baseAsset.asset.penumbraAssetId?.equals(asset1.asset.penumbraAssetId), + ).toBe(true); + expect( + positionOrder.quoteAsset.asset.penumbraAssetId?.equals(asset2.asset.penumbraAssetId), + ).toBe(true); + }); + + it('should return with asset 2/1 as base/quote asset when its the provided base/quote assets', () => { + const position = createPosition({ + r1: 0n, + r2: 100n, + }); + + const [asset1, asset2] = positionsStore.getCalculatedAssets(position as ExecutedPosition); + const directionalOrders = positionsStore.getDirectionalOrders({ + asset1, + asset2, + baseAsset: metadataWithId2, + quoteAsset: metadataWithId1, + }); + const positionOrder = directionalOrders[0]!; + + expect( + positionOrder.baseAsset.asset.penumbraAssetId?.equals(asset2.asset.penumbraAssetId), + ).toBe(true); + expect( + positionOrder.quoteAsset.asset.penumbraAssetId?.equals(asset1.asset.penumbraAssetId), + ).toBe(true); + }); + + it('should return with asset 1 as quote asset when asset 1 is a stable coin', () => { + const position = createPosition({ + r1: 0n, + r2: 100n, + asset1Id: stableId, + asset2Id: id2, + }); + + const [asset1, asset2] = positionsStore.getCalculatedAssets(position as ExecutedPosition); + const directionalOrders = positionsStore.getDirectionalOrders({ + asset1, + asset2, + baseAsset: metadataWithId1, + quoteAsset: metadataWithId2, + }); + const positionOrder = directionalOrders[0]!; + + expect( + positionOrder.baseAsset.asset.penumbraAssetId?.equals(asset2.asset.penumbraAssetId), + ).toBe(true); + expect( + positionOrder.quoteAsset.asset.penumbraAssetId?.equals(asset1.asset.penumbraAssetId), + ).toBe(true); + }); + + it('should return with asset 2 as quote asset when asset 2 is a stable coin', () => { + const position = createPosition({ + r1: 0n, + r2: 100n, + asset1Id: id1, + asset2Id: stableId, + }); + + const [asset1, asset2] = positionsStore.getCalculatedAssets(position as ExecutedPosition); + const directionalOrders = positionsStore.getDirectionalOrders({ + asset1, + asset2, + baseAsset: metadataWithId1, + quoteAsset: metadataWithId2, + }); + const positionOrder = directionalOrders[0]!; + + expect( + positionOrder.baseAsset.asset.penumbraAssetId?.equals(asset1.asset.penumbraAssetId), + ).toBe(true); + expect( + positionOrder.quoteAsset.asset.penumbraAssetId?.equals(asset2.asset.penumbraAssetId), + ).toBe(true); + }); + }); +}); diff --git a/src/pages/trade/model/positions.ts b/src/pages/trade/model/positions.ts index 85a73bbf..affeda79 100644 --- a/src/pages/trade/model/positions.ts +++ b/src/pages/trade/model/positions.ts @@ -3,7 +3,10 @@ import { TransactionPlannerRequest } from '@penumbra-zone/protobuf/penumbra/view import { Position, PositionId, + PositionState, PositionState_PositionStateEnum, + BareTradingFunction, + TradingPair, } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; import { Metadata, ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; import { Amount } from '@penumbra-zone/protobuf/penumbra/core/num/v1/num_pb'; @@ -17,6 +20,8 @@ import { openToast } from '@penumbra-zone/ui/Toast'; import { pnum } from '@penumbra-zone/types/pnum'; import { positionIdFromBech32 } from '@penumbra-zone/bech32m/plpid'; import { updatePositionsQuery } from '@/pages/trade/api/positions'; +import { BigNumber } from 'bignumber.js'; + export interface DisplayPosition { id: PositionId; idString: string; @@ -25,23 +30,38 @@ export interface DisplayPosition { amount: ValueView; basePrice: ValueView; effectivePrice: ValueView; - baseAsset: DisplayAsset; - quoteAsset: DisplayAsset; + baseAsset: CalculatedAsset; + quoteAsset: CalculatedAsset; }[]; fee: string; isActive: boolean; state: PositionState_PositionStateEnum; } -export interface DisplayAsset { +export interface CalculatedAsset { asset: Metadata; exponent: number; - amount: number; - price: number; - effectivePrice: number; + amount: BigNumber; + price: BigNumber; + effectivePrice: BigNumber; reserves: Amount; } +// interface to avoid checking if the nested values exist on a Position +export interface ExecutedPosition { + phi: { + component: BareTradingFunction; + pair: TradingPair; + }; + nonce: Uint8Array; + state: PositionState; + reserves: { + r1: Amount; + r2: Amount; + }; + closeOnFill: boolean; +} + class PositionsStore { public loading = false; public positionsById = new Map(); @@ -122,7 +142,10 @@ class PositionsStore { this.currentPair = [baseAsset, quoteAsset]; }; - getOrdersByBaseQuoteAssets = (baseAsset: DisplayAsset, quoteAsset: DisplayAsset) => { + getOrdersByBaseQuoteAssets = ( + baseAsset: CalculatedAsset, + quoteAsset: CalculatedAsset, + ): { direction: string; baseAsset: CalculatedAsset; quoteAsset: CalculatedAsset }[] => { if (!isZero(baseAsset.reserves) && !isZero(quoteAsset.reserves)) { return [ { @@ -132,8 +155,8 @@ class PositionsStore { }, { direction: 'Sell', - baseAsset: quoteAsset, - quoteAsset: baseAsset, + baseAsset, + quoteAsset, }, ]; } @@ -166,8 +189,8 @@ class PositionsStore { }, { direction: '', - baseAsset: quoteAsset, - quoteAsset: baseAsset, + baseAsset: baseAsset, + quoteAsset: quoteAsset, }, ]; }; @@ -175,158 +198,230 @@ class PositionsStore { getDirectionalOrders = ({ asset1, asset2, + baseAsset, + quoteAsset, }: { - asset1: { - asset: Metadata; - exponent: number; - amount: number; - price: number; - effectivePrice: number; - reserves: Amount; - }; - asset2: { - asset: Metadata; - exponent: number; - amount: number; - price: number; - effectivePrice: number; - reserves: Amount; - }; - }): { direction: string; baseAsset: DisplayAsset; quoteAsset: DisplayAsset }[] => { - if (!this.currentPair || !asset1.asset.penumbraAssetId || !asset2.asset.penumbraAssetId) { - throw new Error('No current pair or assets'); - } - - const [currentBaseAsset, currentQuoteAsset] = this.currentPair; - const asset1IsBaseAsset = asset1.asset.penumbraAssetId.equals(currentBaseAsset.penumbraAssetId); - const asset1IsQuoteAsset = asset1.asset.penumbraAssetId.equals( - currentQuoteAsset.penumbraAssetId, - ); - const asset2IsBaseAsset = asset2.asset.penumbraAssetId.equals(currentBaseAsset.penumbraAssetId); - const asset2IsQuoteAsset = asset2.asset.penumbraAssetId.equals( - currentQuoteAsset.penumbraAssetId, - ); - - // - if position in current pair, use the current orientation - if (asset1IsBaseAsset && asset2IsQuoteAsset) { + asset1: CalculatedAsset; + asset2: CalculatedAsset; + baseAsset: Metadata; + quoteAsset: Metadata; + }): { direction: string; baseAsset: CalculatedAsset; quoteAsset: CalculatedAsset }[] => { + const wellOrdered = + baseAsset.penumbraAssetId?.equals(asset1.asset.penumbraAssetId) && + quoteAsset.penumbraAssetId?.equals(asset2.asset.penumbraAssetId); + + const orderFlipped = + baseAsset.penumbraAssetId?.equals(asset2.asset.penumbraAssetId) && + quoteAsset.penumbraAssetId?.equals(asset1.asset.penumbraAssetId); + + // Happy path: we have a "Current pair" view which informs how we should render BASE/QUOTE assets. + if (wellOrdered) { return this.getOrdersByBaseQuoteAssets(asset1, asset2); } - if (asset1IsQuoteAsset && asset2IsBaseAsset) { + // We check if flipping asset 1 and asset 2 would result in a base/quote match: + if (orderFlipped) { return this.getOrdersByBaseQuoteAssets(asset2, asset1); } - // - if position not in current pair, and one asset in position - // pair is the current view’s quote asset, use that asset as - // the quote asset - if (asset1IsQuoteAsset) { + // If it fails, this means that the position we want to render is not on the "Current pair" + // view. This is the case if we are on the "BTC/USDC" page, and preparing to display a position + // that is for the "UM/USDY" pair. + // In that case, we want to handle this by deciding if the position contain a well-known numeraire, + // or default to canonical ordering since this is both rare and can be filtered at a higher-level + const asset1IsStablecoin = ['USDC', 'USDY', 'USDT'].includes(asset1.asset.symbol.toUpperCase()); + const asset2IsStablecoin = ['USDC', 'USDY', 'USDT'].includes(asset2.asset.symbol.toUpperCase()); + + const asset1IsNumeraire = + asset1IsStablecoin || ['BTC', 'UM'].includes(asset1.asset.symbol.toUpperCase()); + const asset2IsNumeraire = + asset2IsStablecoin || ['BTC', 'UM'].includes(asset2.asset.symbol.toUpperCase()); + + // If both assets are numeraires, we adjudicate based on priority score: + if (asset1IsNumeraire && asset2IsNumeraire) { + // HACK: It's not completely clear that we want to rely on the registry priority + // score vs. our own local numeraire rule. For example, the registry sets UM as + // having the highest priority. This means that all the UM pairs will be rendered + // with UM as the quote asset. Not great for UM/USDC, UM/USDY. + const asset1HasPriority = asset1.asset.priorityScore > asset2.asset.priorityScore; + + if (asset1IsStablecoin && asset2IsStablecoin) { + return asset1HasPriority + ? this.getOrdersByBaseQuoteAssets(asset2, asset1) + : this.getOrdersByBaseQuoteAssets(asset1, asset2); + } + + if (asset1IsStablecoin) { + return this.getOrdersByBaseQuoteAssets(asset2, asset1); + } + + if (asset2IsStablecoin) { + return this.getOrdersByBaseQuoteAssets(asset1, asset2); + } + + return asset1HasPriority + ? this.getOrdersByBaseQuoteAssets(asset2, asset1) + : this.getOrdersByBaseQuoteAssets(asset1, asset2); + } + + // Otherwise, this is simple, if asset 1 is a numeraire then we render it as the quote asset: + if (asset1IsNumeraire) { return this.getOrdersByBaseQuoteAssets(asset2, asset1); } - if (asset2IsQuoteAsset) { + // Otherwise, if asset 2 is a numeraire we render that one as the quote asset: + if (asset2IsNumeraire) { return this.getOrdersByBaseQuoteAssets(asset1, asset2); } - // - otherwise use whatever ordering + // It's possible that neither are, which is rare, in that case we use the canonical ordering: return this.getOrdersByBaseQuoteAssets(asset1, asset2); }; + getOrderValueViews = ({ + direction, + baseAsset, + quoteAsset, + }: { + direction: string; + baseAsset: CalculatedAsset; + quoteAsset: CalculatedAsset; + }) => { + // We want to render two main piece of information to the user, assuming their unit of account is the quote asset: + // - the price i.e, the number of unit of *quote assets* necessary to obtain a unit of base asset. + // - the trade amount i.e, the amount of *base assets* that the position is either seeking to purchase or sell. + const effectivePrice = pnum( + baseAsset.effectivePrice.toString(), + baseAsset.exponent, + ).toValueView(quoteAsset.asset); + const basePrice = pnum(baseAsset.price.toString(), baseAsset.exponent).toValueView( + quoteAsset.asset, + ); + + // This is the trade amount (in base asset) that the position seeks to SELL or BUY. + const amount = + direction === 'Sell' + ? // We are selling the base asset to obtain the quote asset, so we can simply use the current reserves. + pnum(baseAsset.amount.toString(), baseAsset.exponent).toValueView(baseAsset.asset) + : // We are buying the base asset, we need to convert the quantity of quote asset that we have provisioned. + pnum( + quoteAsset.amount.times(quoteAsset.effectivePrice).toString(), + quoteAsset.exponent, + ).toValueView(baseAsset.asset); + + return { + direction, + baseAsset, + quoteAsset, + amount, + basePrice, + effectivePrice, + }; + }; + + getCalculatedAssets(position: ExecutedPosition): [CalculatedAsset, CalculatedAsset] { + const { phi, reserves } = position; + const { pair, component } = phi; + + const asset1 = this.assets.find(asset => asset.penumbraAssetId?.equals(pair.asset1)); + const asset2 = this.assets.find(asset => asset.penumbraAssetId?.equals(pair.asset2)); + if (!asset1?.penumbraAssetId || !asset2?.penumbraAssetId) { + throw new Error('No assets found in registry that belong to the trading pair'); + } + + const asset1Exponent = asset1.denomUnits.find( + denomUnit => denomUnit.denom === asset1.display, + )?.exponent; + const asset2Exponent = asset2.denomUnits.find( + denomUnit => denomUnit.denom === asset2.display, + )?.exponent; + if (!asset1Exponent || !asset2Exponent) { + throw new Error('No exponents found for assets'); + } + + const { p, q } = component; + const { r1, r2 } = reserves; + + // Positions specifying a trading pair between `asset_1:asset_2`. + // This ordering can conflict with the higher level base/quote. + // First, we compute the exchange rate between asset_1 and asset_2: + const asset1Price = pnum(p).toBigNumber().dividedBy(pnum(q).toBigNumber()); + // Then, we compoute the exchange rate between asset_2 and asset_1. + const asset2Price = pnum(q).toBigNumber().dividedBy(pnum(p).toBigNumber()); + // We start tracking the reserves for each assets: + const asset1ReserveAmount = pnum(r1, asset1Exponent).toBigNumber(); + const asset2ReserveAmount = pnum(r2, asset2Exponent).toBigNumber(); + // Next, we compute the fee percentage for the position. + // We are given a fee recorded in basis points, with an implicit 10^4 scaling factor. + const f = component.fee; + // This means that for 0 <= f < 10_000 (0% < f < 100%), the fee percentage is defined by: + const gamma = (10_000 - f) / 10_000; + // We use it to compute the effective price i.e, the price inclusive of fees in each directions, + // in the case of the first rate this is: price * gamma, such that: + const asset1EffectivePrice = asset1Price.times(pnum(gamma).toBigNumber()); + // in the case of the second, we apply it as an inverse: + const asset2EffectivePrice = asset2Price.dividedBy(pnum(gamma).toBigNumber()); + + return [ + { + asset: asset1, + exponent: asset1Exponent, + amount: asset1ReserveAmount, + price: asset1Price, + effectivePrice: asset1EffectivePrice, + reserves: reserves.r1, + }, + { + asset: asset2, + exponent: asset2Exponent, + amount: asset2ReserveAmount, + price: asset2Price, + effectivePrice: asset2EffectivePrice, + reserves: reserves.r2, + }, + ]; + } + get displayPositions(): DisplayPosition[] { if (!this.assets.length || !this.currentPair) { return []; } - return [...this.positionsById.entries()] - .map(([id, position]) => { - /* eslint-disable curly -- makes code more concise */ - const { phi, reserves, state } = position; - if (!phi || !reserves?.r1 || !reserves.r2 || !state) return; - - const { pair, component } = phi; - if (!pair || !component?.p || !component.q) return; - - const asset1 = this.assets.find(asset => asset.penumbraAssetId?.equals(pair.asset1)); - const asset2 = this.assets.find(asset => asset.penumbraAssetId?.equals(pair.asset2)); - if (!asset1?.penumbraAssetId || !asset2?.penumbraAssetId) return; - - const asset1Exponent = asset1.denomUnits.find( - denumUnit => denumUnit.denom === asset1.display, - )?.exponent; - const asset2Exponent = asset2.denomUnits.find( - denumUnit => denumUnit.denom === asset2.display, - )?.exponent; - if (!asset1Exponent || !asset2Exponent) return; - - const { p, q } = component; - const { r1, r2 } = reserves; - const asset1Price = pnum(p).toBigNumber().dividedBy(pnum(q).toBigNumber()).toNumber(); - const asset2Price = pnum(q).toBigNumber().dividedBy(pnum(p).toBigNumber()).toNumber(); - const asset1Amount = pnum(r1, asset1Exponent).toNumber(); - const asset2Amount = pnum(r2, asset2Exponent).toNumber(); - - // but clearly, this measure of price is insufficient because if two - // positions have the same coefficients but one quote a 100% fee and - // the other a 0% fee, they have in fact very different prices. how do - // we get a measure of price that includes this information? - - // this is what the effective price is for: - // effective exchange rate between asset 1 and asset 2: (p_1/p_2)*gamma - // p1 / (p2 * gamma) ? - // - // asset 2 to asset 1: (p_2 * gamma)/p_1 - const gamma = (10_000 - component.fee) / 10_000; - const asset1EffectivePrice = pnum(p) - .toBigNumber() - .dividedBy(pnum(q).toBigNumber().times(pnum(gamma).toBigNumber())) - .toNumber(); - - const asset2EffectivePrice = pnum(q) - .toBigNumber() - .times(pnum(gamma).toBigNumber()) - .dividedBy(pnum(p).toBigNumber()) - .toNumber(); - - const orders = this.getDirectionalOrders({ - asset1: { - asset: asset1, - exponent: asset1Exponent, - amount: asset1Amount, - price: asset1Price, - effectivePrice: asset1EffectivePrice, - reserves: reserves.r1, - }, - asset2: { - asset: asset2, - exponent: asset2Exponent, - amount: asset2Amount, - price: asset2Price, - effectivePrice: asset2EffectivePrice, - reserves: reserves.r2, - }, - }); - - return { - id: new PositionId(positionIdFromBech32(id)), - idString: id, - orders: orders.map(({ direction, baseAsset, quoteAsset }) => ({ - direction, - amount: - direction === 'Sell' - ? pnum(baseAsset.amount, baseAsset.exponent).toValueView(baseAsset.asset) - : pnum(quoteAsset.amount, quoteAsset.exponent).toValueView(quoteAsset.asset), - basePrice: pnum(quoteAsset.price, quoteAsset.exponent).toValueView(quoteAsset.asset), - effectivePrice: pnum(quoteAsset.effectivePrice, quoteAsset.exponent).toValueView( - quoteAsset.asset, - ), - baseAsset, - quoteAsset, - })), - fee: `${pnum(component.fee / 100).toFormattedString({ decimals: 2 })}%`, - isActive: state.state !== PositionState_PositionStateEnum.WITHDRAWN, - state: state.state, - }; - }) - .filter(displayPosition => displayPosition !== undefined); + return [...this.positionsById.entries()].map(([id, position]) => { + const { phi, state } = position as ExecutedPosition; + const { component } = phi; + const [asset1, asset2] = this.getCalculatedAssets(position as ExecutedPosition); + + if (!this.currentPair) { + throw new Error('No current pair or assets'); + } + + const [baseAsset, quoteAsset] = this.currentPair; + + // Now that we have computed all the price information using the canonical ordering, + // we can simply adjust our values if the directed pair is not the same as the canonical one: + const orders = this.getDirectionalOrders({ + asset1, + asset2, + baseAsset, + quoteAsset, + }).map(this.getOrderValueViews); + + return { + id: new PositionId(positionIdFromBech32(id)), + idString: id, + orders, + fee: `${pnum(component.fee / 100).toFormattedString({ decimals: 2 })}%`, + // TODO: + // We do not yet filter `Closed` positions to allow auto-closing position to provide visual + // feedback about execution. This is probably best later replaced by either a notification, or a + // dedicated view. Fine for now. + isActive: state.state !== PositionState_PositionStateEnum.WITHDRAWN, + state: state.state, + }; + }); + // TODO: filter position view using trading pair route. + // .filter(displayPosition => displayPosition !== undefined) } } diff --git a/src/pages/trade/ui/positions-current-value.tsx b/src/pages/trade/ui/positions-current-value.tsx index ea48168a..ce3ff100 100644 --- a/src/pages/trade/ui/positions-current-value.tsx +++ b/src/pages/trade/ui/positions-current-value.tsx @@ -3,23 +3,33 @@ import { useMarketPrice } from '../model/useMarketPrice'; import { ValueViewComponent } from '@penumbra-zone/ui/ValueView'; import { LoadingCell } from './market-trades'; import { pnum } from '@penumbra-zone/types/pnum'; -import { DisplayAsset } from '../model/positions'; +import { DisplayPosition } from '../model/positions'; -export const PositionsCurrentValue = ({ - baseAsset, - quoteAsset, -}: { - baseAsset: DisplayAsset; - quoteAsset: DisplayAsset; -}) => { +export const PositionsCurrentValue = ({ order }: { order: DisplayPosition['orders'][number] }) => { + const { baseAsset, quoteAsset } = order; const marketPrice = useMarketPrice(baseAsset.asset.symbol, quoteAsset.asset.symbol); - return marketPrice ? ( + if (!marketPrice) { + return ; + } + + if (order.direction === 'Buy') { + return ( + + ); + } + + return ( - ) : ( - ); }; diff --git a/src/pages/trade/ui/positions.tsx b/src/pages/trade/ui/positions.tsx index dce7db9e..d4bf1ec0 100644 --- a/src/pages/trade/ui/positions.tsx +++ b/src/pages/trade/ui/positions.tsx @@ -258,7 +258,7 @@ const Positions = observer(({ showInactive }: { showInactive: boolean }) => { ))} @@ -293,7 +293,7 @@ const Positions = observer(({ showInactive }: { showInactive: boolean }) => {
@@ -309,27 +309,19 @@ const Positions = observer(({ showInactive }: { showInactive: boolean }) => {
{position.orders.map((order, i) => ( - - {pnum(order.basePrice).toFormattedString()}{' '} - {order.baseAsset.asset.symbol} - + valueView={order.basePrice} + trailingZeros={false} + density='slim' + /> ))}
{position.orders.map((order, i) => ( - + ))}
diff --git a/src/shared/math/position.test.ts b/src/shared/math/position.test.ts index 5cf68afc..dc2dfab8 100644 --- a/src/shared/math/position.test.ts +++ b/src/shared/math/position.test.ts @@ -75,3 +75,68 @@ describe('planToPosition', () => { expect(pnum(position.reserves?.r2).toNumber()).toEqual(7e8); }); }); + +describe('renderPositions', () => { + it('works for plans with no exponent', () => { + const position = planToPosition({ + baseAsset: { + id: ASSET_A, + exponent: 0, + }, + quoteAsset: { + id: ASSET_B, + exponent: 0, + }, + price: 20.5, + feeBps: 100, + baseReserves: 1000, + quoteReserves: 2000, + }); + expect(position.phi?.component?.fee).toEqual(100); + expect(getPrice(position)).toEqual(20.5); + expect(pnum(position.reserves?.r1).toNumber()).toEqual(1000); + expect(pnum(position.reserves?.r2).toNumber()).toEqual(2000); + }); + + it('works for plans with identical exponent', () => { + const position = planToPosition({ + baseAsset: { + id: ASSET_A, + exponent: 6, + }, + quoteAsset: { + id: ASSET_B, + exponent: 6, + }, + price: 12.34, + feeBps: 100, + baseReserves: 5, + quoteReserves: 7, + }); + expect(position.phi?.component?.fee).toEqual(100); + expect(getPrice(position)).toEqual(12.34); + expect(pnum(position.reserves?.r1).toNumber()).toEqual(5e6); + expect(pnum(position.reserves?.r2).toNumber()).toEqual(7e6); + }); + + it('works for plans with different exponents', () => { + const position = planToPosition({ + baseAsset: { + id: ASSET_A, + exponent: 6, + }, + quoteAsset: { + id: ASSET_B, + exponent: 8, + }, + price: 12.34, + feeBps: 100, + baseReserves: 5, + quoteReserves: 7, + }); + expect(position.phi?.component?.fee).toEqual(100); + expect(getPrice(position) * 10 ** (6 - 8)).toEqual(12.34); + expect(pnum(position.reserves?.r1).toNumber()).toEqual(5e6); + expect(pnum(position.reserves?.r2).toNumber()).toEqual(7e8); + }); +}); diff --git a/src/shared/math/position.ts b/src/shared/math/position.ts index 4bf313d3..60cd2efe 100644 --- a/src/shared/math/position.ts +++ b/src/shared/math/position.ts @@ -10,6 +10,7 @@ import { pnum } from '@penumbra-zone/types/pnum'; import BigNumber from 'bignumber.js'; export const compareAssetId = (a: AssetId, b: AssetId): number => { + // The asset ids are serialized using LE, so this is checking the MSB. for (let i = 31; i >= 0; --i) { const a_i = a.inner[i] ?? -Infinity; const b_i = b.inner[i] ?? -Infinity;