@pear-protocol/utils 0.8.1 → 1.0.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.
@@ -1,6 +1,6 @@
1
1
  import type { InstrumentId } from '@pear-protocol/types';
2
2
  import BigNumber from 'bignumber.js';
3
- import type { Fill } from '../fills/fill.types';
3
+ import type { AssetPositionBasis, AssetReduction } from './transition.types';
4
4
  export type AssetUnrealizedPnL = {
5
5
  id: InstrumentId;
6
6
  signedQuantity: BigNumber;
@@ -11,27 +11,14 @@ export type AssetUnrealizedPnL = {
11
11
  /**
12
12
  * One reduction of the position, attributed to the fill that made it.
13
13
  *
14
- * A discriminated union rather than an optional gross, because whether a close could
15
- * be valued is a property of the replay, not of the fill: a caller cannot tell a
16
- * priced close from an unpriceable one by looking at the fill alone. `reduceOnly` is
17
- * an order intent and says nothing about what actually closed, and a 0-price synthetic
18
- * can equally well OPEN a position — so the walk is the only thing that knows, and it
19
- * says so here instead of leaving the answer to be guessed downstream.
14
+ * The reduction itself is exactly what the transition walk reports — the aggregate adds
15
+ * only the fill it came from, so the two can never describe a close differently.
20
16
  *
21
17
  * The walk is time-agnostic (a Fill carries no timestamp), so callers that need to
22
18
  * filter by time resolve fillId against their own fill records.
23
19
  */
24
- export type AssetClosure = {
25
- kind: 'PRICED';
20
+ export type AssetClosure = AssetReduction & {
26
21
  fillId: string;
27
- gross: BigNumber;
28
- entryPrice: BigNumber;
29
- exitPrice: BigNumber;
30
- closedQuantity: BigNumber;
31
- } | {
32
- kind: 'UNPRICED';
33
- fillId: string;
34
- closedQuantity: BigNumber;
35
22
  };
36
23
  export type AssetRealizedPnL = {
37
24
  id: InstrumentId;
@@ -41,9 +28,17 @@ export type AssetRealizedPnL = {
41
28
  exitQuantity: BigNumber;
42
29
  exitNotional: BigNumber;
43
30
  exitVwap: BigNumber | null;
31
+ residualQuantity: BigNumber;
32
+ residualBasis: AssetPositionBasis;
44
33
  totalFees: BigNumber;
45
34
  tradeFees: BigNumber;
46
35
  pearFees: BigNumber;
47
36
  closures: AssetClosure[];
48
37
  };
49
- export type DeployedCapitalFill = Pick<Fill, 'symbol' | 'side' | 'price' | 'quantity'>;
38
+ /** Whether the fill ledger provides a complete deployed-capital denominator. */
39
+ export type DeployedCapitalState = {
40
+ kind: 'MEASURED';
41
+ capital: BigNumber;
42
+ } | {
43
+ kind: 'UNPRICED';
44
+ };
@@ -1,7 +1,8 @@
1
1
  import type { InstrumentId } from '@pear-protocol/types';
2
2
  import BigNumber from 'bignumber.js';
3
3
  import type { Fill } from '../fills/fill.types';
4
- import type { AssetRealizedPnL, AssetUnrealizedPnL, DeployedCapitalFill } from './asset.types';
4
+ import type { AssetRealizedPnL, AssetUnrealizedPnL, DeployedCapitalState } from './asset.types';
5
+ import type { AssetFillTransition, AssetTransitionFill } from './transition.types';
5
6
  type ExposureMap = Record<InstrumentId, string>;
6
7
  type PriceMap = Record<InstrumentId, string>;
7
8
  /**
@@ -37,6 +38,41 @@ export declare function computeAssetUndeterminedSizes(fills: Fill[]): Record<Ins
37
38
  * Formula: Σ(signedQty × (currentPrice − entryPrice))
38
39
  */
39
40
  export declare function computeAssetUnrealizedPnLs(exposureMap: ExposureMap, priceMap: PriceMap, fills: Fill[]): AssetUnrealizedPnL[];
41
+ /** One replay of a position's ledger, answering both questions the walk can answer. */
42
+ export type PositionLedgerWalk = {
43
+ assets: AssetRealizedPnL[];
44
+ capital: DeployedCapitalState;
45
+ };
46
+ /**
47
+ * Replay one position's complete ledger once, deriving realized PnL and deployed capital.
48
+ *
49
+ * The two answers were previously two replays of the same fills — computeAssetRealizedPnLs
50
+ * per asset, then computeDeployedCapitalState across the basket — because they accumulate
51
+ * differently, not because they read different transitions. They read exactly the same ones:
52
+ * the transition walk keys its running state by symbol, so replaying an asset's fills alone
53
+ * and replaying them inside the basket produce an identical transition for every fill.
54
+ *
55
+ * Folding them also removes the possibility that the position's realized PnL and the capital
56
+ * it is measured against ever describe different replays of the same ledger.
57
+ *
58
+ * Chronological order across legs is required, as it is for the capital walk alone: the peak
59
+ * is the most every leg ever committed at one time, which only a cross-leg sequence can say.
60
+ *
61
+ * @param fills - One position's complete ledger, chronological across every leg.
62
+ * @returns The per-asset realized rows and the basket's capital state.
63
+ */
64
+ export declare function walkPositionLedger(fills: Fill[]): PositionLedgerWalk;
65
+ /**
66
+ * The same walk, over transitions a caller has already computed.
67
+ *
68
+ * Exposed because the transition stream is the expensive part and more than one consumer
69
+ * reads it: a caller that needs both this and its own per-transition output can compute the
70
+ * stream once and hand it to both, rather than replaying the ledger for each.
71
+ *
72
+ * @param transitions - One position's transitions, in the ledger's own order.
73
+ * @returns The per-asset realized rows and the basket's capital state.
74
+ */
75
+ export declare function walkPositionTransitions(transitions: readonly AssetFillTransition[]): PositionLedgerWalk;
40
76
  /**
41
77
  * Compute realized PnL for an entire position.
42
78
  *
@@ -46,6 +82,13 @@ export declare function computeAssetRealizedPnLs(fills: Fill[]): AssetRealizedPn
46
82
  /**
47
83
  * The most cash the position's selected legs had committed at one time.
48
84
  *
85
+ * Returned as a state rather than a number: MEASURED carries that peak, and UNPRICED
86
+ * refuses the measurement outright once a zero-price fill has opened selected exposure —
87
+ * an open, an increase, or the opening half of a flip. Cash committed at an unknown price
88
+ * can only raise the true peak, so a peak built without it would err the return large,
89
+ * the unsafe direction, and no number is offered instead. A zero-price reduction stays
90
+ * measurable, because what a close releases depends on the basis held, not the exit price.
91
+ *
49
92
  * Cash, not market value: each opening fill commits what it actually paid, at its own
50
93
  * execution price, and each closing fill releases the share of that commitment the quantity
51
94
  * it closed was holding. No price beyond the fills' own is read, which matters because there
@@ -82,5 +125,11 @@ export declare function computeAssetRealizedPnLs(fills: Fill[]): AssetRealizedPn
82
125
  * That only ever adds to the peak, so the denominator errs large and the return it produces
83
126
  * errs small — the safe direction for a public board.
84
127
  */
85
- export declare function computeDeployedCapital(fills: DeployedCapitalFill[], assets: Set<string> | undefined): BigNumber;
128
+ export declare function computeDeployedCapitalState(fills: AssetTransitionFill[], assets: Set<string> | undefined): DeployedCapitalState;
129
+ /**
130
+ * The measurable peak deployed capital, or zero when the ledger contains unpriced basis.
131
+ *
132
+ * @deprecated Use computeDeployedCapitalState when zero and unpriced need different handling.
133
+ */
134
+ export declare function computeDeployedCapital(fills: AssetTransitionFill[], assets: Set<string> | undefined): BigNumber;
86
135
  export {};
@@ -1,2 +1,4 @@
1
- export type { AssetClosure, AssetRealizedPnL, AssetUnrealizedPnL, DeployedCapitalFill } from './asset.types';
1
+ export type { AssetClosure, AssetRealizedPnL, AssetUnrealizedPnL, DeployedCapitalState, } from './asset.types';
2
2
  export * from './compute';
3
+ export type { AssetFillTransition, AssetPositionBasis, AssetPositionState, AssetReduction, AssetTransitionFill, } from './transition.types';
4
+ export * from './transitions';
@@ -0,0 +1,68 @@
1
+ /** Public contracts for per-fill exposure and cost-basis transitions. */
2
+ import type BigNumber from 'bignumber.js';
3
+ import type { Fill } from '../fills/fill.types';
4
+ /** The fill fields required to derive exposure and cost-basis transitions. */
5
+ export type AssetTransitionFill = Pick<Fill, 'symbol' | 'side' | 'price' | 'quantity'>;
6
+ /** Whether an asset is flat, has a known average basis, or carries unpriceable basis. */
7
+ export type AssetPositionBasis = {
8
+ kind: 'FLAT';
9
+ } | {
10
+ kind: 'PRICED';
11
+ entryPrice: BigNumber;
12
+ } | {
13
+ kind: 'UNPRICED';
14
+ };
15
+ /** The signed exposure and cost-basis state immediately beside one fill. */
16
+ export type AssetPositionState = {
17
+ signedQuantity: BigNumber;
18
+ basis: AssetPositionBasis;
19
+ };
20
+ /**
21
+ * The priced or unpriceable portion of a fill that reduced existing exposure.
22
+ *
23
+ * A discriminated union rather than an optional grossPnl, because whether a close could
24
+ * be valued is a property of the replay, not of the fill: a caller cannot tell a priced
25
+ * close from an unpriceable one by looking at the fill alone. `reduceOnly` is an order
26
+ * intent and says nothing about what actually closed, and a 0-price synthetic can equally
27
+ * well OPEN a position — so the walk is the only thing that knows, and it says so here
28
+ * instead of leaving the answer to be guessed downstream.
29
+ */
30
+ export type AssetReduction = {
31
+ kind: 'PRICED';
32
+ quantity: BigNumber;
33
+ entryPrice: BigNumber;
34
+ exitPrice: BigNumber;
35
+ grossPnl: BigNumber;
36
+ } | {
37
+ kind: 'UNPRICED';
38
+ quantity: BigNumber;
39
+ };
40
+ type AssetFillTransitionBase<TFill extends AssetTransitionFill> = {
41
+ fill: TFill;
42
+ positionBefore: AssetPositionState;
43
+ positionAfter: AssetPositionState;
44
+ };
45
+ /**
46
+ * One fill's complete effect on its asset's exposure and basis.
47
+ *
48
+ * The original generic fill is retained so callers keep metadata such as timestamp,
49
+ * provenance, and settlement status without resolving a fill id after the walk.
50
+ */
51
+ export type AssetFillTransition<TFill extends AssetTransitionFill = Fill> = (AssetFillTransitionBase<TFill> & {
52
+ kind: 'OPEN';
53
+ openedQuantity: BigNumber;
54
+ }) | (AssetFillTransitionBase<TFill> & {
55
+ kind: 'INCREASE';
56
+ increasedQuantity: BigNumber;
57
+ }) | (AssetFillTransitionBase<TFill> & {
58
+ kind: 'REDUCE';
59
+ reduction: AssetReduction;
60
+ }) | (AssetFillTransitionBase<TFill> & {
61
+ kind: 'CLOSE';
62
+ reduction: AssetReduction;
63
+ }) | (AssetFillTransitionBase<TFill> & {
64
+ kind: 'FLIP';
65
+ reduction: AssetReduction;
66
+ openedQuantity: BigNumber;
67
+ });
68
+ export {};
@@ -0,0 +1,13 @@
1
+ import type { AssetFillTransition, AssetTransitionFill } from './transition.types';
2
+ /**
3
+ * Replay a complete chronological fill ledger into one transition per input fill.
4
+ *
5
+ * Asset state is independent while output order is identical to input order. Equal-time
6
+ * ordering is therefore the caller's responsibility and is preserved without re-sorting.
7
+ *
8
+ * Preconditions, not enforced: every fill carries a positive finite quantity and a
9
+ * non-negative finite price (price "0" marks an unpriceable synthetic). A zero-quantity
10
+ * fill is not a valid ledger entry and yields a transition whose kind does not describe
11
+ * its (absent) exposure effect.
12
+ */
13
+ export declare function computeFillTransitions<TFill extends AssetTransitionFill>(fills: readonly TFill[]): AssetFillTransition<TFill>[];
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Bucket fills under the position that owns them, preserving the caller's order.
3
+ *
4
+ * Two walks depend on that order, so this must never re-sort. The realized-PnL walk reads
5
+ * each asset's sequence on its own; the deployed-capital walk spans every leg at once, and
6
+ * so needs the sequence chronological ACROSS legs — the stricter of the two requirements,
7
+ * and the one a caller is likeliest to break.
8
+ *
9
+ * Every history that replays a ledger — portfolio, leaderboard, closed and open positions,
10
+ * trade history — partitions it here first, so the contract is stated once for all of them.
11
+ */
12
+ export declare function groupFillsByPosition<Fill extends {
13
+ positionId: string;
14
+ }>(fills: readonly Fill[]): Map<string, Fill[]>;
15
+ /**
16
+ * Bucket one position's fills under the asset each traded, preserving the caller's order.
17
+ *
18
+ * The per-leg readers walk a bucket in sequence — entry VWAP prices the opens in the order
19
+ * they happened, and a leg's side falls back to its first fill — so this must never re-sort
20
+ * either. An asset the walk reported but the ledger never filled has no bucket at all,
21
+ * which is a real state, not a lookup miss.
22
+ */
23
+ export declare function groupFillsByAsset<Fill extends {
24
+ symbol: string;
25
+ }>(fills: readonly Fill[]): Map<string, Fill[]>;
@@ -1,2 +1,3 @@
1
1
  export * from './compute';
2
+ export * from './group';
2
3
  export * from './realized';
package/dist/index.js CHANGED
@@ -142,6 +142,192 @@ function computeEntryPriceForAsset(fills) {
142
142
  }
143
143
  __name(computeEntryPriceForAsset, "computeEntryPriceForAsset");
144
144
 
145
+ // src/asset/transitions.ts
146
+ function computeFillTransitions(fills) {
147
+ const stateByAsset = /* @__PURE__ */ new Map();
148
+ const transitions = [];
149
+ for (const fill of fills) {
150
+ const currentState = stateByAsset.get(fill.symbol) ?? {
151
+ signedQuantity: ZERO,
152
+ entryPrice: ZERO,
153
+ basisUnpriced: false
154
+ };
155
+ const result = transitionFill(currentState, fill);
156
+ transitions.push(result.transition);
157
+ stateByAsset.set(fill.symbol, result.nextState);
158
+ }
159
+ return transitions;
160
+ }
161
+ __name(computeFillTransitions, "computeFillTransitions");
162
+ function transitionFill(currentState, fill) {
163
+ const price = parse(fill.price);
164
+ const quantity = parse(fill.quantity);
165
+ const signedQuantity = signedBySide(quantity, fill.side);
166
+ const nextSignedQuantity = add(currentState.signedQuantity, signedQuantity);
167
+ const kind = classifyTransition(currentState.signedQuantity, signedQuantity, nextSignedQuantity);
168
+ const nextState = advanceState(currentState, kind, price, quantity, nextSignedQuantity);
169
+ const positionBefore = toPositionState(currentState);
170
+ const positionAfter = toPositionState(nextState);
171
+ switch (kind) {
172
+ case "OPEN":
173
+ return {
174
+ nextState,
175
+ transition: {
176
+ kind,
177
+ fill,
178
+ positionBefore,
179
+ positionAfter,
180
+ openedQuantity: quantity
181
+ }
182
+ };
183
+ case "INCREASE":
184
+ return {
185
+ nextState,
186
+ transition: {
187
+ kind,
188
+ fill,
189
+ positionBefore,
190
+ positionAfter,
191
+ increasedQuantity: quantity
192
+ }
193
+ };
194
+ case "REDUCE": {
195
+ const reduction = buildReduction(currentState, price, quantity);
196
+ return {
197
+ nextState,
198
+ transition: {
199
+ kind,
200
+ fill,
201
+ positionBefore,
202
+ positionAfter,
203
+ reduction
204
+ }
205
+ };
206
+ }
207
+ case "CLOSE": {
208
+ const reduction = buildReduction(currentState, price, quantity);
209
+ return {
210
+ nextState,
211
+ transition: {
212
+ kind,
213
+ fill,
214
+ positionBefore,
215
+ positionAfter,
216
+ reduction
217
+ }
218
+ };
219
+ }
220
+ case "FLIP": {
221
+ const reduction = buildReduction(currentState, price, quantity);
222
+ return {
223
+ nextState,
224
+ transition: {
225
+ kind,
226
+ fill,
227
+ positionBefore,
228
+ positionAfter,
229
+ reduction,
230
+ openedQuantity: nextSignedQuantity.abs()
231
+ }
232
+ };
233
+ }
234
+ }
235
+ }
236
+ __name(transitionFill, "transitionFill");
237
+ function classifyTransition(currentQuantity, fillQuantity, nextQuantity) {
238
+ if (isZero(currentQuantity)) {
239
+ return "OPEN";
240
+ }
241
+ if (isPositive(currentQuantity) === isPositive(fillQuantity)) {
242
+ return "INCREASE";
243
+ }
244
+ if (isZero(nextQuantity)) {
245
+ return "CLOSE";
246
+ }
247
+ if (isPositive(nextQuantity) !== isPositive(currentQuantity)) {
248
+ return "FLIP";
249
+ }
250
+ return "REDUCE";
251
+ }
252
+ __name(classifyTransition, "classifyTransition");
253
+ function buildReduction(currentState, exitPrice, fillQuantity) {
254
+ const quantity = min(currentState.signedQuantity.abs(), fillQuantity);
255
+ if (currentState.basisUnpriced || isZero(exitPrice)) {
256
+ return {
257
+ kind: "UNPRICED",
258
+ quantity
259
+ };
260
+ }
261
+ const grossPnl = isPositive(currentState.signedQuantity) ? multiply(quantity, subtract(exitPrice, currentState.entryPrice)) : multiply(quantity, subtract(currentState.entryPrice, exitPrice));
262
+ return {
263
+ kind: "PRICED",
264
+ quantity,
265
+ entryPrice: currentState.entryPrice,
266
+ exitPrice,
267
+ grossPnl
268
+ };
269
+ }
270
+ __name(buildReduction, "buildReduction");
271
+ function advanceState(currentState, kind, price, fillQuantity, nextSignedQuantity) {
272
+ switch (kind) {
273
+ case "OPEN":
274
+ case "FLIP":
275
+ return {
276
+ signedQuantity: nextSignedQuantity,
277
+ entryPrice: price,
278
+ basisUnpriced: isZero(price)
279
+ };
280
+ case "INCREASE": {
281
+ const entryNotional = multiply(currentState.entryPrice, currentState.signedQuantity.abs());
282
+ const addedNotional = multiply(price, fillQuantity);
283
+ const entryPrice = divide(add(entryNotional, addedNotional), nextSignedQuantity.abs());
284
+ return {
285
+ signedQuantity: nextSignedQuantity,
286
+ entryPrice,
287
+ basisUnpriced: currentState.basisUnpriced || isZero(price)
288
+ };
289
+ }
290
+ case "REDUCE":
291
+ return {
292
+ ...currentState,
293
+ signedQuantity: nextSignedQuantity
294
+ };
295
+ case "CLOSE":
296
+ return {
297
+ signedQuantity: ZERO,
298
+ entryPrice: ZERO,
299
+ basisUnpriced: false
300
+ };
301
+ }
302
+ }
303
+ __name(advanceState, "advanceState");
304
+ function toPositionState(state) {
305
+ if (isZero(state.signedQuantity)) {
306
+ return {
307
+ signedQuantity: state.signedQuantity,
308
+ basis: {
309
+ kind: "FLAT"
310
+ }
311
+ };
312
+ }
313
+ if (state.basisUnpriced) {
314
+ return {
315
+ signedQuantity: state.signedQuantity,
316
+ basis: {
317
+ kind: "UNPRICED"
318
+ }
319
+ };
320
+ }
321
+ return {
322
+ signedQuantity: state.signedQuantity,
323
+ basis: {
324
+ kind: "PRICED",
325
+ entryPrice: state.entryPrice
326
+ }
327
+ };
328
+ }
329
+ __name(toPositionState, "toPositionState");
330
+
145
331
  // src/asset/compute.ts
146
332
  function computeAssetEntryNotional(signedQuantity, fills) {
147
333
  if (signedQuantity.isZero()) {
@@ -207,6 +393,49 @@ function computeAssetUnrealizedPnLs(exposureMap, priceMap, fills) {
207
393
  return result;
208
394
  }
209
395
  __name(computeAssetUnrealizedPnLs, "computeAssetUnrealizedPnLs");
396
+ function walkPositionLedger(fills) {
397
+ return walkPositionTransitions(computeFillTransitions(fills));
398
+ }
399
+ __name(walkPositionLedger, "walkPositionLedger");
400
+ function walkPositionTransitions(transitions) {
401
+ const realizedByAsset = /* @__PURE__ */ new Map();
402
+ const capitalByAsset = /* @__PURE__ */ new Map();
403
+ let committed = ZERO;
404
+ let peak = ZERO;
405
+ let capitalUnpriced = false;
406
+ for (const transition of transitions) {
407
+ const { fill, positionAfter } = transition;
408
+ const realized = realizedByAsset.get(fill.symbol) ?? createAssetRealizedAccumulator();
409
+ accumulateAssetRealization(realized, transition);
410
+ realizedByAsset.set(fill.symbol, realized);
411
+ if (positionAfter.basis.kind === "UNPRICED") {
412
+ capitalUnpriced = true;
413
+ }
414
+ if (capitalUnpriced) {
415
+ continue;
416
+ }
417
+ const previousCapital = capitalByAsset.get(fill.symbol) ?? ZERO;
418
+ const nextCapital = capitalAfterTransition(transition, previousCapital);
419
+ committed = add(subtract(committed, previousCapital), nextCapital);
420
+ capitalByAsset.set(fill.symbol, nextCapital);
421
+ if (isGreaterThan(committed, peak)) {
422
+ peak = committed;
423
+ }
424
+ }
425
+ const assets = [
426
+ ...realizedByAsset
427
+ ].map(([id, accumulator]) => finalizeAssetRealization(id, accumulator));
428
+ return {
429
+ assets,
430
+ capital: capitalUnpriced ? {
431
+ kind: "UNPRICED"
432
+ } : {
433
+ kind: "MEASURED",
434
+ capital: peak
435
+ }
436
+ };
437
+ }
438
+ __name(walkPositionTransitions, "walkPositionTransitions");
210
439
  function computeAssetRealizedPnLs(fills) {
211
440
  const bySymbol = /* @__PURE__ */ new Map();
212
441
  for (const fill of fills) {
@@ -219,7 +448,7 @@ function computeAssetRealizedPnLs(fills) {
219
448
  const tradeFees = assetFills.reduce((acc, fill) => acc.plus(new BigNumber7(fill.tradeFee ?? "0")), ZERO);
220
449
  const pearFees = assetFills.reduce((acc, fill) => acc.plus(new BigNumber7(fill.pearFee ?? "0")), ZERO);
221
450
  const totalFees = tradeFees.plus(pearFees);
222
- const { gross: grossPnl, undeterminedSize, closures, exitQuantity, exitNotional } = walkAssetFills(assetFills);
451
+ const { gross: grossPnl, undeterminedSize, closures, exitQuantity, exitNotional, residualQuantity, residualBasis } = walkAssetFills(assetFills);
223
452
  const netPnl = grossPnl.minus(totalFees);
224
453
  const exitVwap = isZero(exitQuantity) ? null : divide(exitNotional, exitQuantity);
225
454
  result.push({
@@ -233,106 +462,188 @@ function computeAssetRealizedPnLs(fills) {
233
462
  totalFees,
234
463
  tradeFees,
235
464
  pearFees,
465
+ residualQuantity,
466
+ residualBasis,
236
467
  closures
237
468
  });
238
469
  }
239
470
  return result;
240
471
  }
241
472
  __name(computeAssetRealizedPnLs, "computeAssetRealizedPnLs");
242
- function computeDeployedCapital(fills, assets) {
243
- const legs = /* @__PURE__ */ new Map();
473
+ function computeDeployedCapitalState(fills, assets) {
474
+ const capitalByAsset = /* @__PURE__ */ new Map();
244
475
  let committed = ZERO;
245
476
  let peak = ZERO;
246
- for (const fill of fills) {
477
+ for (const transition of computeFillTransitions(fills)) {
478
+ const { fill, positionAfter } = transition;
247
479
  if (assets && !assets.has(fill.symbol.toUpperCase())) {
248
480
  continue;
249
481
  }
250
- const leg = legs.get(fill.symbol) ?? {
251
- quantity: ZERO,
252
- basis: ZERO
253
- };
254
- const delta = signedBySide(fill.quantity, fill.side);
255
- const closed = closesAgainstHolding(leg.quantity, delta);
256
- const opened = subtract(abs(delta), closed);
257
- const released = isZero(leg.quantity) ? ZERO : divide(multiply(leg.basis, closed), abs(leg.quantity));
258
- const basis = add(subtract(leg.basis, released), multiply(opened, fill.price));
259
- committed = add(subtract(committed, leg.basis), basis);
260
- legs.set(fill.symbol, {
261
- quantity: add(leg.quantity, delta),
262
- basis
263
- });
482
+ if (positionAfter.basis.kind === "UNPRICED") {
483
+ return {
484
+ kind: "UNPRICED"
485
+ };
486
+ }
487
+ const previousCapital = capitalByAsset.get(fill.symbol) ?? ZERO;
488
+ let nextCapital;
489
+ switch (transition.kind) {
490
+ case "OPEN":
491
+ nextCapital = multiply(transition.openedQuantity, fill.price);
492
+ break;
493
+ case "INCREASE":
494
+ nextCapital = add(previousCapital, multiply(transition.increasedQuantity, fill.price));
495
+ break;
496
+ case "REDUCE": {
497
+ const released = divide(multiply(previousCapital, transition.reduction.quantity), transition.positionBefore.signedQuantity.abs());
498
+ nextCapital = subtract(previousCapital, released);
499
+ break;
500
+ }
501
+ case "CLOSE":
502
+ nextCapital = ZERO;
503
+ break;
504
+ case "FLIP":
505
+ nextCapital = multiply(transition.openedQuantity, fill.price);
506
+ break;
507
+ }
508
+ committed = add(subtract(committed, previousCapital), nextCapital);
509
+ capitalByAsset.set(fill.symbol, nextCapital);
264
510
  if (isGreaterThan(committed, peak)) {
265
511
  peak = committed;
266
512
  }
267
513
  }
268
- return peak;
514
+ return {
515
+ kind: "MEASURED",
516
+ capital: peak
517
+ };
518
+ }
519
+ __name(computeDeployedCapitalState, "computeDeployedCapitalState");
520
+ function computeDeployedCapital(fills, assets) {
521
+ const state = computeDeployedCapitalState(fills, assets);
522
+ switch (state.kind) {
523
+ case "MEASURED":
524
+ return state.capital;
525
+ case "UNPRICED":
526
+ return ZERO;
527
+ }
269
528
  }
270
529
  __name(computeDeployedCapital, "computeDeployedCapital");
271
530
  function walkAssetFills(fills) {
272
- let entryPrice = ZERO;
273
- let positionSize = ZERO;
274
- let basisUnpriced = false;
531
+ const transitions = computeFillTransitions(fills);
275
532
  let gross = ZERO;
276
533
  let undeterminedSize = ZERO;
277
534
  const closures = [];
278
535
  let exitQuantity = ZERO;
279
536
  let exitNotional = ZERO;
280
- for (const fill of fills) {
281
- const price = parse(fill.price);
282
- const quantity = parse(fill.quantity);
283
- const signedQuantity = signedBySide(quantity, fill.side);
284
- const newPositionSize = add(positionSize, signedQuantity);
285
- if (!isZero(positionSize) && isPositive(positionSize) !== isPositive(signedQuantity)) {
286
- const closedQty = min(positionSize.abs(), signedQuantity.abs());
287
- if (basisUnpriced || isZero(price)) {
288
- undeterminedSize = add(undeterminedSize, closedQty);
289
- closures.push({
290
- kind: "UNPRICED",
291
- fillId: fill.id,
292
- closedQuantity: closedQty
293
- });
294
- } else {
295
- const realized = isPositive(positionSize) ? multiply(closedQty, subtract(price, entryPrice)) : multiply(closedQty, subtract(entryPrice, price));
296
- gross = add(gross, realized);
297
- closures.push({
298
- kind: "PRICED",
299
- fillId: fill.id,
300
- gross: realized,
301
- entryPrice,
302
- exitPrice: price,
303
- closedQuantity: closedQty
304
- });
305
- exitQuantity = add(exitQuantity, closedQty);
306
- exitNotional = add(exitNotional, multiply(closedQty, price));
307
- }
537
+ let residualQuantity = ZERO;
538
+ let residualBasis = {
539
+ kind: "FLAT"
540
+ };
541
+ for (const transition of transitions) {
542
+ residualQuantity = transition.positionAfter.signedQuantity;
543
+ residualBasis = transition.positionAfter.basis;
544
+ if (transition.kind === "OPEN" || transition.kind === "INCREASE") {
545
+ continue;
308
546
  }
309
- const isSameDirection = !isZero(positionSize) && isPositive(positionSize) === isPositive(signedQuantity);
310
- const isFlip = !isZero(positionSize) && !isZero(newPositionSize) && isPositive(newPositionSize) !== isPositive(positionSize);
311
- if (isZero(positionSize) || isFlip) {
312
- entryPrice = price;
313
- basisUnpriced = isZero(price);
314
- } else if (isSameDirection) {
315
- entryPrice = entryPrice.times(positionSize.abs()).plus(price.times(quantity)).div(newPositionSize.abs());
316
- basisUnpriced = basisUnpriced || isZero(price);
547
+ const { reduction } = transition;
548
+ closures.push({
549
+ ...reduction,
550
+ fillId: transition.fill.id
551
+ });
552
+ if (reduction.kind === "UNPRICED") {
553
+ undeterminedSize = add(undeterminedSize, reduction.quantity);
554
+ continue;
317
555
  }
318
- positionSize = newPositionSize;
556
+ gross = add(gross, reduction.grossPnl);
557
+ exitQuantity = add(exitQuantity, reduction.quantity);
558
+ exitNotional = add(exitNotional, multiply(reduction.quantity, reduction.exitPrice));
319
559
  }
320
560
  return {
321
561
  gross,
322
562
  undeterminedSize,
323
563
  closures,
324
564
  exitQuantity,
325
- exitNotional
565
+ exitNotional,
566
+ residualQuantity,
567
+ residualBasis
326
568
  };
327
569
  }
328
570
  __name(walkAssetFills, "walkAssetFills");
329
- function closesAgainstHolding(held, delta) {
330
- if (isZero(held) || isPositive(held) === isPositive(delta)) {
331
- return ZERO;
571
+ function createAssetRealizedAccumulator() {
572
+ return {
573
+ gross: ZERO,
574
+ undeterminedSize: ZERO,
575
+ closures: [],
576
+ exitQuantity: ZERO,
577
+ exitNotional: ZERO,
578
+ residualQuantity: ZERO,
579
+ residualBasis: {
580
+ kind: "FLAT"
581
+ },
582
+ tradeFees: ZERO,
583
+ pearFees: ZERO
584
+ };
585
+ }
586
+ __name(createAssetRealizedAccumulator, "createAssetRealizedAccumulator");
587
+ function accumulateAssetRealization(accumulator, transition) {
588
+ const { fill } = transition;
589
+ accumulator.tradeFees = add(accumulator.tradeFees, fill.tradeFee ?? "0");
590
+ accumulator.pearFees = add(accumulator.pearFees, fill.pearFee ?? "0");
591
+ accumulator.residualQuantity = transition.positionAfter.signedQuantity;
592
+ accumulator.residualBasis = transition.positionAfter.basis;
593
+ if (transition.kind === "OPEN" || transition.kind === "INCREASE") {
594
+ return;
595
+ }
596
+ const { reduction } = transition;
597
+ accumulator.closures.push({
598
+ ...reduction,
599
+ fillId: fill.id
600
+ });
601
+ if (reduction.kind === "UNPRICED") {
602
+ accumulator.undeterminedSize = add(accumulator.undeterminedSize, reduction.quantity);
603
+ return;
332
604
  }
333
- return min(abs(delta), abs(held));
605
+ accumulator.gross = add(accumulator.gross, reduction.grossPnl);
606
+ accumulator.exitQuantity = add(accumulator.exitQuantity, reduction.quantity);
607
+ accumulator.exitNotional = add(accumulator.exitNotional, multiply(reduction.quantity, reduction.exitPrice));
334
608
  }
335
- __name(closesAgainstHolding, "closesAgainstHolding");
609
+ __name(accumulateAssetRealization, "accumulateAssetRealization");
610
+ function finalizeAssetRealization(id, accumulator) {
611
+ const totalFees = add(accumulator.tradeFees, accumulator.pearFees);
612
+ const exitVwap = isZero(accumulator.exitQuantity) ? null : divide(accumulator.exitNotional, accumulator.exitQuantity);
613
+ return {
614
+ id,
615
+ grossPnl: accumulator.gross,
616
+ netPnl: subtract(accumulator.gross, totalFees),
617
+ undeterminedSize: accumulator.undeterminedSize,
618
+ exitQuantity: accumulator.exitQuantity,
619
+ exitNotional: accumulator.exitNotional,
620
+ exitVwap,
621
+ totalFees,
622
+ tradeFees: accumulator.tradeFees,
623
+ pearFees: accumulator.pearFees,
624
+ residualQuantity: accumulator.residualQuantity,
625
+ residualBasis: accumulator.residualBasis,
626
+ closures: accumulator.closures
627
+ };
628
+ }
629
+ __name(finalizeAssetRealization, "finalizeAssetRealization");
630
+ function capitalAfterTransition(transition, previousCapital) {
631
+ switch (transition.kind) {
632
+ case "OPEN":
633
+ return multiply(transition.openedQuantity, transition.fill.price);
634
+ case "INCREASE":
635
+ return add(previousCapital, multiply(transition.increasedQuantity, transition.fill.price));
636
+ case "REDUCE": {
637
+ const released = divide(multiply(previousCapital, transition.reduction.quantity), transition.positionBefore.signedQuantity.abs());
638
+ return subtract(previousCapital, released);
639
+ }
640
+ case "CLOSE":
641
+ return ZERO;
642
+ case "FLIP":
643
+ return multiply(transition.openedQuantity, transition.fill.price);
644
+ }
645
+ }
646
+ __name(capitalAfterTransition, "capitalAfterTransition");
336
647
 
337
648
  // src/basket/compute.ts
338
649
  function computeBasketWeightedRatioV1(basket, priceMap) {
@@ -553,6 +864,28 @@ function baseToContracts(base, contractSize) {
553
864
  return new BigNumber7(base).div(contractSize);
554
865
  }
555
866
  __name(baseToContracts, "baseToContracts");
867
+
868
+ // src/fills/group.ts
869
+ function groupFillsByPosition(fills) {
870
+ const byPosition = /* @__PURE__ */ new Map();
871
+ for (const fill of fills) {
872
+ const positionFills = byPosition.get(fill.positionId) ?? [];
873
+ positionFills.push(fill);
874
+ byPosition.set(fill.positionId, positionFills);
875
+ }
876
+ return byPosition;
877
+ }
878
+ __name(groupFillsByPosition, "groupFillsByPosition");
879
+ function groupFillsByAsset(fills) {
880
+ const byAsset = /* @__PURE__ */ new Map();
881
+ for (const fill of fills) {
882
+ const assetFills = byAsset.get(fill.symbol) ?? [];
883
+ assetFills.push(fill);
884
+ byAsset.set(fill.symbol, assetFills);
885
+ }
886
+ return byAsset;
887
+ }
888
+ __name(groupFillsByAsset, "groupFillsByAsset");
556
889
  function computeRealizedPnlBySymbol(fills) {
557
890
  const bySymbol = groupFillsBySymbol(fills);
558
891
  const result = /* @__PURE__ */ new Map();
@@ -653,6 +986,10 @@ function aggregateRealizedPositionState(assetPnLs) {
653
986
  };
654
987
  }
655
988
  __name(aggregateRealizedPositionState, "aggregateRealizedPositionState");
989
+ function sumAttributedFunding(fundingByAsset) {
990
+ return Object.values(fundingByAsset).reduce((total, assetFunding) => add(total, assetFunding), ZERO);
991
+ }
992
+ __name(sumAttributedFunding, "sumAttributedFunding");
656
993
  function computePositionEntryPrice(assetUPnLs) {
657
994
  const { totalWeightedPrice, totalWeight } = assetUPnLs.reduce((acc, { signedQuantity, entryPrice }) => {
658
995
  const absQuantity = signedQuantity.abs();
@@ -2352,4 +2689,4 @@ function takeVerifier(handoffId, storage) {
2352
2689
  }
2353
2690
  __name(takeVerifier, "takeVerifier");
2354
2691
 
2355
- export { BIPS, ZERO, abs, add, aggregateRealizedPositionState, baseToContracts, buildApplicables, compareValue, computeAssetEntryNotional, computeAssetGrossRealizedPnL, computeAssetRealizedPnLs, computeAssetUndeterminedSizes, computeAssetUnrealizedPnL, computeAssetUnrealizedPnLs, computeBasketWeightedRatioV1, computeDeployedCapital, computeEntryPriceForAsset, computeMaxBasketSize, computeMultiQuoteBasketSize, computePositionEntryPrices, computeRealizedPnlBySymbol, computeRealizedPnlFromFills, computeRealizedPositionState, computeSyncPayload, computeUnrealizedPositionState, computeUnrealizedPositionStateFromEntryPrices, configureProxy, contractsToBase, countDecimals, deriveBasketKey, divide, exponentiate, finishXHandoff, generateDeterministicPositionKey, isEqualTo, isFiniteDecimal, isGreaterThan, isGreaterThanOrEqual, isLessThan, isLessThanOrEqual, isNegative, isPositive, isZero, min, multiply, negate, parse, precisePrice, preciseQuantity, proxyFetch, sideSign, sign, signed, signedBySide, sizeBasket, sizeBasketByLeverageMode, sizeBasketPerAssetLeverage, startXHandoff, stripXReturn, subtract, toDecimalString, validateLeverage, validateQuantity };
2692
+ export { BIPS, ZERO, abs, add, aggregateRealizedPositionState, baseToContracts, buildApplicables, compareValue, computeAssetEntryNotional, computeAssetGrossRealizedPnL, computeAssetRealizedPnLs, computeAssetUndeterminedSizes, computeAssetUnrealizedPnL, computeAssetUnrealizedPnLs, computeBasketWeightedRatioV1, computeDeployedCapital, computeDeployedCapitalState, computeEntryPriceForAsset, computeFillTransitions, computeMaxBasketSize, computeMultiQuoteBasketSize, computePositionEntryPrices, computeRealizedPnlBySymbol, computeRealizedPnlFromFills, computeRealizedPositionState, computeSyncPayload, computeUnrealizedPositionState, computeUnrealizedPositionStateFromEntryPrices, configureProxy, contractsToBase, countDecimals, deriveBasketKey, divide, exponentiate, finishXHandoff, generateDeterministicPositionKey, groupFillsByAsset, groupFillsByPosition, isEqualTo, isFiniteDecimal, isGreaterThan, isGreaterThanOrEqual, isLessThan, isLessThanOrEqual, isNegative, isPositive, isZero, min, multiply, negate, parse, precisePrice, preciseQuantity, proxyFetch, sideSign, sign, signed, signedBySide, sizeBasket, sizeBasketByLeverageMode, sizeBasketPerAssetLeverage, startXHandoff, stripXReturn, subtract, sumAttributedFunding, toDecimalString, validateLeverage, validateQuantity, walkPositionLedger, walkPositionTransitions };
@@ -1,4 +1,5 @@
1
- import type { InstrumentId } from '@pear-protocol/types';
1
+ import type { InstrumentId, PositionTotalFundingPayment } from '@pear-protocol/types';
2
+ import BigNumber from 'bignumber.js';
2
3
  import type { AssetRealizedPnL } from '../asset/asset.types';
3
4
  import type { Fill } from '../fills/fill.types';
4
5
  import type { RealizedPositionState, UnrealizedPositionState } from './position.types';
@@ -32,6 +33,18 @@ export declare function computeRealizedPositionState(fills: Fill[]): RealizedPos
32
33
  * @returns The realized position state those rows add up to.
33
34
  */
34
35
  export declare function aggregateRealizedPositionState(assetPnLs: AssetRealizedPnL[]): RealizedPositionState;
36
+ /**
37
+ * Total the funding attributed to one position, across every asset that earned any.
38
+ *
39
+ * Deliberately totals the attribution map rather than the position's legs. Funding
40
+ * follows exposure, not fills, so an asset that carried exposure without a fill has
41
+ * funding and no leg. Summing the legs would silently drop it, which is why this total
42
+ * can exceed the breakdown a caller can see.
43
+ *
44
+ * @param fundingByAsset - Funding attributed per asset, as the wire records it.
45
+ * @returns The position's net funding carry.
46
+ */
47
+ export declare function sumAttributedFunding(fundingByAsset: PositionTotalFundingPayment): BigNumber;
35
48
  /**
36
49
  * Weighted-average entry (cost-basis) price per instrument in a position, derived purely from
37
50
  * fills — no mark price needed. This is the authoritative source for a position's entry price;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pear-protocol/utils",
3
- "version": "0.8.1",
3
+ "version": "1.0.0",
4
4
  "description": "Pear Protocol Utility functions",
5
5
  "private": false,
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@noble/hashes": "^1.3.2",
30
- "@pear-protocol/types": "^1.32.0",
30
+ "@pear-protocol/types": "^2.0.0",
31
31
  "@scure/base": "^2.3.0",
32
32
  "bignumber.js": "9.3.1"
33
33
  },