@pulsepairs/sdk 0.4.0 → 0.5.1
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 +44 -0
- package/dist/http.d.ts +19 -0
- package/dist/http.js +38 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/pnl.d.ts +150 -0
- package/dist/pnl.js +157 -0
- package/dist/ws.d.ts +23 -0
- package/dist/ws.js +89 -1
- package/package.json +15 -3
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,17 +93,103 @@ 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;
|
|
157
|
+
// Close any socket we're superseding before opening a new one. Without
|
|
158
|
+
// this, a rapid connect→(reconnect|reconnectMode) churn could leave the
|
|
159
|
+
// previous socket open — especially one still in CONNECTING, whose
|
|
160
|
+
// `onopen` fires after we've moved on.
|
|
161
|
+
if (this.ws) {
|
|
162
|
+
const stale = this.ws;
|
|
163
|
+
stale.onopen = stale.onmessage = stale.onclose = stale.onerror = null;
|
|
164
|
+
try {
|
|
165
|
+
stale.close();
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
/* ignore */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
99
171
|
const ws = new WebSocket(this.url);
|
|
100
172
|
this.ws = ws;
|
|
101
173
|
this.state = "idle";
|
|
102
174
|
ws.onopen = () => {
|
|
175
|
+
// Guard: if we were disconnected, or a newer socket has superseded this
|
|
176
|
+
// one, this socket is an orphan (it finished CONNECTING after we moved
|
|
177
|
+
// on). Close it instead of leaving it open and streaming.
|
|
178
|
+
if (this.closed || this.ws !== ws) {
|
|
179
|
+
try {
|
|
180
|
+
ws.close();
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
/* ignore */
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
103
187
|
this.reconnect = 0;
|
|
104
188
|
void this.startHandshake();
|
|
105
189
|
};
|
|
106
190
|
ws.onmessage = (ev) => {
|
|
191
|
+
if (this.ws !== ws)
|
|
192
|
+
return; // ignore frames from a superseded socket
|
|
107
193
|
let parsed;
|
|
108
194
|
try {
|
|
109
195
|
parsed = JSON.parse(String(ev.data));
|
|
@@ -114,7 +200,9 @@ export class UpDownWsClient {
|
|
|
114
200
|
void this.handleIncoming(parsed);
|
|
115
201
|
};
|
|
116
202
|
ws.onclose = () => {
|
|
117
|
-
|
|
203
|
+
// A superseded socket's close must not trigger a reconnect — the live
|
|
204
|
+
// `this.ws` is a different, healthy socket.
|
|
205
|
+
if (this.closed || this.ws !== ws)
|
|
118
206
|
return;
|
|
119
207
|
if (this.state === "unauthed-failure") {
|
|
120
208
|
// signAuth was rejected — don't reconnect into a hot loop.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pulsepairs/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Standalone SDK for the UpDown (PulsePairs) up/down prediction markets
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Standalone SDK for the UpDown (PulsePairs) up/down prediction markets — 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",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -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",
|