@pulsepairs/sdk 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -314,6 +314,47 @@ this cap across partial fills.
314
314
 
315
315
  ---
316
316
 
317
+ ## PnL — positions marked to market (v0.5.0)
318
+
319
+ The matcher hands you the *ingredients* for PnL (`shares` / `avgPrice` /
320
+ `costBasis` on positions, `winner` + order book on markets) but never a PnL
321
+ number. `getPnl` assembles one, marking each position:
322
+
323
+ - **resolved market** → won shares at full face (10000 bps), lost shares at 0;
324
+ - **live market** → the order-book **mid** for that option;
325
+ - **no book signal** → the position's own `avgPrice` (an honest 0-PnL mark,
326
+ flagged `markSource: "cost"` so you can tell it apart).
327
+
328
+ ```ts
329
+ const client = new UpDownHttpClient("https://api.demo-pulsepairs.rainwins.com");
330
+
331
+ const pnl = await client.getPnl(wallet, { includeFees: true });
332
+ console.log(pnl.unrealizedPnlUsdt, pnl.roiPct); // e.g. -3, -30
333
+ for (const p of pnl.positions) {
334
+ console.log(p.optionLabel, p.markSource, p.unrealizedPnlUsdt);
335
+ }
336
+ ```
337
+
338
+ All money math is BigInt-exact on **atomic** units and returned as decimal
339
+ strings (`costBasis`, `markValue`, `unrealizedPnl` — signed); the `*Usdt` /
340
+ `roiPct` floats are display-only. Cost basis is **fee-exclusive** (matching the
341
+ backend's fold), so fees are a separate `fees` line, reported not netted.
342
+
343
+ Already holding the data? The pure helpers are transport-free and unit-tested:
344
+
345
+ ```ts
346
+ import { positionPnl, computePortfolioPnl, midBps, feesPaidByWallet } from "@pulsepairs/sdk";
347
+
348
+ positionPnl(position, { markBps: 7000 }); // one position, explicit mark
349
+ positionPnl(position, { winner: 1 }); // settled: full-face / zero
350
+ computePortfolioPnl(positions, marketsByKey); // roll-up from market details
351
+ ```
352
+
353
+ Units: `shares` atomic (= USDT face at settlement), price/`avgPrice`/`markBps`
354
+ in **bps** (10000 = full face), fees atomic. `Option` is `1 = UP`, `2 = DOWN`.
355
+
356
+ ---
357
+
317
358
  ## API surface
318
359
 
319
360
  ```
@@ -324,6 +365,9 @@ EIP-712 ORDER_TYPES, CANCEL_TYPES, WS_AUTH_TYPES,
324
365
  findPairBySettlement, parseCompositeMarketKey
325
366
  Trade-math centsToBps, bpsToCents, parseStake, assertStakeBounds, feeAtomic,
326
367
  MIN_STAKE_ATOMIC, MAX_STAKE_ATOMIC
368
+ PnL (v0.5.0) UpDownHttpClient.getPnl, positionPnl, portfolioPnl,
369
+ computePortfolioPnl, markValueAtomic, midBps, markForOption,
370
+ feesPaidByWallet, atomicToUsdt, isResolvedWinner
327
371
  Approve (EOA) ensureSettlementAllowance, MAX_UINT256
328
372
  Account Kit UpDownAccountKitSigner (connect, onboard, approve, withdraw,
329
373
  signTypedDataBare, signWsAuth, isDeployed, grantSession,
package/dist/http.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ApiConfig, Balance, CancelOrderBody, MarketDetail, MarketListItem, OrderBookFull, OrdersResponse, PairSymbol, PostOrderBody, Position, Stats, Trade, Version } from "./types.js";
2
+ import { type PortfolioPnl } from "./pnl.js";
2
3
  export declare class UpDownHttpClient {
3
4
  private readonly baseUrl;
4
5
  constructor(baseUrl: string);
@@ -21,6 +22,24 @@ export declare class UpDownHttpClient {
21
22
  limit?: number;
22
23
  offset?: number;
23
24
  }): Promise<Trade[]>;
25
+ /**
26
+ * Portfolio PnL for a wallet: fetches open positions, marks each to its
27
+ * market (settled winner → full-face/zero, else order-book mid, else cost),
28
+ * and rolls the results up. One `/markets/:address` read per distinct market;
29
+ * a failed read degrades that position to a cost-basis mark rather than
30
+ * aborting the whole report.
31
+ *
32
+ * See `pnl.ts` for the unit contract and the standalone (transport-free)
33
+ * helpers if you already hold the positions/markets.
34
+ *
35
+ * @param opts.includeFees fetch `/trades/:wallet` and report total fees paid
36
+ * (default false — it costs an extra request; fees are reported, not netted).
37
+ * @param opts.usdtDecimals display-float decimals (default 6).
38
+ */
39
+ getPnl(wallet: string, opts?: {
40
+ includeFees?: boolean;
41
+ usdtDecimals?: number;
42
+ }): Promise<PortfolioPnl>;
24
43
  getOrders(wallet: string, opts?: {
25
44
  status?: string[];
26
45
  limit?: number;
package/dist/http.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { computePortfolioPnl, feesPaidByWallet } from "./pnl.js";
1
2
  function buildUrl(base, path, query) {
2
3
  const b = base.replace(/\/$/, "");
3
4
  const p = path.startsWith("/") ? path : `/${path}`;
@@ -75,6 +76,43 @@ export class UpDownHttpClient {
75
76
  }));
76
77
  return parseJson(res);
77
78
  }
79
+ /**
80
+ * Portfolio PnL for a wallet: fetches open positions, marks each to its
81
+ * market (settled winner → full-face/zero, else order-book mid, else cost),
82
+ * and rolls the results up. One `/markets/:address` read per distinct market;
83
+ * a failed read degrades that position to a cost-basis mark rather than
84
+ * aborting the whole report.
85
+ *
86
+ * See `pnl.ts` for the unit contract and the standalone (transport-free)
87
+ * helpers if you already hold the positions/markets.
88
+ *
89
+ * @param opts.includeFees fetch `/trades/:wallet` and report total fees paid
90
+ * (default false — it costs an extra request; fees are reported, not netted).
91
+ * @param opts.usdtDecimals display-float decimals (default 6).
92
+ */
93
+ async getPnl(wallet, opts) {
94
+ const positions = await this.getPositions(wallet);
95
+ if (positions.length === 0) {
96
+ return computePortfolioPnl([], new Map(), { usdtDecimals: opts?.usdtDecimals });
97
+ }
98
+ const marketKeys = Array.from(new Set(positions.map((p) => p.market)));
99
+ const details = await Promise.all(marketKeys.map((key) => this.getMarket(key).catch(() => null)));
100
+ const marketsByKey = new Map();
101
+ marketKeys.forEach((key, i) => {
102
+ const md = details[i];
103
+ if (md)
104
+ marketsByKey.set(key, { winner: md.winner, orderBook: md.orderBook });
105
+ });
106
+ let fees;
107
+ if (opts?.includeFees) {
108
+ const trades = await this.getTrades(wallet, { limit: 500 });
109
+ fees = feesPaidByWallet(wallet, trades);
110
+ }
111
+ return computePortfolioPnl(positions, marketsByKey, {
112
+ fees,
113
+ usdtDecimals: opts?.usdtDecimals,
114
+ });
115
+ }
78
116
  async getOrders(wallet, opts) {
79
117
  const params = new URLSearchParams();
80
118
  if (opts?.limit != null)
package/dist/index.d.ts CHANGED
@@ -6,4 +6,5 @@ export { ensureSettlementAllowance, MAX_UINT256, DEFAULT_APPROVAL_AMOUNT, type E
6
6
  export { readOnChainHolderShares, reconcileFills, reportedFromPositions, type OnChainHolderShares, type ShareReconciliation, type ReconcileStatus, type FillReconciliationReport, } from "./reconcile.js";
7
7
  export { UpDownAccountKitSigner, bareErc1271Signer, stripErc6492Wrapper, isErc6492Signature, type UpDownAccountKitConfig, type Eip1193Provider, type GrantSessionResult, type RawTypedDataSigner, } from "./accountKit.js";
8
8
  export { buildApproveSettlementTx, buildInstallOrderSessionTx, signWithOrderSession, type RawTransaction, type OrderSessionRecord, } from "./accountKit.js";
9
+ export { PRICE_BPS_SCALE, WIN_MARK_BPS, LOSE_MARK_BPS, DEFAULT_USDT_DECIMALS, atomicToUsdt, markValueAtomic, midBps, markForOption, isResolvedWinner, positionPnl, feesPaidByWallet, portfolioPnl, computePortfolioPnl, type MarkSource, type PositionPnl, type PortfolioPnl, type PositionPnlOptions, } from "./pnl.js";
9
10
  export { OrderType, OrderSide, Option, type ApiConfig, type Balance, type CancelOrderBody, type Eip712Domain, type MarketDetail, type MarketListItem, type OptionValue, type OrderBookFull, type OrderBookLevel, type OrderBookSide, type OrderRow, type OrderSideKey, type OrderSideValue, type OrderStatus, type OrderTypeKey, type OrderTypeValue, type OrdersResponse, type PairConfig, type PairSymbol, type PostOrderBody, type Position, type Stats, type Trade, type Version, } from "./types.js";
package/dist/index.js CHANGED
@@ -20,5 +20,9 @@ export { UpDownAccountKitSigner, bareErc1271Signer, stripErc6492Wrapper, isErc64
20
20
  // session): on-chain setup builders → rain's exact `RawTransaction` shape, plus
21
21
  // a local order-session signer for the off-chain sigs their session can't make.
22
22
  export { buildApproveSettlementTx, buildInstallOrderSessionTx, signWithOrderSession, } from "./accountKit.js";
23
+ // PnL — profit-and-loss math over positions/markets/trades. Pure, BigInt-exact,
24
+ // unit-faithful to the backend's own position fold (see pnl.ts for the unit
25
+ // contract). `UpDownHttpClient.getPnl` is the batteries-included wrapper.
26
+ export { PRICE_BPS_SCALE, WIN_MARK_BPS, LOSE_MARK_BPS, DEFAULT_USDT_DECIMALS, atomicToUsdt, markValueAtomic, midBps, markForOption, isResolvedWinner, positionPnl, feesPaidByWallet, portfolioPnl, computePortfolioPnl, } from "./pnl.js";
23
27
  // Types
24
28
  export { OrderType, OrderSide, Option, } from "./types.js";
package/dist/pnl.d.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * PnL — profit-and-loss math for a wallet's UpDown positions.
3
+ *
4
+ * The matcher exposes the *ingredients* for PnL (`GET /positions/:wallet` gives
5
+ * `shares` / `avgPrice` / `costBasis`; `GET /markets/:address` gives the live
6
+ * order book and the resolved `winner`) but never a PnL number itself. This
7
+ * module layers that number on top, in exactly the units the rest of the SDK
8
+ * and the backend already use — so a PnL here reconciles with the backend's own
9
+ * position accounting rather than drifting from it.
10
+ *
11
+ * ── Units (do not guess these; they are load-bearing) ───────────────────────
12
+ * • `shares` — atomic, same denomination as `Trade.amount`. Shares ≡ the
13
+ * USDT face value paid at settlement: a WINNING share redeems
14
+ * 1:1 for its face (`shares` atomic USDT), a losing share for 0.
15
+ * (`UpDownSettlement._redeemToAllowZero` pays `userShares`.)
16
+ * • `avgPrice` — basis points, 0..10000. 10000 bps = full face (1.0). This is
17
+ * what the backend's `avgPriceBps` returns.
18
+ * • `costBasis`— atomic USDT = `shares * avgPrice / 10000`, FEE-EXCLUSIVE
19
+ * (the backend's fold ignores fees; so do we, so the two agree).
20
+ * • order-book `price` — basis points, same scale as `avgPrice`.
21
+ * • fees (`platformFee`/`makerFee`) — atomic USDT.
22
+ *
23
+ * ── What we compute ─────────────────────────────────────────────────────────
24
+ * markValue = shares * markBps / 10000 (atomic USDT)
25
+ * unrealizedPnl = markValue - costBasis (atomic USDT, signed)
26
+ * where `markBps` is:
27
+ * • 10000 or 0 for a RESOLVED market (won → full face, lost → nothing).
28
+ * This is the *realized-at-settlement* value; `resolved: true` flags it.
29
+ * • the order-book mid for a live market (mark-to-market).
30
+ * • the position's own `avgPrice` as a last resort when there is no book
31
+ * signal — a deliberately honest 0-PnL mark, flagged `markSource: "cost"`.
32
+ *
33
+ * All money math is BigInt-exact on atomic units; the `*Usdt` / `roiPct` floats
34
+ * are derived once at the end for display only.
35
+ */
36
+ import type { MarketDetail, OrderBookSide, Position, Trade } from "./types.js";
37
+ /** Basis-points denominator shared by every price in the system. */
38
+ export declare const PRICE_BPS_SCALE = 10000n;
39
+ /** A winning share is worth its full face (10000 bps). */
40
+ export declare const WIN_MARK_BPS = 10000;
41
+ /** A losing share is worth nothing. */
42
+ export declare const LOSE_MARK_BPS = 0;
43
+ /** USDT atomic decimals on the demo/mainnet mock (1 USDT = 1e6). */
44
+ export declare const DEFAULT_USDT_DECIMALS = 6;
45
+ /** Where a position's mark price came from — surfaced so callers can tell a
46
+ * real mark-to-market from a cost-basis fallback. */
47
+ export type MarkSource = "settled" | "orderbook-mid" | "orderbook-bid" | "orderbook-ask" | "explicit" | "cost";
48
+ /** PnL for a single open position, all atomic strings BigInt-exact. */
49
+ export type PositionPnl = {
50
+ market: string;
51
+ option: number;
52
+ optionLabel: "UP" | "DOWN";
53
+ /** Atomic shares (echoed from the position). */
54
+ shares: string;
55
+ /** Atomic USDT cost, fee-exclusive (echoed from the position). */
56
+ costBasis: string;
57
+ /** Price used to value the shares, basis points. */
58
+ markBps: number;
59
+ markSource: MarkSource;
60
+ /** Atomic USDT the shares are worth at `markBps` (= shares * markBps / 10000). */
61
+ markValue: string;
62
+ /** Atomic USDT, signed: `markValue - costBasis`. Negative = loss. */
63
+ unrealizedPnl: string;
64
+ /** `unrealizedPnl` as a human USDT float (display only). */
65
+ unrealizedPnlUsdt: number;
66
+ /** `100 * unrealizedPnl / costBasis`, or null when costBasis is 0. */
67
+ roiPct: number | null;
68
+ /** True when `markBps` is a settled winner/loser value, not a live mark. */
69
+ resolved: boolean;
70
+ };
71
+ /** Portfolio roll-up across positions. */
72
+ export type PortfolioPnl = {
73
+ positions: PositionPnl[];
74
+ /** Atomic USDT sums across all positions. */
75
+ costBasis: string;
76
+ markValue: string;
77
+ unrealizedPnl: string;
78
+ /** Display floats. */
79
+ unrealizedPnlUsdt: number;
80
+ roiPct: number | null;
81
+ /** Atomic USDT of fees paid on the wallet's trades, when trades were supplied
82
+ * (see `feesPaidByWallet`); "0" otherwise. Not netted into `unrealizedPnl`
83
+ * — cost basis is fee-exclusive, so fees stay a separate, explicit line. */
84
+ fees: string;
85
+ };
86
+ /** Options controlling how one position is valued. */
87
+ export type PositionPnlOptions = {
88
+ /** Explicit mark, basis points. Ignored when `winner` marks the position as
89
+ * resolved. When omitted and not resolved, we fall back to `avgPrice`. */
90
+ markBps?: number;
91
+ /** Label for an explicit `markBps` (defaults to "explicit"). */
92
+ markSource?: MarkSource;
93
+ /** Resolved winner: 1 = UP, 2 = DOWN, 0/null = not resolved. When set to a
94
+ * real winner, the position is marked at full face (won) or 0 (lost). */
95
+ winner?: number | null;
96
+ /** USDT atomic decimals for the display float (default 6). */
97
+ usdtDecimals?: number;
98
+ };
99
+ /** Atomic → human float for display. Precision is ample for real stakes; never
100
+ * feed the result back into money math (use the atomic strings for that). */
101
+ export declare function atomicToUsdt(atomic: bigint | string, decimals?: number): number;
102
+ /** Value of `sharesAtomic` shares marked at `markBps`, atomic USDT. Uses the
103
+ * same floor-division as the backend's cost fold so the two never disagree by
104
+ * a rounding unit. */
105
+ export declare function markValueAtomic(sharesAtomic: bigint, markBps: number): bigint;
106
+ /**
107
+ * Mid price (basis points) for one side of a book, from its best bid/ask.
108
+ * Prefers the two-sided mid; degrades to the single resting quote; returns null
109
+ * for an empty book so the caller can choose a fallback rather than invent one.
110
+ */
111
+ export declare function midBps(side: OrderBookSide | undefined | null): {
112
+ bps: number;
113
+ source: MarkSource;
114
+ } | null;
115
+ /** The book side (`up`/`down`) an option trades on. UP = 1, DOWN = 2. */
116
+ export declare function markForOption(orderBook: MarketDetail["orderBook"] | undefined, option: number): {
117
+ bps: number;
118
+ source: MarkSource;
119
+ } | null;
120
+ /** True once a market carries a definitive winner (1 = UP, 2 = DOWN). The
121
+ * contract's `resolve` rejects any other value, so there is no draw/void. */
122
+ export declare function isResolvedWinner(winner: number | null | undefined): winner is 1 | 2;
123
+ /**
124
+ * PnL for a single position. Resolution wins over any supplied `markBps`: a
125
+ * settled market is worth exactly full-face or nothing, never a stale quote.
126
+ */
127
+ export declare function positionPnl(position: Position, opts?: PositionPnlOptions): PositionPnl;
128
+ /** Sum of `platformFee + makerFee` (atomic USDT) across every trade the wallet
129
+ * took part in. A gross tally — it does not attempt maker/taker attribution,
130
+ * so treat it as an upper bound on the wallet's own fee drag. */
131
+ export declare function feesPaidByWallet(wallet: string, trades: Trade[]): string;
132
+ /** Roll individual position PnLs up into a portfolio total. Pass `fees` (from
133
+ * `feesPaidByWallet`) to carry a fee line through; it is reported, not netted. */
134
+ export declare function portfolioPnl(items: PositionPnl[], opts?: {
135
+ fees?: string;
136
+ usdtDecimals?: number;
137
+ }): PortfolioPnl;
138
+ /**
139
+ * Given positions and the market details they live in, compute a full
140
+ * portfolio PnL. Transport-free: hand it data you already fetched. The HTTP
141
+ * client's `getPnl` is a thin wrapper that fetches the markets for you.
142
+ *
143
+ * `marketsByKey` maps a position's `market` key to its `MarketDetail`. A
144
+ * missing entry (or an empty book) degrades to a cost-basis mark for that
145
+ * position rather than dropping it.
146
+ */
147
+ export declare function computePortfolioPnl(positions: Position[], marketsByKey: Map<string, Pick<MarketDetail, "winner" | "orderBook">>, opts?: {
148
+ fees?: string;
149
+ usdtDecimals?: number;
150
+ }): PortfolioPnl;
package/dist/pnl.js ADDED
@@ -0,0 +1,157 @@
1
+ /** Basis-points denominator shared by every price in the system. */
2
+ export const PRICE_BPS_SCALE = 10000n;
3
+ /** A winning share is worth its full face (10000 bps). */
4
+ export const WIN_MARK_BPS = 10_000;
5
+ /** A losing share is worth nothing. */
6
+ export const LOSE_MARK_BPS = 0;
7
+ /** USDT atomic decimals on the demo/mainnet mock (1 USDT = 1e6). */
8
+ export const DEFAULT_USDT_DECIMALS = 6;
9
+ /** BigInt-exact `numer/denom` as a percentage float with 4-decimal resolution;
10
+ * null on a zero denominator. Sign-correct for negative numerators. */
11
+ function pctOf(numer, denom) {
12
+ if (denom === 0n)
13
+ return null;
14
+ // scaled = ratio * 1e6, kept in BigInt so huge atomic values stay exact;
15
+ // /1e4 turns the ratio into a percentage carrying 4 decimal places.
16
+ const scaled = (numer * 1000000n) / denom;
17
+ return Number(scaled) / 10_000;
18
+ }
19
+ /** Atomic → human float for display. Precision is ample for real stakes; never
20
+ * feed the result back into money math (use the atomic strings for that). */
21
+ export function atomicToUsdt(atomic, decimals = DEFAULT_USDT_DECIMALS) {
22
+ const a = typeof atomic === "bigint" ? atomic : BigInt(atomic);
23
+ return Number(a) / 10 ** decimals;
24
+ }
25
+ /** Value of `sharesAtomic` shares marked at `markBps`, atomic USDT. Uses the
26
+ * same floor-division as the backend's cost fold so the two never disagree by
27
+ * a rounding unit. */
28
+ export function markValueAtomic(sharesAtomic, markBps) {
29
+ return (sharesAtomic * BigInt(Math.trunc(markBps))) / PRICE_BPS_SCALE;
30
+ }
31
+ /**
32
+ * Mid price (basis points) for one side of a book, from its best bid/ask.
33
+ * Prefers the two-sided mid; degrades to the single resting quote; returns null
34
+ * for an empty book so the caller can choose a fallback rather than invent one.
35
+ */
36
+ export function midBps(side) {
37
+ const bid = side?.bestBid?.price;
38
+ const ask = side?.bestAsk?.price;
39
+ const hasBid = typeof bid === "number" && Number.isFinite(bid);
40
+ const hasAsk = typeof ask === "number" && Number.isFinite(ask);
41
+ if (hasBid && hasAsk)
42
+ return { bps: Math.round((bid + ask) / 2), source: "orderbook-mid" };
43
+ if (hasBid)
44
+ return { bps: bid, source: "orderbook-bid" };
45
+ if (hasAsk)
46
+ return { bps: ask, source: "orderbook-ask" };
47
+ return null;
48
+ }
49
+ /** The book side (`up`/`down`) an option trades on. UP = 1, DOWN = 2. */
50
+ export function markForOption(orderBook, option) {
51
+ if (!orderBook)
52
+ return null;
53
+ return midBps(option === 1 ? orderBook.up : orderBook.down);
54
+ }
55
+ /** True once a market carries a definitive winner (1 = UP, 2 = DOWN). The
56
+ * contract's `resolve` rejects any other value, so there is no draw/void. */
57
+ export function isResolvedWinner(winner) {
58
+ return winner === 1 || winner === 2;
59
+ }
60
+ /**
61
+ * PnL for a single position. Resolution wins over any supplied `markBps`: a
62
+ * settled market is worth exactly full-face or nothing, never a stale quote.
63
+ */
64
+ export function positionPnl(position, opts = {}) {
65
+ const decimals = opts.usdtDecimals ?? DEFAULT_USDT_DECIMALS;
66
+ const shares = BigInt(position.shares);
67
+ const costBasis = BigInt(position.costBasis);
68
+ let markBps;
69
+ let markSource;
70
+ let resolved = false;
71
+ if (isResolvedWinner(opts.winner)) {
72
+ resolved = true;
73
+ markSource = "settled";
74
+ markBps = position.option === opts.winner ? WIN_MARK_BPS : LOSE_MARK_BPS;
75
+ }
76
+ else if (typeof opts.markBps === "number" && Number.isFinite(opts.markBps)) {
77
+ markBps = opts.markBps;
78
+ markSource = opts.markSource ?? "explicit";
79
+ }
80
+ else {
81
+ // No signal: mark at cost so unrealized PnL is 0 rather than a fiction.
82
+ markBps = position.avgPrice;
83
+ markSource = "cost";
84
+ }
85
+ const markValue = markValueAtomic(shares, markBps);
86
+ const unrealizedPnl = markValue - costBasis;
87
+ return {
88
+ market: position.market,
89
+ option: position.option,
90
+ optionLabel: position.optionLabel,
91
+ shares: shares.toString(),
92
+ costBasis: costBasis.toString(),
93
+ markBps,
94
+ markSource,
95
+ markValue: markValue.toString(),
96
+ unrealizedPnl: unrealizedPnl.toString(),
97
+ unrealizedPnlUsdt: atomicToUsdt(unrealizedPnl, decimals),
98
+ roiPct: pctOf(unrealizedPnl, costBasis),
99
+ resolved,
100
+ };
101
+ }
102
+ /** Sum of `platformFee + makerFee` (atomic USDT) across every trade the wallet
103
+ * took part in. A gross tally — it does not attempt maker/taker attribution,
104
+ * so treat it as an upper bound on the wallet's own fee drag. */
105
+ export function feesPaidByWallet(wallet, trades) {
106
+ const w = wallet.toLowerCase();
107
+ let total = 0n;
108
+ for (const t of trades) {
109
+ if (t.buyer?.toLowerCase() !== w && t.seller?.toLowerCase() !== w)
110
+ continue;
111
+ total += BigInt(t.platformFee || "0") + BigInt(t.makerFee || "0");
112
+ }
113
+ return total.toString();
114
+ }
115
+ /** Roll individual position PnLs up into a portfolio total. Pass `fees` (from
116
+ * `feesPaidByWallet`) to carry a fee line through; it is reported, not netted. */
117
+ export function portfolioPnl(items, opts = {}) {
118
+ const decimals = opts.usdtDecimals ?? DEFAULT_USDT_DECIMALS;
119
+ let costBasis = 0n;
120
+ let markValue = 0n;
121
+ for (const it of items) {
122
+ costBasis += BigInt(it.costBasis);
123
+ markValue += BigInt(it.markValue);
124
+ }
125
+ const unrealizedPnl = markValue - costBasis;
126
+ return {
127
+ positions: items,
128
+ costBasis: costBasis.toString(),
129
+ markValue: markValue.toString(),
130
+ unrealizedPnl: unrealizedPnl.toString(),
131
+ unrealizedPnlUsdt: atomicToUsdt(unrealizedPnl, decimals),
132
+ roiPct: pctOf(unrealizedPnl, costBasis),
133
+ fees: opts.fees ?? "0",
134
+ };
135
+ }
136
+ /**
137
+ * Given positions and the market details they live in, compute a full
138
+ * portfolio PnL. Transport-free: hand it data you already fetched. The HTTP
139
+ * client's `getPnl` is a thin wrapper that fetches the markets for you.
140
+ *
141
+ * `marketsByKey` maps a position's `market` key to its `MarketDetail`. A
142
+ * missing entry (or an empty book) degrades to a cost-basis mark for that
143
+ * position rather than dropping it.
144
+ */
145
+ export function computePortfolioPnl(positions, marketsByKey, opts = {}) {
146
+ const items = positions.map((pos) => {
147
+ const md = marketsByKey.get(pos.market);
148
+ const mark = md ? markForOption(md.orderBook, pos.option) : null;
149
+ return positionPnl(pos, {
150
+ winner: md?.winner ?? null,
151
+ markBps: mark?.bps,
152
+ markSource: mark?.source,
153
+ usdtDecimals: opts.usdtDecimals,
154
+ });
155
+ });
156
+ return portfolioPnl(items, opts);
157
+ }
package/dist/ws.d.ts CHANGED
@@ -110,6 +110,29 @@ export declare class UpDownWsClient {
110
110
  */
111
111
  connect(subscribe: SubscribePayload): void;
112
112
  disconnect(): void;
113
+ /**
114
+ * Add channels to the live subscription set on an already-connected
115
+ * client, without tearing down the socket. Sends `{type:'subscribe',
116
+ * channels}` immediately if the socket is OPEN; the channels are also
117
+ * merged into the connect-time set so they are replayed automatically
118
+ * after any reconnect (or after the auth handshake, on an authed
119
+ * client). Safe to call before the socket opens.
120
+ *
121
+ * Public channels (`markets`, `orderbook:*`, `trades:*`) work on any
122
+ * client. Private channels (`orders:*`, `balance:*`) still require the
123
+ * client to have been created via `connectAuthed` — the server drops
124
+ * private subs from an unauthenticated connection.
125
+ */
126
+ subscribe(channels: string[]): void;
127
+ /**
128
+ * Remove channels from the live subscription set. Sends
129
+ * `{type:'unsubscribe', channels}` if OPEN and drops them from the
130
+ * replay set so a later reconnect does not re-subscribe them. Channels
131
+ * not currently subscribed are ignored.
132
+ */
133
+ unsubscribe(channels: string[]): void;
134
+ /** The mutable connect-time channel array for the active mode. */
135
+ private modeChannels;
113
136
  private openSocket;
114
137
  private startHandshake;
115
138
  private runSignAndSendAuth;
package/dist/ws.js CHANGED
@@ -93,6 +93,64 @@ export class UpDownWsClient {
93
93
  this.ws?.close();
94
94
  this.ws = null;
95
95
  }
96
+ /**
97
+ * Add channels to the live subscription set on an already-connected
98
+ * client, without tearing down the socket. Sends `{type:'subscribe',
99
+ * channels}` immediately if the socket is OPEN; the channels are also
100
+ * merged into the connect-time set so they are replayed automatically
101
+ * after any reconnect (or after the auth handshake, on an authed
102
+ * client). Safe to call before the socket opens.
103
+ *
104
+ * Public channels (`markets`, `orderbook:*`, `trades:*`) work on any
105
+ * client. Private channels (`orders:*`, `balance:*`) still require the
106
+ * client to have been created via `connectAuthed` — the server drops
107
+ * private subs from an unauthenticated connection.
108
+ */
109
+ subscribe(channels) {
110
+ if (!this.mode || channels.length === 0)
111
+ return;
112
+ const set = this.modeChannels();
113
+ const added = [];
114
+ for (const ch of channels) {
115
+ if (!set.includes(ch)) {
116
+ set.push(ch);
117
+ added.push(ch);
118
+ }
119
+ }
120
+ if (added.length === 0)
121
+ return;
122
+ this.send({ type: "subscribe", channels: added });
123
+ }
124
+ /**
125
+ * Remove channels from the live subscription set. Sends
126
+ * `{type:'unsubscribe', channels}` if OPEN and drops them from the
127
+ * replay set so a later reconnect does not re-subscribe them. Channels
128
+ * not currently subscribed are ignored.
129
+ */
130
+ unsubscribe(channels) {
131
+ if (!this.mode || channels.length === 0)
132
+ return;
133
+ const set = this.modeChannels();
134
+ const removed = [];
135
+ for (const ch of channels) {
136
+ const i = set.indexOf(ch);
137
+ if (i !== -1) {
138
+ set.splice(i, 1);
139
+ removed.push(ch);
140
+ }
141
+ }
142
+ if (removed.length === 0)
143
+ return;
144
+ this.send({ type: "unsubscribe", channels: removed });
145
+ }
146
+ /** The mutable connect-time channel array for the active mode. */
147
+ modeChannels() {
148
+ if (!this.mode)
149
+ return [];
150
+ return this.mode.kind === "public"
151
+ ? this.mode.channels
152
+ : this.mode.opts.channels;
153
+ }
96
154
  openSocket() {
97
155
  if (this.closed)
98
156
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulsepairs/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Standalone SDK for the UpDown (PulsePairs) up/down prediction markets \u2014 matcher REST/WS client, EIP-712 order signing, trade-math, and Alchemy Account Kit (smart-account) order signing for rain.trade integration.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -11,6 +11,18 @@
11
11
  ".": {
12
12
  "types": "./dist/index.d.ts",
13
13
  "import": "./dist/index.js"
14
+ },
15
+ "./ws": {
16
+ "types": "./dist/ws.d.ts",
17
+ "import": "./dist/ws.js"
18
+ },
19
+ "./eip712": {
20
+ "types": "./dist/eip712.d.ts",
21
+ "import": "./dist/eip712.js"
22
+ },
23
+ "./http": {
24
+ "types": "./dist/http.d.ts",
25
+ "import": "./dist/http.js"
14
26
  }
15
27
  },
16
28
  "files": [
@@ -21,7 +33,7 @@
21
33
  "scripts": {
22
34
  "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
23
35
  "build": "npm run clean && tsc",
24
- "test": "node scripts/eip712-golden.test.mjs && node scripts/rawtx-tier.test.mjs && node scripts/hot-key-safety.test.mjs && node scripts/reconcile.test.mjs",
36
+ "test": "node scripts/eip712-golden.test.mjs && node scripts/rawtx-tier.test.mjs && node scripts/hot-key-safety.test.mjs && node scripts/reconcile.test.mjs && node scripts/pnl.test.mjs && node scripts/ws-subscribe.test.mjs",
25
37
  "prepublishOnly": "npm run build && npm test",
26
38
  "example:taker": "npx tsx examples/simple-taker.ts",
27
39
  "example:maker": "npx tsx examples/simple-maker.ts",