Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/model/model.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ import type {
PositionFilter,
PositionKey,
PositionKind,
PositionList,
PositionSummary,
RewardsPnL,
StrategyPosition,
StrategyPositionKey,
Expand All @@ -127,7 +129,9 @@ import type {
positionFilterSchema,
positionKeySchema,
positionKindSchema,
positionListSchema,
positionSchema,
positionSummarySchema,
rewardsPnLSchema,
strategyPositionKeySchema,
strategyPositionSchema,
Expand Down Expand Up @@ -383,6 +387,12 @@ describe("model schemas match model types", () => {
z.infer<typeof strategyPositionSchema>
>().toEqualTypeOf<StrategyPosition>();
expectTypeOf<z.infer<typeof positionSchema>>().toEqualTypeOf<Position>();
expectTypeOf<
z.infer<typeof positionSummarySchema>
>().toEqualTypeOf<PositionSummary>();
expectTypeOf<
z.infer<typeof positionListSchema>
>().toEqualTypeOf<PositionList>();
expectTypeOf<
z.infer<typeof positionFilterSchema>
>().toEqualTypeOf<PositionFilter>();
Expand Down
18 changes: 18 additions & 0 deletions src/model/positions.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
**/
Expand Down
56 changes: 56 additions & 0 deletions src/model/positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/new-sdk/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 11 additions & 3 deletions src/new-sdk/positions/PositionsNamespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
PositionHistoryMetric,
PositionId,
PositionKey,
PositionList,
StrategyPositionHistoryMetric,
StrategyPositionRef,
} from "../../model/index.js";
Expand Down Expand Up @@ -65,14 +66,21 @@ export class PositionsNamespace
public async list(
wallet: Address,
filter?: PositionFilter,
): Promise<ReadResult<Position[]>> {
return this.readList(
): Promise<ReadResult<PositionList>> {
return this.read<PositionList>(
"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 } : {}),
}),
);
}

Expand Down
10 changes: 9 additions & 1 deletion src/new-sdk/positions/mode.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -23,6 +24,13 @@ describe("mode gates method existence", () => {
expectTypeOf<Positions<"both">>().toHaveProperty("list");
});

it("returns positions together with their optional summary", () => {
const positions = {} as Positions<"both">;
expectTypeOf(
positions.list("0x0000000000000000000000000000000000000000"),
).resolves.toEqualTypeOf<ReadResult<PositionList>>();
});

it("history exists only where a backend does", () => {
expectTypeOf<Positions<"offchain">>().toHaveProperty("history");
expectTypeOf<Positions<"both">>().toHaveProperty("history");
Expand Down
7 changes: 4 additions & 3 deletions src/new-sdk/positions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { Address } from "viem";
import type {
PoolPositionHistoryMetric,
PoolPositionRef,
Position,
PositionFilter,
PositionList,
StrategyPositionHistoryMetric,
StrategyPositionRef,
} from "../../model/index.js";
Expand All @@ -16,15 +16,16 @@ 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.
**/
list(
wallet: Address,
filter?: PositionFilter,
): Promise<ReadResult<Position[]>>;
): Promise<ReadResult<PositionList>>;
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/offchain/positions/OffchainPositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -24,12 +24,12 @@ export class OffchainPositions extends AbstractOffchainNamespace {
public async list(
wallet: Address,
filter?: PositionFilter,
): Promise<OffchainResult<Position[]>> {
): Promise<OffchainResult<PositionList>> {
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" } };
}

/**
Expand Down
Loading