@pear-protocol/utils 1.4.0 → 1.6.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/dist/index.js CHANGED
@@ -1288,35 +1288,43 @@ BigNumber7.config({
1288
1288
  DECIMAL_PLACES: 30,
1289
1289
  ROUNDING_MODE: BigNumber7.ROUND_DOWN
1290
1290
  });
1291
- function preciseQuantity(connector, raw, precision, side) {
1291
+ function preciseQuantity(connector, raw, precision, side, rounding = "side") {
1292
+ const roundingMode = resolveRoundingMode2(side, rounding);
1292
1293
  switch (connector) {
1293
1294
  case "hyperliquid":
1294
- return preciseHyperliquidQuantity(raw, precision, side);
1295
+ return preciseHyperliquidQuantity(raw, precision, roundingMode);
1295
1296
  case "binance":
1296
1297
  case "bybit":
1297
1298
  case "okx":
1298
1299
  case "lighter":
1299
1300
  case "lighter_rhc":
1300
- return preciseStepGridQuantity(raw, precision, side);
1301
+ return preciseStepGridQuantity(raw, precision, roundingMode);
1301
1302
  }
1302
1303
  }
1303
1304
  __name(preciseQuantity, "preciseQuantity");
1304
- function preciseHyperliquidQuantity(raw, precision, side) {
1305
+ function resolveRoundingMode2(side, rounding) {
1306
+ if (rounding === "floor") {
1307
+ return BigNumber7.ROUND_FLOOR;
1308
+ }
1309
+ return side === "BUY" ? BigNumber7.ROUND_FLOOR : BigNumber7.ROUND_CEIL;
1310
+ }
1311
+ __name(resolveRoundingMode2, "resolveRoundingMode");
1312
+ function preciseHyperliquidQuantity(raw, precision, roundingMode) {
1305
1313
  const decimals = precision.quantity.decimals;
1306
1314
  const quantity = new BigNumber7(raw);
1307
- const rounded = quantity.decimalPlaces(decimals, side === "BUY" ? BigNumber7.ROUND_FLOOR : BigNumber7.ROUND_CEIL);
1315
+ const rounded = quantity.decimalPlaces(decimals, roundingMode);
1308
1316
  return rounded.toFixed();
1309
1317
  }
1310
1318
  __name(preciseHyperliquidQuantity, "preciseHyperliquidQuantity");
1311
- function preciseStepGridQuantity(raw, precision, side) {
1319
+ function preciseStepGridQuantity(raw, precision, roundingMode) {
1312
1320
  const step = precision.quantity.step;
1313
1321
  const contractQuantity = baseToContracts(raw, precision.quantity.contractSize);
1314
1322
  const stepBN = new BigNumber7(step);
1315
1323
  const scaled = contractQuantity.div(stepBN);
1316
- const rounded = side === "BUY" ? scaled.integerValue(BigNumber7.ROUND_FLOOR) : scaled.integerValue(BigNumber7.ROUND_CEIL);
1324
+ const rounded = scaled.integerValue(roundingMode);
1317
1325
  const result = rounded.times(stepBN);
1318
1326
  const maxDecimals = precision.quantity.decimals;
1319
- const final = result.decimalPlaces(maxDecimals, side === "BUY" ? BigNumber7.ROUND_FLOOR : BigNumber7.ROUND_CEIL);
1327
+ const final = result.decimalPlaces(maxDecimals, roundingMode);
1320
1328
  return final.toFixed();
1321
1329
  }
1322
1330
  __name(preciseStepGridQuantity, "preciseStepGridQuantity");
@@ -1490,14 +1498,6 @@ var Basket = class _Basket {
1490
1498
  static detect(fills) {
1491
1499
  return groupFillsIntoBaskets(fills).map((group) => new _Basket(combineFillsBySymbolAndSide(group), group));
1492
1500
  }
1493
- /** Active long symbol set the matcher compares against open positions. */
1494
- get longs() {
1495
- return new Set(this.legs.filter((leg) => leg.side === "BUY").map((leg) => leg.symbol));
1496
- }
1497
- /** Active short symbol set the matcher compares against open positions. */
1498
- get shorts() {
1499
- return new Set(this.legs.filter((leg) => leg.side === "SELL").map((leg) => leg.symbol));
1500
- }
1501
1501
  };
1502
1502
  var CombinedLeg = class {
1503
1503
  static {
@@ -1649,15 +1649,6 @@ var Exposure = class _Exposure {
1649
1649
  shorts
1650
1650
  };
1651
1651
  }
1652
- /** True once every leg sits within DUST of zero (effectively closed). */
1653
- isFlat() {
1654
- for (const qty of this.legs.values()) {
1655
- if (isGreaterThanOrEqual(qty.abs(), DUST)) {
1656
- return false;
1657
- }
1658
- }
1659
- return true;
1660
- }
1661
1652
  /** True when every leg is exactly zero (a fully CLOSED position). */
1662
1653
  isAllZero() {
1663
1654
  for (const qty of this.legs.values()) {
@@ -1667,18 +1658,6 @@ var Exposure = class _Exposure {
1667
1658
  }
1668
1659
  return true;
1669
1660
  }
1670
- /**
1671
- * Snap every non-zero leg to exactly zero. Called once a position is flat so it
1672
- * reads CLOSED (all-zero exposure) and never lingers in the open list with stray
1673
- * dust that a later reopen of the same pair could merge into.
1674
- */
1675
- snapToZero() {
1676
- for (const [symbol, qty] of this.legs) {
1677
- if (!qty.isZero()) {
1678
- this.legs.set(symbol, ZERO);
1679
- }
1680
- }
1681
- }
1682
1661
  /** All symbols with a leg, including zeroed ones (a closed leg still carries cost basis). */
1683
1662
  symbols() {
1684
1663
  return [
@@ -1727,7 +1706,7 @@ function buildSyntheticFill(args) {
1727
1706
  return {
1728
1707
  // Content-addressed id — no wall-clock, no positionId, so re-syncs collide
1729
1708
  // on the dedup index and synthetic generation stays idempotent.
1730
- exchangeFillId: `synthetic-${args.positionKey}-${args.symbol}-${args.side}`,
1709
+ exchangeFillId: syntheticFillId(args),
1731
1710
  cloid: null,
1732
1711
  connector: args.connector,
1733
1712
  symbol: args.symbol,
@@ -1750,6 +1729,13 @@ function buildSyntheticFill(args) {
1750
1729
  };
1751
1730
  }
1752
1731
  __name(buildSyntheticFill, "buildSyntheticFill");
1732
+ function syntheticFillId(args) {
1733
+ if (args.lifecycleStartedAt === null) {
1734
+ return `synthetic-${args.positionKey}-${args.symbol}-${args.side}`;
1735
+ }
1736
+ return `synthetic-${args.positionKey}-${args.lifecycleStartedAt}-${args.symbol}-${args.side}`;
1737
+ }
1738
+ __name(syntheticFillId, "syntheticFillId");
1753
1739
  function buildMissedHistoryFills(args) {
1754
1740
  const { orphanFills, pear, connector, entryBySymbol, snapshot } = args;
1755
1741
  const fillTotals = /* @__PURE__ */ new Map();
@@ -1786,7 +1772,7 @@ function buildMissedHistoryFills(args) {
1786
1772
  });
1787
1773
  result.push({
1788
1774
  connector,
1789
- exchangeFillId: `${MISSED_HISTORY_FILL_PREFIX}${symbol}-${gap.side.toLowerCase()}`,
1775
+ exchangeFillId: `${MISSED_HISTORY_FILL_PREFIX}${symbol}-${gap.side.toLowerCase()}-${watermark}`,
1790
1776
  cloid: null,
1791
1777
  symbol,
1792
1778
  price,
@@ -1858,7 +1844,7 @@ function findNeededSynthetics(args) {
1858
1844
  symbol: gap.symbol,
1859
1845
  side: gap.side,
1860
1846
  quantity: gap.quantity,
1861
- closedReason: "NETTED"
1847
+ closedReason: "USER"
1862
1848
  });
1863
1849
  break;
1864
1850
  case "gap":
@@ -1894,20 +1880,22 @@ function assignSyntheticsToPositions(args) {
1894
1880
  if (!target) {
1895
1881
  target = existing ? (
1896
1882
  // seed its exposure and origin so folding the synthetic yields the true
1897
- // final state (a gone-close nets to zero -> CLOSED/NETTED, a gap writes
1883
+ // final state (a gone-close nets to zero -> CLOSED/USER, a gap writes
1898
1884
  // the full total, not just the delta).
1899
1885
  {
1900
1886
  positionKey,
1901
1887
  parts: [],
1902
1888
  closed: false,
1903
1889
  exposure: Exposure.fromRecord(existing.exposure),
1904
- existingOrigin: existing.origin
1890
+ existingOrigin: existing.origin,
1891
+ lifecycleStartedAt: Date.parse(existing.createdAt)
1905
1892
  }
1906
1893
  ) : {
1907
1894
  positionKey,
1908
1895
  parts: [],
1909
1896
  closed: false,
1910
- exposure: Exposure.empty()
1897
+ exposure: Exposure.empty(),
1898
+ lifecycleStartedAt: null
1911
1899
  };
1912
1900
  created.push(target);
1913
1901
  }
@@ -1977,11 +1965,9 @@ function matchPositions(args) {
1977
1965
  for (const basket of Basket.detect(orphanFills)) {
1978
1966
  const at = basketTime(basket);
1979
1967
  book.foldIntoLive(at);
1980
- const exactCloseTarget = findExactCloseTarget(book.live(at), basket.longs, basket.shorts);
1981
1968
  const leftover = applyCloses({
1982
1969
  legs: basket.legs,
1983
1970
  book,
1984
- exactCloseTarget,
1985
1971
  at
1986
1972
  });
1987
1973
  if (leftover.length === 0) {
@@ -2025,6 +2011,8 @@ var WorkingBook = class WorkingBook2 {
2025
2011
  // A partial fill on the original order leaves exposure short of the
2026
2012
  // symbols the key was hashed from, so a recomputed key would never match.
2027
2013
  positionKey: p.positionKey,
2014
+ createdAt: Date.parse(p.createdAt),
2015
+ orderId: p.id,
2028
2016
  exposure: Exposure.fromRecord(p.exposure),
2029
2017
  parts: [],
2030
2018
  closed: false,
@@ -2043,9 +2031,9 @@ var WorkingBook = class WorkingBook2 {
2043
2031
  symbols
2044
2032
  });
2045
2033
  }
2046
- /** Working positions open and already existing at basket time `at`, in insertion order. */
2034
+ /** Working positions open and already existing at basket time `at`, in creation order. */
2047
2035
  live(at) {
2048
- return this.all.filter((position) => !position.closed && position.liveFrom <= at);
2036
+ return this.all.filter((position) => !position.closed && position.liveFrom <= at).sort((a, b) => a.createdAt - b.createdAt || a.orderId.localeCompare(b.orderId));
2049
2037
  }
2050
2038
  /** Look up a position open and existing at `at` by its positionKey (for dedup on merge). */
2051
2039
  findLiveByKey(key, at) {
@@ -2073,9 +2061,11 @@ var WorkingBook = class WorkingBook2 {
2073
2061
  }
2074
2062
  }
2075
2063
  /** Register a fresh empty working position under `key` and return it. */
2076
- create(key) {
2064
+ create(key, createdAt) {
2077
2065
  const position = {
2078
2066
  positionKey: key,
2067
+ createdAt,
2068
+ orderId: key,
2079
2069
  exposure: Exposure.empty(),
2080
2070
  parts: [],
2081
2071
  closed: false,
@@ -2085,12 +2075,11 @@ var WorkingBook = class WorkingBook2 {
2085
2075
  this.all.push(position);
2086
2076
  return position;
2087
2077
  }
2088
- /** Flip a position to closed once every leg is within DUST of zero. */
2078
+ /** Flip a position to closed once every leg is exactly zero. */
2089
2079
  markClosedIfFlat(position) {
2090
- if (position.closed || !position.exposure.isFlat()) {
2080
+ if (position.closed || !position.exposure.isAllZero()) {
2091
2081
  return;
2092
2082
  }
2093
- position.exposure.snapToZero();
2094
2083
  position.closed = true;
2095
2084
  }
2096
2085
  /** Working positions that received fills become MatchedPosition records; untouched ones are dropped. */
@@ -2100,22 +2089,13 @@ var WorkingBook = class WorkingBook2 {
2100
2089
  parts: position.parts,
2101
2090
  closed: position.closed,
2102
2091
  exposure: position.exposure,
2103
- existingOrigin: position.existingOrigin
2092
+ existingOrigin: position.existingOrigin,
2093
+ lifecycleStartedAt: position.createdAt
2104
2094
  }));
2105
2095
  }
2106
2096
  };
2107
- function findExactCloseTarget(openList, basketLongs, basketShorts) {
2108
- return openList.find((position) => {
2109
- const { longs, shorts } = position.exposure.activeLegs();
2110
- return setEq(longs, basketShorts) && setEq(shorts, basketLongs);
2111
- });
2112
- }
2113
- __name(findExactCloseTarget, "findExactCloseTarget");
2114
- var setEq = /* @__PURE__ */ __name((a, b) => a.size === b.size && [
2115
- ...a
2116
- ].every((x) => b.has(x)), "setEq");
2117
2097
  function applyCloses(args) {
2118
- const { legs, book, exactCloseTarget, at } = args;
2098
+ const { legs, book, at } = args;
2119
2099
  const openList = book.live(at);
2120
2100
  const leftover = [];
2121
2101
  for (const leg of legs) {
@@ -2128,9 +2108,6 @@ function applyCloses(args) {
2128
2108
  }
2129
2109
  return closeTargetIsLong ? qty.isPositive() : qty.isNegative();
2130
2110
  });
2131
- if (exactCloseTarget) {
2132
- holders.sort((a, b) => a === exactCloseTarget ? -1 : b === exactCloseTarget ? 1 : 0);
2133
- }
2134
2111
  for (const position of holders) {
2135
2112
  if (isLessThanOrEqual(remaining, ZERO)) {
2136
2113
  break;
@@ -2142,7 +2119,7 @@ function applyCloses(args) {
2142
2119
  remaining = remaining.minus(reduce);
2143
2120
  book.markClosedIfFlat(position);
2144
2121
  }
2145
- if (isGreaterThanOrEqual(remaining, DUST)) {
2122
+ if (isGreaterThan(remaining, ZERO)) {
2146
2123
  leftover.push({
2147
2124
  side: leg.side,
2148
2125
  qty: remaining,
@@ -2173,143 +2150,99 @@ function resolveLeftoverTarget(args) {
2173
2150
  ])
2174
2151
  ];
2175
2152
  const positionKey = book.keyFor(symbols);
2176
- return book.findLiveByKey(positionKey, at) ?? book.create(positionKey);
2153
+ return book.findLiveByKey(positionKey, at) ?? book.create(positionKey, at);
2177
2154
  }
2178
2155
  __name(resolveLeftoverTarget, "resolveLeftoverTarget");
2179
2156
  function applyLeftover(target, leftover) {
2180
2157
  for (const entry of leftover) {
2181
- target.parts.push(...entry.leg.take(entry.qty));
2158
+ const parts = entry.leg.take(entry.qty);
2159
+ target.parts.push(...parts);
2160
+ if (target.existingOrigin === void 0) {
2161
+ target.createdAt = Math.min(...target.parts.map((part) => part.source.timestamp));
2162
+ target.orderId = target.parts.filter((part) => part.source.timestamp === target.createdAt).map((part) => part.source.exchangeFillId).sort()[0] ?? target.positionKey;
2163
+ }
2182
2164
  target.exposure.addSigned(entry.leg.symbol, entry.qty.toFixed(), entry.side);
2183
2165
  }
2184
2166
  }
2185
2167
  __name(applyLeftover, "applyLeftover");
2168
+ function setEq(a, b) {
2169
+ return a.size === b.size && [
2170
+ ...a
2171
+ ].every((symbol) => b.has(symbol));
2172
+ }
2173
+ __name(setEq, "setEq");
2186
2174
 
2187
2175
  // src/sync/applicables/fills.ts
2188
- function assignPartIds(positions) {
2189
- const bySource = /* @__PURE__ */ new Map();
2190
- for (const pos of positions) {
2191
- for (const part of pos.parts) {
2192
- const list = bySource.get(part.source.exchangeFillId) ?? [];
2193
- list.push(part);
2194
- bySource.set(part.source.exchangeFillId, list);
2195
- }
2196
- }
2197
- const resolution = /* @__PURE__ */ new Map();
2198
- for (const [sourceId, parts] of bySource) {
2199
- const single = parts.length === 1 ? parts[0] : void 0;
2200
- if (single && isEqualTo(single.quantity, single.source.quantity)) {
2201
- resolution.set(single, {
2202
- exchangeFillId: sourceId
2203
- });
2204
- continue;
2205
- }
2206
- parts.forEach((part, i) => {
2207
- resolution.set(part, {
2208
- exchangeFillId: `${sourceId}-p${i}`,
2209
- original: {
2210
- exchangeFillId: sourceId,
2211
- quantity: part.source.quantity
2212
- }
2213
- });
2214
- });
2215
- }
2216
- return (part) => {
2217
- const id = resolution.get(part);
2218
- if (!id) {
2219
- throw new Error("applicables: fill part was never assigned an id");
2220
- }
2221
- return id;
2222
- };
2223
- }
2224
- __name(assignPartIds, "assignPartIds");
2225
- function partToApplicableFill(part, id, connector, liquidationOutcome) {
2226
- const fill = part.source;
2227
- const base = {
2228
- exchangeFillId: id.exchangeFillId,
2176
+ function sourceToApplicableFill(fill, connector) {
2177
+ const synthetic = isMissedHistoryFill(fill);
2178
+ return {
2179
+ exchangeFillId: fill.exchangeFillId,
2229
2180
  connector,
2230
2181
  symbol: fill.symbol,
2231
2182
  side: fill.side,
2232
- quantity: part.quantity,
2183
+ quantity: fill.quantity,
2233
2184
  price: fill.price,
2234
- usd: multiply(part.quantity, fill.price).toFixed(),
2235
- timestamp: fill.timestamp
2185
+ usd: multiply(fill.quantity, fill.price).toFixed(),
2186
+ timestamp: fill.timestamp,
2187
+ cloid: synthetic ? null : fill.cloid ?? null,
2188
+ reduceOnly: synthetic ? false : fill.reduceOnly,
2189
+ synthetic,
2190
+ isExternal: synthetic || !isPearCloid(connector, fill.cloid),
2191
+ tradeFee: synthetic ? "0" : fill.tradeFee,
2192
+ builderFee: synthetic ? "0" : fill.builderFee,
2193
+ liquidation: synthetic ? null : fill.liquidation
2236
2194
  };
2237
- if (isMissedHistoryFill(fill)) {
2238
- return {
2239
- ...base,
2240
- cloid: null,
2241
- reduceOnly: false,
2242
- synthetic: true,
2243
- isExternal: true,
2244
- tradeFee: "0",
2245
- builderFee: "0",
2246
- liquidation: null
2247
- };
2248
- }
2249
- const fromPear = isPearCloid(connector, fill.cloid);
2250
- return {
2251
- ...base,
2252
- cloid: fill.cloid ?? null,
2253
- reduceOnly: fill.reduceOnly,
2254
- synthetic: false,
2255
- isExternal: !fromPear,
2256
- tradeFee: feeShareForPart(fill.tradeFee, part.quantity, fill.quantity),
2257
- builderFee: feeShareForPart(fill.builderFee, part.quantity, fill.quantity),
2258
- liquidation: fill.liquidation ? {
2259
- mark: fill.liquidation.mark,
2260
- outcome: liquidationOutcome ?? void 0
2261
- } : null,
2262
- ...id.original ? {
2263
- original: id.original
2264
- } : {}
2265
- };
2266
- }
2267
- __name(partToApplicableFill, "partToApplicableFill");
2268
- function feeShareForPart(totalFee, partQty, sourceQty) {
2269
- if (isEqualTo(partQty, sourceQty)) {
2270
- return totalFee;
2271
- }
2272
- if (isZero(sourceQty)) {
2273
- return "0";
2274
- }
2275
- const feeShare = divide(multiply(totalFee, partQty), sourceQty);
2276
- return feeShare.toFixed();
2277
2195
  }
2278
- __name(feeShareForPart, "feeShareForPart");
2196
+ __name(sourceToApplicableFill, "sourceToApplicableFill");
2279
2197
 
2280
2198
  // src/sync/applicables/position.ts
2281
2199
  function assembleApplicables(args) {
2282
2200
  const { positions, connector, synthByPosition, now } = args;
2283
- const partIdFor = assignPartIds(positions);
2201
+ const fillsById = /* @__PURE__ */ new Map();
2284
2202
  const applicables = [];
2285
2203
  for (const pos of positions) {
2286
- const applicable = assembleOneApplicable(pos, connector, partIdFor, synthByPosition.get(pos) ?? [], now);
2204
+ const applicable = assembleOneApplicable(pos, connector, fillsById, synthByPosition.get(pos) ?? [], now);
2287
2205
  if (applicable) {
2288
2206
  applicables.push(applicable);
2289
2207
  }
2290
2208
  }
2291
- return applicables;
2209
+ return {
2210
+ fills: [
2211
+ ...fillsById.values()
2212
+ ].sort((a, b) => a.timestamp - b.timestamp || a.exchangeFillId.localeCompare(b.exchangeFillId)),
2213
+ applicables
2214
+ };
2292
2215
  }
2293
2216
  __name(assembleApplicables, "assembleApplicables");
2294
- function assembleOneApplicable(pos, connector, partIdFor, assignedSynthetics, now) {
2295
- const { realFills, closedReason } = finalizeRealFills(pos, connector, partIdFor);
2217
+ function assembleOneApplicable(pos, connector, fillsById, assignedSynthetics, now) {
2218
+ const { realFills, assignments, closedReason } = finalizeRealFills(pos, connector);
2296
2219
  const syntheticFills = assignedSynthetics.map((synthetic) => buildSyntheticFill({
2297
2220
  positionKey: pos.positionKey,
2221
+ lifecycleStartedAt: pos.lifecycleStartedAt,
2298
2222
  connector,
2299
2223
  timestamp: anchorSyntheticTimestamp(realFills, synthetic.symbol, now),
2300
2224
  ...synthetic
2301
2225
  }));
2302
- const fills = [
2303
- ...realFills,
2304
- ...syntheticFills
2305
- ].sort((a, b) => a.timestamp - b.timestamp);
2306
- if (fills.length === 0) {
2226
+ for (const part of pos.parts) {
2227
+ fillsById.set(part.source.exchangeFillId, sourceToApplicableFill(part.source, connector));
2228
+ }
2229
+ for (const fill of syntheticFills) {
2230
+ fillsById.set(fill.exchangeFillId, fill);
2231
+ assignments.push({
2232
+ exchangeFillId: fill.exchangeFillId,
2233
+ quantity: fill.quantity
2234
+ });
2235
+ }
2236
+ if (realFills.length === 0 && syntheticFills.length === 0) {
2307
2237
  return null;
2308
2238
  }
2309
2239
  const exposure = finalExposure(pos, assignedSynthetics);
2310
2240
  const closed = exposure.isAllZero();
2311
2241
  const status = closed ? "CLOSED" : "OPEN";
2312
- const origin = mergeOrigin(pos.existingOrigin, fills);
2242
+ const origin = mergeOrigin(pos.existingOrigin, [
2243
+ ...realFills,
2244
+ ...syntheticFills
2245
+ ]);
2313
2246
  return {
2314
2247
  positionKey: pos.positionKey,
2315
2248
  exposure: exposure.toSigned(),
@@ -2318,32 +2251,46 @@ function assembleOneApplicable(pos, connector, partIdFor, assignedSynthetics, no
2318
2251
  ...closed ? {
2319
2252
  closedReason
2320
2253
  } : {},
2321
- fills,
2254
+ assignments: assignments.sort((a, b) => (fillsById.get(a.exchangeFillId)?.timestamp ?? 0) - (fillsById.get(b.exchangeFillId)?.timestamp ?? 0)),
2322
2255
  metadata: buildMetadata(new Set(exposure.symbols()), realFills)
2323
2256
  };
2324
2257
  }
2325
2258
  __name(assembleOneApplicable, "assembleOneApplicable");
2326
- function finalizeRealFills(position, connector, partIdFor) {
2259
+ function finalizeRealFills(position, connector) {
2327
2260
  const newestFirst = [
2328
2261
  ...position.parts
2329
2262
  ].sort((a, b) => a.source.timestamp - b.source.timestamp).reverse();
2330
2263
  const exposure = position.exposure.clone();
2331
2264
  const realFills = [];
2332
- let closedReason = "NETTED";
2265
+ const assignments = [];
2266
+ let closedReason = "USER";
2333
2267
  for (const [index, part] of newestFirst.entries()) {
2334
2268
  const after = exposure.get(part.source.symbol);
2335
- const flatAfter = exposure.isFlat();
2269
+ const flatAfter = exposure.isAllZero();
2336
2270
  exposure.addDelta(part.source.symbol, negate(signedBySide(part.quantity, part.source.side)));
2337
2271
  const reducesLeg = isGreaterThan(exposure.get(part.source.symbol).abs(), after.abs());
2338
2272
  const liquidationOutcome = part.source.liquidation && reducesLeg ? flatAfter ? "CLOSED" : "REDUCED" : null;
2339
2273
  if (index === 0 && position.closed && liquidationOutcome === "CLOSED") {
2340
2274
  closedReason = "LIQUIDATED";
2341
2275
  }
2342
- realFills.push(partToApplicableFill(part, partIdFor(part), connector, liquidationOutcome));
2276
+ realFills.push({
2277
+ ...sourceToApplicableFill(part.source, connector),
2278
+ quantity: part.quantity,
2279
+ usd: multiply(part.quantity, part.source.price).toFixed()
2280
+ });
2281
+ assignments.push({
2282
+ exchangeFillId: part.source.exchangeFillId,
2283
+ quantity: part.quantity,
2284
+ ...liquidationOutcome === null ? {} : {
2285
+ liquidationOutcome
2286
+ }
2287
+ });
2343
2288
  }
2344
2289
  realFills.reverse();
2290
+ assignments.reverse();
2345
2291
  return {
2346
2292
  realFills,
2293
+ assignments,
2347
2294
  closedReason
2348
2295
  };
2349
2296
  }
@@ -2468,7 +2415,7 @@ var PearHoldings = class _PearHoldings {
2468
2415
  /** Filter `openPositions` to the given connector and wrap. */
2469
2416
  static from(args) {
2470
2417
  const { openPositions, connector } = args;
2471
- return new _PearHoldings(openPositions.filter((position) => position.connector === connector));
2418
+ return new _PearHoldings(openPositions.filter((position) => position.connector === connector).sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || a.id.localeCompare(b.id)));
2472
2419
  }
2473
2420
  /** The open Pear positions for this connector. */
2474
2421
  positions() {
@@ -2598,7 +2545,7 @@ __name(isStrictTimestampBoundary, "isStrictTimestampBoundary");
2598
2545
  async function computeSyncPayload(args) {
2599
2546
  const { tradeAccountId, connector, openPositions, entryBySymbol, provisionalFills, confirmedExchangeFillIds, externalFills, exchangePositions, neverSynced } = args;
2600
2547
  const fillsToProcess = _prepareExternalFills(externalFills, confirmedExchangeFillIds);
2601
- const orphans = _excludeClaimedFills(provisionalFills, fillsToProcess);
2548
+ const orphans = _excludeClaimedFills(provisionalFills.filter((fill) => fill.tradeAccountId === tradeAccountId && fill.connector === connector), fillsToProcess);
2602
2549
  const { keptOrphans, firstSyncTrim } = applyFirstSyncCutoff({
2603
2550
  neverSynced,
2604
2551
  fillsToProcess,
@@ -2606,7 +2553,7 @@ async function computeSyncPayload(args) {
2606
2553
  exchangePositions,
2607
2554
  connector
2608
2555
  });
2609
- const applicables = buildApplicables({
2556
+ const { fills, applicables } = buildApplicables({
2610
2557
  tradeAccountId,
2611
2558
  connector,
2612
2559
  orphanFills: keptOrphans,
@@ -2620,6 +2567,7 @@ async function computeSyncPayload(args) {
2620
2567
  }
2621
2568
  const externalMaxTs = latestFillTimestamp(fillsToProcess);
2622
2569
  return {
2570
+ fills,
2623
2571
  applicables,
2624
2572
  exchangePositions,
2625
2573
  meta: {
@@ -2666,12 +2614,12 @@ function _excludeClaimedFills(provisionalFills, externalFills) {
2666
2614
  for (const fill of provisionalFills) {
2667
2615
  const cloid = _normalizeCloid(fill.cloid);
2668
2616
  if (cloid) {
2669
- ownedCloids.add(cloid);
2617
+ ownedCloids.add(`${fill.connector}:${cloid}`);
2670
2618
  }
2671
2619
  }
2672
2620
  return externalFills.filter((fill) => {
2673
2621
  const cloid = _normalizeCloid(fill.cloid);
2674
- return cloid === void 0 || !ownedCloids.has(cloid);
2622
+ return cloid === void 0 || !ownedCloids.has(`${fill.connector}:${cloid}`);
2675
2623
  });
2676
2624
  }
2677
2625
  __name(_excludeClaimedFills, "_excludeClaimedFills");
@@ -1,7 +1,13 @@
1
1
  import type { Connector, InstrumentPrecision, Side } from '@pear-protocol/types';
2
2
  import BigNumber from 'bignumber.js';
3
3
  /**
4
- * Round a raw quantity to an exchange-valid string for the given connector
5
- * (BUY rounds down, SELL rounds up).
4
+ * Rounding strategy for quantities that fall off the venue grid.
5
+ * 'side' keeps the historical behavior (BUY floors, SELL ceils); 'floor'
6
+ * rounds down on both sides so a fractional amount is never exceeded.
6
7
  */
7
- export declare function preciseQuantity(connector: Connector, raw: BigNumber.Value, precision: InstrumentPrecision, side: Side): string;
8
+ export type QuantityRounding = 'side' | 'floor';
9
+ /**
10
+ * Round a raw quantity to an exchange-valid string for the given connector.
11
+ * Default 'side' rounding: BUY rounds down, SELL rounds up.
12
+ */
13
+ export declare function preciseQuantity(connector: Connector, raw: BigNumber.Value, precision: InstrumentPrecision, side: Side, rounding?: QuantityRounding): string;
@@ -16,10 +16,6 @@ export declare class Basket {
16
16
  private constructor();
17
17
  /** Detect baskets from a fill stream, returned in ascending time order. */
18
18
  static detect(fills: HttpTypes.UnknownExternalFill[]): Basket[];
19
- /** Active long symbol set the matcher compares against open positions. */
20
- get longs(): Set<string>;
21
- /** Active short symbol set the matcher compares against open positions. */
22
- get shorts(): Set<string>;
23
19
  }
24
20
  /**
25
21
  * One combined symbol+side leg of a basket. Owns the per-source FIFO queue, so
@@ -3,7 +3,7 @@ import type { FillPart } from './types';
3
3
  /**
4
4
  * Signed per-asset exposure of a position — the running `symbol -> quantity` book
5
5
  * (BUY positive, SELL negative). Wraps the raw map so the signed-quantity math, the
6
- * DUST rule, and the leg-set queries live in one place instead of scattered helpers.
6
+ * exact-zero closure rule, and leg-set queries live in one place instead of scattered helpers.
7
7
  *
8
8
  * Mutable by design: the matcher mutates a working position's exposure in place while
9
9
  * replaying fills. Use {@link clone} when a caller needs an independent copy.
@@ -30,16 +30,8 @@ export declare class Exposure {
30
30
  longs: Set<string>;
31
31
  shorts: Set<string>;
32
32
  };
33
- /** True once every leg sits within DUST of zero (effectively closed). */
34
- isFlat(): boolean;
35
33
  /** True when every leg is exactly zero (a fully CLOSED position). */
36
34
  isAllZero(): boolean;
37
- /**
38
- * Snap every non-zero leg to exactly zero. Called once a position is flat so it
39
- * reads CLOSED (all-zero exposure) and never lingers in the open list with stray
40
- * dust that a later reopen of the same pair could merge into.
41
- */
42
- snapToZero(): void;
43
35
  /** All symbols with a leg, including zeroed ones (a closed leg still carries cost basis). */
44
36
  symbols(): string[];
45
37
  /** Canonical signed-string view for the wire (`+0` for zero). */
@@ -1,16 +1,3 @@
1
1
  import type { HttpTypes } from '@pear-protocol/types';
2
- import type { FillPart, MatchedPosition, PartId } from './types';
3
- /**
4
- * A source fill may be split into multiple slices (different positions). Build a
5
- * stable id per slice: unsplit slices keep the native id; split slices get a
6
- * deterministic `-pN` suffix plus an `original` pointer to the source.
7
- * Returns an accessor; every part of every given position has an id by construction.
8
- */
9
- export declare function assignPartIds(positions: MatchedPosition[]): (part: FillPart) => PartId;
10
- /**
11
- * Convert a matched fill slice into the wire-shape ApplicableFill the server persists.
12
- * Three shapes emerge: a real external fill, a real Pear fill (cloid we minted), or a
13
- * missed-history synthetic (external, priced to reconcile with the exchange entry).
14
- * Fees are quantity-prorated when the source fill was split across positions.
15
- */
16
- export declare function partToApplicableFill(part: FillPart, id: PartId, connector: HttpTypes.Connector, liquidationOutcome: 'CLOSED' | 'REDUCED' | null): HttpTypes.ApplicableFill;
2
+ /** Format one canonical source receipt without changing its quantity or fees. */
3
+ export declare function sourceToApplicableFill(fill: HttpTypes.UnknownExternalFill, connector: HttpTypes.Connector): HttpTypes.ApplicableFill;
@@ -3,8 +3,8 @@
3
3
  * find-or-creates into positions.
4
4
  *
5
5
  * Each fill is processed against the currently open positions, oldest first:
6
- * 1. close out an exact-match inverse position (same active legs),
7
- * 2. else reduce any other positions partially holding that leg,
6
+ * 1. reduce opposing positions in creation order,
7
+ * 2. consume each opposing leg before opening its remainder,
8
8
  * 3. else merge the leftover into an open position already holding those legs, or open a new one.
9
9
  * A fill can be split across several positions when it closes more than one.
10
10
  *
@@ -20,7 +20,7 @@ import type { HttpTypes } from '@pear-protocol/types';
20
20
  /**
21
21
  * Build the `applicables` bucket: baskets of fills (real + synthetic) keyed by a
22
22
  * deterministic positionKey, plus creation metadata. Positions are derived by
23
- * replaying fills through dynamic matching (exact active-set match, cross-
23
+ * replaying fills through dynamic matching (oldest-first cross-
24
24
  * position close, subset-merge of leftover opens), so same-asset exposure
25
25
  * consolidates instead of fragmenting per basket symbol-set.
26
26
  */
@@ -31,4 +31,7 @@ export declare function buildApplicables(args: {
31
31
  openPositions: HttpTypes.OpenPositionLite[];
32
32
  entryBySymbol: Record<string, string>;
33
33
  exchangePositions: HttpTypes.ExternalPosition[];
34
- }): HttpTypes.ApplicablePosition[];
34
+ }): {
35
+ fills: HttpTypes.ApplicableFill[];
36
+ applicables: HttpTypes.ApplicablePosition[];
37
+ };
@@ -3,9 +3,7 @@ import type { PearHoldings } from './snapshot';
3
3
  import type { MatchedPosition } from './types';
4
4
  /**
5
5
  * Replay external fills to decide which position each fill slice belongs to. Flow :
6
- * - findExactCloseTarget picks the open position whose ACTIVE legs mirror the
7
- * basket (dead legs ignored) — the basket closes that position first,
8
- * - applyCloses reduces holders (the mirrored target first, then any other),
6
+ * - applyCloses reduces holders (the oldest eligible position first),
9
7
  * - resolveLeftoverTarget merges into a position whose FULL leg set (active and
10
8
  * dormant) is exactly the leftover's, else opens new (same-shape ADDs consolidate here).
11
9
  * The asset SET of a position never grows, so positionKey == key(opening symbols).
@@ -5,13 +5,15 @@
5
5
  import type { HttpTypes } from '@pear-protocol/types';
6
6
  import type { MatchedPosition, NeededSynthetic } from './types';
7
7
  /**
8
- * Assemble the final ApplicablePosition records: resolve stable per-slice fill
9
- * ids once across all positions, then build each applicable (dropping unchanged
10
- * positions that produced no fills).
8
+ * Assemble canonical receipts once and position assignments separately, dropping
9
+ * unchanged positions that produced no fills.
11
10
  */
12
11
  export declare function assembleApplicables(args: {
13
12
  positions: MatchedPosition[];
14
13
  connector: HttpTypes.Connector;
15
14
  synthByPosition: Map<MatchedPosition, NeededSynthetic[]>;
16
15
  now: number;
17
- }): HttpTypes.ApplicablePosition[];
16
+ }): {
17
+ fills: HttpTypes.ApplicableFill[];
18
+ applicables: HttpTypes.ApplicablePosition[];
19
+ };
@@ -40,9 +40,10 @@ export declare function alignPositionsWithExchange(args: {
40
40
  positions: MatchedPosition[];
41
41
  synthByPosition: Map<MatchedPosition, NeededSynthetic[]>;
42
42
  };
43
- /** Build the content-addressed synthetic fill for a position/asset/side triple. */
43
+ /** Build the content-addressed synthetic fill for a position lifecycle/asset/side. */
44
44
  export declare function buildSyntheticFill(args: {
45
45
  positionKey: string;
46
+ lifecycleStartedAt: number | null;
46
47
  connector: HttpTypes.Connector;
47
48
  symbol: string;
48
49
  side: Side;
@@ -26,6 +26,8 @@ export type FillPart = {
26
26
  */
27
27
  export type WorkingPosition = {
28
28
  positionKey: string;
29
+ createdAt: number;
30
+ orderId: string;
29
31
  exposure: Exposure;
30
32
  parts: FillPart[];
31
33
  closed: boolean;
@@ -51,6 +53,12 @@ export type MatchedPosition = {
51
53
  exposure: Exposure;
52
54
  /** Origin of the seeded existing position; undefined when this applicable creates a position. */
53
55
  existingOrigin?: HttpTypes.PositionOrigin;
56
+ /**
57
+ * When this lifecycle of the position key began (ms): the stored creation time of a seeded
58
+ * position, else the earliest fill this replay assigned to it. Null for a position the align
59
+ * step mints from the snapshot alone — no fill or stored row dates that lifecycle.
60
+ */
61
+ lifecycleStartedAt: number | null;
54
62
  };
55
63
  /** One combined leg that could not be closed against existing exposure — an open. */
56
64
  export type Leftover = {
@@ -72,11 +80,3 @@ export type NeededSynthetic = {
72
80
  quantity: string;
73
81
  closedReason?: HttpTypes.PositionClosedReason;
74
82
  };
75
- /** Per-slice id + `original` pointer, resolved once across ALL positions so split slices stay unique. */
76
- export type PartId = {
77
- exchangeFillId: string;
78
- original?: {
79
- exchangeFillId: string;
80
- quantity: string;
81
- };
82
- };
@@ -11,6 +11,7 @@ export type ComputeSyncPayloadArgs = {
11
11
  neverSynced: boolean;
12
12
  };
13
13
  export type ComputeSyncPayloadResult = {
14
+ fills: HttpTypes.ApplicableFill[];
14
15
  applicables: HttpTypes.ApplicablePosition[];
15
16
  exchangePositions: HttpTypes.ExternalPosition[];
16
17
  meta: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pear-protocol/utils",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Pear Protocol Utility functions",
5
5
  "private": false,
6
6
  "type": "module",
@@ -23,11 +23,11 @@
23
23
  "test": "vitest run",
24
24
  "build": "tsup && tsc -p tsconfig.build.json",
25
25
  "clean": "rm -rf dist .turbo",
26
- "typecheck": "tsc --noEmit"
26
+ "typecheck": "bunx --package bun@1.4.3 bun --check --threads=2"
27
27
  },
28
28
  "dependencies": {
29
29
  "@noble/hashes": "^1.3.2",
30
- "@pear-protocol/types": "^2.19.0",
30
+ "@pear-protocol/types": "^2.24.0",
31
31
  "@scure/base": "^2.3.0",
32
32
  "bignumber.js": "9.3.1"
33
33
  },