From 675f106ea2b5823aed2b57888c087e2843a2ac32 Mon Sep 17 00:00:00 2001 From: Gregory Melnikov Date: Mon, 17 Aug 2026 00:45:59 +0300 Subject: [PATCH] feat: add positions list summary --- src/model/model.test-d.ts | 10 ++++ src/model/positions.schema.ts | 18 +++++++ src/model/positions.ts | 56 +++++++++++++++++++++ src/new-sdk/DECISIONS.md | 4 ++ src/new-sdk/positions/PositionsNamespace.ts | 14 ++++-- src/new-sdk/positions/mode.test-d.ts | 10 +++- src/new-sdk/positions/types.ts | 7 +-- src/offchain/positions/OffchainPositions.ts | 6 +-- 8 files changed, 115 insertions(+), 10 deletions(-) diff --git a/src/model/model.test-d.ts b/src/model/model.test-d.ts index 2420bbd32..09cc70ea2 100644 --- a/src/model/model.test-d.ts +++ b/src/model/model.test-d.ts @@ -112,6 +112,8 @@ import type { PositionFilter, PositionKey, PositionKind, + PositionList, + PositionSummary, RewardsPnL, StrategyPosition, StrategyPositionKey, @@ -127,7 +129,9 @@ import type { positionFilterSchema, positionKeySchema, positionKindSchema, + positionListSchema, positionSchema, + positionSummarySchema, rewardsPnLSchema, strategyPositionKeySchema, strategyPositionSchema, @@ -383,6 +387,12 @@ describe("model schemas match model types", () => { z.infer >().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); expectTypeOf< z.infer >().toEqualTypeOf(); diff --git a/src/model/positions.schema.ts b/src/model/positions.schema.ts index 452b8a017..54f70fe07 100644 --- a/src/model/positions.schema.ts +++ b/src/model/positions.schema.ts @@ -124,6 +124,24 @@ export const positionSchema = z.discriminatedUnion("kind", [ liquidationPositionSchema, ]); +/** + * {@link PositionSummary} + **/ +export const positionSummarySchema = z.object({ + currentApy: bpsSchema, + pnl: z.number(), + netValue: z.number(), + rewards: z.number(), +}); + +/** + * {@link PositionList} + **/ +export const positionListSchema = z.object({ + positions: z.array(positionSchema), + summary: positionSummarySchema.optional(), +}); + /** * {@link PositionFilter} **/ diff --git a/src/model/positions.ts b/src/model/positions.ts index db6aba822..71fd81470 100644 --- a/src/model/positions.ts +++ b/src/model/positions.ts @@ -237,6 +237,62 @@ export interface StrategyPosition { **/ export type Position = PoolPosition | StrategyPosition | LiquidationPosition; +/** + * Portfolio-level figures for the positions returned by one list read. + * + * Monetary fields are aggregated in USD because a wallet can hold positions + * denominated in different tokens and on different chains. Point rewards are + * intentionally excluded from {@link rewards}: unlike token rewards, they + * have no price and cannot be folded into one portfolio value. + **/ +export interface PositionSummary { + /** + * Current net APY of the returned positions, weighted by their USD net + * value, in basis points. + * + * @example `842` for 8.42% APY + **/ + currentApy: Bps; + /** + * Total PnL of the returned positions in USD, including priced rewards. + * + * @example `1250.5` + **/ + pnl: number; + /** + * Current net value of the returned positions in USD. + * + * @example `25000.5` + **/ + netValue: number; + /** + * Part of {@link pnl} earned as priced token rewards, in USD. Points are + * available in each position's {@link PnlBreakdown.rewards} instead. + * + * @example `125.5` + **/ + rewards: number; +} + +/** + * Positions returned by one list read together with their aggregate figures. + **/ +export interface PositionList { + /** + * Every position matching the list filter. + **/ + positions: Position[]; + /** + * Aggregate figures for {@link positions}. + * + * Absent in `onchain` mode: PnL and the complete current APY require the + * position history and incentive data owned by the backend. + * + * @mode offchain + **/ + summary?: PositionSummary; +} + /** * Canonical id of a position: the string used to match a row read from the * chain with the same row served by the backend. diff --git a/src/new-sdk/DECISIONS.md b/src/new-sdk/DECISIONS.md index 057310b16..15636ee74 100644 --- a/src/new-sdk/DECISIONS.md +++ b/src/new-sdk/DECISIONS.md @@ -19,6 +19,10 @@ namespace-agnostic — it records the rules every namespace follows. synchronous materialized collections. This matches how the backend works (detail endpoints, history, per-wallet queries) and how `LiquidationsService` is already written. +- **List payloads may name their rows.** A list that carries aggregate data returns a + serialisable wrapper rather than attaching properties to an array. Positions use + `PositionList` (`{ positions, summary? }`), where the backend-owned summary is absent + when only the chain answered. - **No entity classes.** POJOs plus canonical ids and explicit lookups. Entity graphs with navigation getters are not serialisable (Redux, SSR, `structuredClone`), are hard to merge two sources into, and force the whole graph to be resident. diff --git a/src/new-sdk/positions/PositionsNamespace.ts b/src/new-sdk/positions/PositionsNamespace.ts index 19692fd1f..d78c69694 100644 --- a/src/new-sdk/positions/PositionsNamespace.ts +++ b/src/new-sdk/positions/PositionsNamespace.ts @@ -8,6 +8,7 @@ import type { PositionHistoryMetric, PositionId, PositionKey, + PositionList, StrategyPositionHistoryMetric, StrategyPositionRef, } from "../../model/index.js"; @@ -65,14 +66,21 @@ export class PositionsNamespace public async list( wallet: Address, filter?: PositionFilter, - ): Promise> { - return this.readList( + ): Promise> { + return this.read( "list positions", async sdk => { const { result, meta } = await sdk.positions.list({ wallet, filter }); - return { value: result, chains: meta }; + return { value: { positions: result }, chains: meta }; }, api => api.positions.list(wallet, filter), + (onchain, offchain) => ({ + positions: this.mergeList( + onchain?.positions ?? [], + offchain?.positions ?? [], + ), + ...(offchain?.summary ? { summary: offchain.summary } : {}), + }), ); } diff --git a/src/new-sdk/positions/mode.test-d.ts b/src/new-sdk/positions/mode.test-d.ts index 16d02506b..e2b44f1db 100644 --- a/src/new-sdk/positions/mode.test-d.ts +++ b/src/new-sdk/positions/mode.test-d.ts @@ -2,10 +2,11 @@ import { describe, expectTypeOf, it } from "vitest"; import type { PoolPositionHistoryMetric, PoolPositionRef, + PositionList, StrategyPositionHistoryMetric, StrategyPositionRef, } from "../../model/index.js"; -import type { Mode } from "../types.js"; +import type { Mode, ReadResult } from "../types.js"; import type { Chart } from "../utils/index.js"; import type { Positions } from "./types.js"; @@ -23,6 +24,13 @@ describe("mode gates method existence", () => { expectTypeOf>().toHaveProperty("list"); }); + it("returns positions together with their optional summary", () => { + const positions = {} as Positions<"both">; + expectTypeOf( + positions.list("0x0000000000000000000000000000000000000000"), + ).resolves.toEqualTypeOf>(); + }); + it("history exists only where a backend does", () => { expectTypeOf>().toHaveProperty("history"); expectTypeOf>().toHaveProperty("history"); diff --git a/src/new-sdk/positions/types.ts b/src/new-sdk/positions/types.ts index c36d48dd8..1f8754651 100644 --- a/src/new-sdk/positions/types.ts +++ b/src/new-sdk/positions/types.ts @@ -2,8 +2,8 @@ import type { Address } from "viem"; import type { PoolPositionHistoryMetric, PoolPositionRef, - Position, PositionFilter, + PositionList, StrategyPositionHistoryMetric, StrategyPositionRef, } from "../../model/index.js"; @@ -16,7 +16,8 @@ import type { HistoryReader } from "../utils/history.js"; export interface PositionsBase { /** * Everything a wallet holds: its pool shares, its credit accounts and the - * delayed withdrawals it took over by liquidating, optionally narrowed. + * delayed withdrawals it took over by liquidating, optionally narrowed, + * together with the backend's aggregate summary when available. * * In `both` mode the two lists are unioned by canonical position id and * merged field-wise, with the chain winning any field both sources fill. @@ -24,7 +25,7 @@ export interface PositionsBase { list( wallet: Address, filter?: PositionFilter, - ): Promise>; + ): Promise>; } /** diff --git a/src/offchain/positions/OffchainPositions.ts b/src/offchain/positions/OffchainPositions.ts index e657d1784..7615f6c2e 100644 --- a/src/offchain/positions/OffchainPositions.ts +++ b/src/offchain/positions/OffchainPositions.ts @@ -4,7 +4,7 @@ import type { PositionHistoryMetric, PositionHistoryQuery, } from "../../model/history.js"; -import type { Position, PositionFilter } from "../../model/positions.js"; +import type { PositionFilter, PositionList } from "../../model/positions.js"; import { AbstractOffchainNamespace } from "../AbstractOffchainNamespace.js"; import type { GearboxAPIOptions, OffchainResult } from "../types.js"; @@ -24,12 +24,12 @@ export class OffchainPositions extends AbstractOffchainNamespace { public async list( wallet: Address, filter?: PositionFilter, - ): Promise> { + ): Promise> { this.logger?.debug( { wallet, filter }, "offchain positions list is not implemented, serving empty list", ); - return { result: [], meta: { status: "success" } }; + return { result: { positions: [] }, meta: { status: "success" } }; } /**