@pear-protocol/exchanges-sdk 0.1.12 → 0.3.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,4 +1,7 @@
1
+ import { parseUnits, parseEther } from 'viem';
1
2
  import { fetchApiKeys, fetchAccount, fetchAccountsByL1Address } from '../../client/lighter/rest';
3
+ import { writeWithdraw, writeDeposit } from './ethereum';
4
+ import { getAssetDecimals } from './types';
2
5
 
3
6
  function createLighterHelper(sdk) {
4
7
  return {
@@ -60,6 +63,28 @@ function createLighterHelper(sdk) {
60
63
  apiKeyIndex: params.apiKeyIndex
61
64
  });
62
65
  return { txHash };
66
+ },
67
+ async deposit(walletClient, params) {
68
+ const txHash = await writeDeposit(walletClient, {
69
+ to: params.to,
70
+ assetIndex: params.assetId,
71
+ routeType: params.routeType,
72
+ // `amount` is a human string in the asset's own units; scale to base units.
73
+ amount: parseUnits(params.amount, getAssetDecimals(params.assetId)),
74
+ // Native-ETH deposits are payable; `nativeValue` is the ETH sent as
75
+ // msg.value (18-decimal wei), separate from the accounting `amount`.
76
+ value: params.nativeValue ? parseEther(params.nativeValue) : void 0
77
+ });
78
+ return { txHash };
79
+ },
80
+ async withdraw(walletClient, params) {
81
+ const txHash = await writeWithdraw(walletClient, {
82
+ accountIndex: params.accountIndex,
83
+ assetIndex: params.assetId,
84
+ routeType: params.routeType,
85
+ baseAmount: parseUnits(params.amount, getAssetDecimals(params.assetId))
86
+ });
87
+ return { txHash };
63
88
  }
64
89
  };
65
90
  }
package/dist/index.d.ts CHANGED
@@ -275,11 +275,19 @@ type ApproveIntegratorParams = {
275
275
  type ApproveIntegratorResult = {
276
276
  txHash: string;
277
277
  };
278
+ type DepositResult = {
279
+ txHash: string;
280
+ };
281
+ type WithdrawResult = {
282
+ txHash: string;
283
+ };
278
284
  interface LighterHelper {
279
285
  lookupAccount(l1Address: string): Promise<LighterLookupResult>;
280
286
  checkIntegratorApproval(accountIndex: number, integratorAccountIndex: number): Promise<boolean>;
281
287
  connectWallet(walletClient: WalletClientWithAccount, params: ConnectWalletParams): Promise<ConnectWalletResult>;
282
288
  approveIntegrator(walletClient: WalletClientWithAccount, params: ApproveIntegratorParams): Promise<ApproveIntegratorResult>;
289
+ deposit(walletClient: WalletClientWithAccount, params: DepositParams): Promise<DepositResult>;
290
+ withdraw(walletClient: WalletClientWithAccount, params: WithdrawParams): Promise<WithdrawResult>;
283
291
  }
284
292
  type LighterLookupResult = {
285
293
  mainAccountIndex: number;
@@ -303,7 +311,8 @@ interface AccountSyncerConfig {
303
311
  demo: boolean;
304
312
  }
305
313
  interface AccountSyncer {
306
- synchronize(): Promise<void>;
314
+ synchronizeFills(): Promise<void>;
315
+ synchronizeFunding(): Promise<void>;
307
316
  }
308
317
  interface ListExternalFillsParams {
309
318
  since: Date;
@@ -311,6 +320,13 @@ interface ListExternalFillsParams {
311
320
  openPositions: HttpTypes.OpenPositionLite[];
312
321
  provisionalFills: HttpTypes.ProvisionalFill[];
313
322
  }
323
+ interface ListExternalPositionsParams {
324
+ instruments: HttpTypes.Instrument[];
325
+ }
326
+ interface ListExternalFundingParams {
327
+ since: Date;
328
+ instruments: HttpTypes.Instrument[];
329
+ }
314
330
  declare abstract class BaseAccountSyncer<ExternalFill> implements AccountSyncer {
315
331
  protected sdk: PearSDK;
316
332
  protected connector: Connector;
@@ -319,9 +335,36 @@ declare abstract class BaseAccountSyncer<ExternalFill> implements AccountSyncer
319
335
  protected ws: ExchangeWsByConnector[Connector];
320
336
  protected demo: boolean;
321
337
  constructor(config: AccountSyncerConfig);
338
+ /**
339
+ * Fetch the account's currently-open positions from the exchange, normalized
340
+ * to BASE units (contractSize applied). The exchange position API is the
341
+ * source of truth for current exposure. Account-wide; no symbol scoping.
342
+ * Hedge-mode (LONG/SHORT legs on one symbol) is rejected — skipped this cycle.
343
+ */
344
+ protected abstract listExternalPositions(params: ListExternalPositionsParams): Promise<HttpTypes.ExternalPosition[]>;
322
345
  protected abstract listExternalFills(params: ListExternalFillsParams): Promise<HttpTypes.UnknownExternalFill[]>;
346
+ /**
347
+ * Fetch the account's historical funding payments since the watermark,
348
+ * applied-rate-only and mapped to instrument ids. Account-wide, no symbol scoping.
349
+ */
350
+ protected abstract listExternalFunding(params: ListExternalFundingParams): Promise<HttpTypes.FundingPaymentEntry[]>;
323
351
  protected abstract isReduceOnly(fill: ExternalFill): boolean;
324
- synchronize(): Promise<void>;
352
+ private loadPearState;
353
+ /**
354
+ * Global per-symbol cost-basis entry of existing open positions, derived by pulling each
355
+ * position's fills and folding them (mirrors example-ui). One call per open position today —
356
+ * can collapse to a single endpoint later.
357
+ */
358
+ private loadEntryBySymbol;
359
+ private snapshotExchange;
360
+ private computeAndPostSync;
361
+ /**
362
+ * Pull historical funding payments since the funding watermark and post them.
363
+ * Independent of the fill sync cycle (`synchronizeFills`) — funding rows are
364
+ * immutable and upsert idempotently, so this is driven on its own cadence.
365
+ */
366
+ synchronizeFunding(): Promise<void>;
367
+ synchronizeFills(): Promise<void>;
325
368
  }
326
369
 
327
370
  interface BinanceRawUserTrade {
@@ -344,8 +387,10 @@ interface BinanceRawUserTrade {
344
387
  declare class BinanceAccountSyncer extends BaseAccountSyncer<BinanceRawUserTrade> {
345
388
  private readonly creds;
346
389
  constructor(config: AccountSyncerConfig);
390
+ protected listExternalPositions({ instruments, }: ListExternalPositionsParams): Promise<HttpTypes.ExternalPosition[]>;
347
391
  protected listExternalFills({ since, instruments, openPositions, provisionalFills, }: ListExternalFillsParams): Promise<HttpTypes.UnknownExternalFill[]>;
348
- protected isReduceOnly(trade: BinanceRawUserTrade): boolean;
392
+ protected listExternalFunding(_params: ListExternalFundingParams): Promise<HttpTypes.FundingPaymentEntry[]>;
393
+ protected isReduceOnly(rawFill: BinanceRawUserTrade): boolean;
349
394
  private collectRequestedInstrumentIds;
350
395
  private mapOrdersBySymbolOrderId;
351
396
  private orderKey;
@@ -368,8 +413,10 @@ interface BybitRawExecution {
368
413
  declare class BybitAccountSyncer extends BaseAccountSyncer<BybitRawExecution> {
369
414
  private readonly creds;
370
415
  constructor(config: AccountSyncerConfig);
416
+ protected listExternalPositions({ instruments, }: ListExternalPositionsParams): Promise<HttpTypes.ExternalPosition[]>;
371
417
  protected listExternalFills({ since }: ListExternalFillsParams): Promise<HttpTypes.UnknownExternalFill[]>;
372
- protected isReduceOnly(execution: BybitRawExecution): boolean;
418
+ protected listExternalFunding(_params: ListExternalFundingParams): Promise<HttpTypes.FundingPaymentEntry[]>;
419
+ protected isReduceOnly(rawFill: BybitRawExecution): boolean;
373
420
  }
374
421
 
375
422
  declare function createAccountSyncer(exchange: Connector, config: AccountSyncerConfig): AccountSyncer;
@@ -450,20 +497,26 @@ type UserFillsByTimeResponse = {
450
497
  };
451
498
  };
452
499
 
453
- declare class HyperliquidAccountSyncer extends BaseAccountSyncer<UserFillsByTimeResponse> {
500
+ type HyperliquidRawFill = UserFillsByTimeResponse;
501
+ declare class HyperliquidAccountSyncer extends BaseAccountSyncer<HyperliquidRawFill> {
454
502
  private readonly address;
455
503
  constructor(config: AccountSyncerConfig);
504
+ protected listExternalPositions({ instruments, }: ListExternalPositionsParams): Promise<HttpTypes.ExternalPosition[]>;
456
505
  protected listExternalFills({ since, instruments, }: ListExternalFillsParams): Promise<HttpTypes.UnknownExternalFill[]>;
457
- protected isReduceOnly(fill: UserFillsByTimeResponse): boolean;
506
+ protected listExternalFunding({ since, instruments, }: ListExternalFundingParams): Promise<HttpTypes.FundingPaymentEntry[]>;
507
+ protected isReduceOnly(rawFill: HyperliquidRawFill): boolean;
458
508
  }
459
509
 
460
- declare class LighterAccountSyncer extends BaseAccountSyncer<LighterRawTrade> {
510
+ type RawFill$1 = LighterRawTrade;
511
+ declare class LighterAccountSyncer extends BaseAccountSyncer<RawFill$1> {
461
512
  private readonly accountIndex;
462
513
  private readonly apiKeyIndex;
463
514
  private readonly apiPrivateKey;
464
515
  constructor(config: AccountSyncerConfig);
516
+ protected listExternalPositions({ instruments, }: ListExternalPositionsParams): Promise<HttpTypes.ExternalPosition[]>;
465
517
  protected listExternalFills({ since, instruments, }: ListExternalFillsParams): Promise<HttpTypes.UnknownExternalFill[]>;
466
- protected isReduceOnly(trade: LighterRawTrade): boolean;
518
+ protected listExternalFunding(_params: ListExternalFundingParams): Promise<HttpTypes.FundingPaymentEntry[]>;
519
+ protected isReduceOnly(rawFill: RawFill$1): boolean;
467
520
  private getAccountSide;
468
521
  private getClientOrderId;
469
522
  private isMaker;
@@ -491,11 +544,14 @@ interface OkxRawFill {
491
544
  ts: string;
492
545
  }
493
546
 
494
- declare class OkxAccountSyncer extends BaseAccountSyncer<OkxRawFill> {
547
+ type RawFill = OkxRawFill;
548
+ declare class OkxAccountSyncer extends BaseAccountSyncer<RawFill> {
495
549
  private readonly creds;
496
550
  constructor(config: AccountSyncerConfig);
551
+ protected listExternalPositions({ instruments, }: ListExternalPositionsParams): Promise<HttpTypes.ExternalPosition[]>;
497
552
  protected listExternalFills({ since, instruments, }: ListExternalFillsParams): Promise<HttpTypes.UnknownExternalFill[]>;
498
- protected isReduceOnly(fill: OkxRawFill): boolean;
553
+ protected listExternalFunding(_params: ListExternalFundingParams): Promise<HttpTypes.FundingPaymentEntry[]>;
554
+ protected isReduceOnly(rawFill: RawFill): boolean;
499
555
  private contractsToBase;
500
556
  }
501
557
 
@@ -814,4 +870,4 @@ declare class ExchangesSDK {
814
870
  private fetchCredentials;
815
871
  }
816
872
 
817
- export { ACCOUNT_TYPE_LABELS, ASSET_DECIMALS, type AccountBalance, type AccountHealthStatus, type AccountPosition, type AccountStatus, type AccountSyncer, type AccountSyncerConfig, type AccountTracker, type AccountTrackerConfig, type AccountType, type AccountWarningReason, type ApiKey, type ApproveIntegratorParams, type ApproveIntegratorResult, type AssetBalance, AssetId, type BalanceCallback, BaseAccountSyncer, BaseAccountTracker, BaseWs, BinanceAccountSyncer, BinanceAccountTracker, type BinanceAccountTrackerConfig, BinanceAccountType, BinanceExchangeWs, BinanceUserDataWs, type BinanceUserDataWsConfig, BinanceWsApi, type BinanceWsApiConfig, BybitAccountSyncer, BybitAccountTracker, type BybitAccountTrackerConfig, BybitAccountType, BybitExchangeWs, BybitWs, type BybitWsConfig, type CheckApiWalletParams, type ConnectOptions, type ConnectWalletParams, type ConnectWalletResult, type CreateAccountTrackerConfig, type CreateExchangeWsConfig, type DepositParams, type ExchangeConnection, type ExchangeHelperExchange, type ExchangeHelperMap, type ExchangeState, ExchangeWs, type ExchangeWsByConnector, ExchangesSDK, type ExchangesSDKConfig, HyperliquidAccountSyncer, HyperliquidAccountTracker, type HyperliquidAccountTrackerConfig, HyperliquidAccountType, HyperliquidExchangeWs, type HyperliquidHelper, HyperliquidWs, LighterAccountSyncer, LighterAccountTracker, type LighterAccountTrackerConfig, LighterAccountType, LighterExchangeWs, type LighterHelper, type LighterLookupResult, LighterWs, type ListExternalFillsParams, type MarginMode, type MessageListener, OkxAccountSyncer, OkxAccountTracker, type OkxAccountTrackerConfig, OkxAccountType, OkxExchangeWs, OkxWs, type OkxWsConfig, type OpenListener, type PositionsCallback, RouteType, type StateUpdate, type SupportedCredentials, type TrackedAssetCallback, type TrackedAssetInfo, type WalletClientWithAccount, type WithdrawParams, type WsMessage, createAccountSyncer, createAccountTracker, createExchangeWs, createHyperliquidHelper, createLighterHelper, getAssetDecimals };
873
+ export { ACCOUNT_TYPE_LABELS, ASSET_DECIMALS, type AccountBalance, type AccountHealthStatus, type AccountPosition, type AccountStatus, type AccountSyncer, type AccountSyncerConfig, type AccountTracker, type AccountTrackerConfig, type AccountType, type AccountWarningReason, type ApiKey, type ApproveIntegratorParams, type ApproveIntegratorResult, type AssetBalance, AssetId, type BalanceCallback, BaseAccountSyncer, BaseAccountTracker, BaseWs, BinanceAccountSyncer, BinanceAccountTracker, type BinanceAccountTrackerConfig, BinanceAccountType, BinanceExchangeWs, BinanceUserDataWs, type BinanceUserDataWsConfig, BinanceWsApi, type BinanceWsApiConfig, BybitAccountSyncer, BybitAccountTracker, type BybitAccountTrackerConfig, BybitAccountType, BybitExchangeWs, BybitWs, type BybitWsConfig, type CheckApiWalletParams, type ConnectOptions, type ConnectWalletParams, type ConnectWalletResult, type CreateAccountTrackerConfig, type CreateExchangeWsConfig, type DepositParams, type DepositResult, type ExchangeConnection, type ExchangeHelperExchange, type ExchangeHelperMap, type ExchangeState, ExchangeWs, type ExchangeWsByConnector, ExchangesSDK, type ExchangesSDKConfig, HyperliquidAccountSyncer, HyperliquidAccountTracker, type HyperliquidAccountTrackerConfig, HyperliquidAccountType, HyperliquidExchangeWs, type HyperliquidHelper, HyperliquidWs, LighterAccountSyncer, LighterAccountTracker, type LighterAccountTrackerConfig, LighterAccountType, LighterExchangeWs, type LighterHelper, type LighterLookupResult, LighterWs, type ListExternalFillsParams, type ListExternalFundingParams, type ListExternalPositionsParams, type MarginMode, type MessageListener, OkxAccountSyncer, OkxAccountTracker, type OkxAccountTrackerConfig, OkxAccountType, OkxExchangeWs, OkxWs, type OkxWsConfig, type OpenListener, type PositionsCallback, RouteType, type StateUpdate, type SupportedCredentials, type TrackedAssetCallback, type TrackedAssetInfo, type WalletClientWithAccount, type WithdrawParams, type WithdrawResult, type WsMessage, createAccountSyncer, createAccountTracker, createExchangeWs, createHyperliquidHelper, createLighterHelper, getAssetDecimals };
@@ -1,5 +1,6 @@
1
- import { computeSyncPayload } from '@pear-protocol/utils';
1
+ import { computeEntryPriceForAsset, computeSyncPayload } from '@pear-protocol/utils';
2
2
 
3
+ const OVERLAP_MS = 6e4;
3
4
  class BaseAccountSyncer {
4
5
  sdk;
5
6
  connector;
@@ -15,46 +16,129 @@ class BaseAccountSyncer {
15
16
  this.ws = config.ws;
16
17
  this.demo = config.demo;
17
18
  }
18
- async synchronize() {
19
- const [{ positions }, { fills: provisionalFills }] = await Promise.all([
19
+ // Phase A: Load current Pear state (positions, fills, instruments, watermark).
20
+ async loadPearState() {
21
+ const [{ positions: openPositions }, { fills: provisionalFills }, { instruments }, watermark] = await Promise.all([
20
22
  this.sdk.core.positions.openLite(),
21
- this.sdk.core.fills.provisionals()
22
- ]);
23
- if (!positions.length && !provisionalFills.length) {
24
- return;
25
- }
26
- const [{ instruments }, since] = await Promise.all([
23
+ this.sdk.core.fills.provisionals(),
27
24
  this.sdk.core.instruments.list({ connector: this.connector }),
28
- this.sdk.core.sync.since({
29
- overlapMs: 6e4,
30
- nowMs: Date.now(),
31
- bootstrapLookbackMs: 30 * 24 * 60 * 60 * 1e3
32
- })
25
+ this.sdk.core.sync.fills.since({ overlapMs: OVERLAP_MS })
33
26
  ]);
27
+ const entryBySymbol = await this.loadEntryBySymbol(openPositions);
28
+ return { openPositions, entryBySymbol, provisionalFills, instruments, watermark };
29
+ }
30
+ /**
31
+ * Global per-symbol cost-basis entry of existing open positions, derived by pulling each
32
+ * position's fills and folding them (mirrors example-ui). One call per open position today —
33
+ * can collapse to a single endpoint later.
34
+ */
35
+ async loadEntryBySymbol(openPositions) {
36
+ const responses = await Promise.all(openPositions.map((position) => this.sdk.core.fills.details(position.id)));
37
+ const fills = responses.flatMap((response) => response.fills).sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
38
+ const fillsBySymbol = /* @__PURE__ */ new Map();
39
+ for (const fill of fills) {
40
+ const list = fillsBySymbol.get(fill.symbol) ?? [];
41
+ list.push(fill);
42
+ fillsBySymbol.set(fill.symbol, list);
43
+ }
44
+ const entryBySymbol = {};
45
+ for (const [symbol, symbolFills] of fillsBySymbol) {
46
+ const entry = computeEntryPriceForAsset(symbolFills);
47
+ if (!entry.isZero()) entryBySymbol[symbol] = entry.toFixed();
48
+ }
49
+ return entryBySymbol;
50
+ }
51
+ // Phase B: Snapshot the exchange — fetch positions and fills since the watermark.
52
+ async snapshotExchange(state) {
53
+ const { openPositions, provisionalFills, instruments, watermark } = state;
34
54
  const externalFills = await this.listExternalFills({
35
- since: new Date(since.since),
55
+ since: new Date(watermark.sinceMs),
36
56
  instruments,
37
- openPositions: positions,
57
+ openPositions,
38
58
  provisionalFills
39
59
  });
60
+ const exchangePositions = await this.listExternalPositions({ instruments });
61
+ return { exchangePositions, externalFills };
62
+ }
63
+ // Phase C: Compute the sync payload and post it across the three sync endpoints.
64
+ async computeAndPostSync(state, snapshot) {
65
+ const { openPositions, entryBySymbol, provisionalFills, watermark } = state;
66
+ const { exchangePositions, externalFills } = snapshot;
40
67
  const payload = await computeSyncPayload({
41
- openPositions: positions,
68
+ tradeAccountId: this.account.id,
69
+ connector: this.connector,
70
+ openPositions,
71
+ entryBySymbol,
42
72
  provisionalFills,
43
- confirmedExchangeFillIds: since.confirmedExchangeFillIds ?? [],
44
- externalFills
73
+ confirmedExchangeFillIds: watermark.confirmedExchangeFillIds ?? [],
74
+ externalFills,
75
+ exchangePositions,
76
+ neverSynced: watermark.neverSynced
45
77
  });
46
78
  if (!payload) {
47
79
  return;
48
80
  }
49
81
  if (payload.reconcilables) {
50
- await this.sdk.core.sync.reconcile(payload.reconcilables);
82
+ await this.sdk.core.sync.fills.reconcile(payload.reconcilables);
51
83
  }
52
84
  if (payload.confirmables) {
53
- await this.sdk.core.sync.confirm(payload.confirmables);
85
+ await this.sdk.core.sync.fills.confirm(payload.confirmables);
86
+ }
87
+ const { firstSyncTrim } = payload;
88
+ let meta = payload.meta;
89
+ if (firstSyncTrim) {
90
+ console.log(
91
+ `${this.connector}: first-sync trim \u2014 dropped ${firstSyncTrim.trimmedCount} pre-cutoff fills (cutoffTs=${firstSyncTrim.cutoffTs})`
92
+ );
93
+ if (meta.externalMaxTs !== null && firstSyncTrim.trimmedMaxTs >= meta.externalMaxTs - OVERLAP_MS) {
94
+ meta = { ...meta, externalMaxTs: meta.externalMaxTs + OVERLAP_MS + 1 };
95
+ }
96
+ }
97
+ const createBody = {
98
+ applicables: payload.applicables,
99
+ exchangePositions: payload.exchangePositions,
100
+ meta
101
+ };
102
+ const bytes = new Blob([JSON.stringify(createBody)]).size;
103
+ if (bytes > 50 * 1024) {
104
+ await this.sdk.core.sync.fills.createBulk(createBody);
105
+ return;
106
+ }
107
+ await this.sdk.core.sync.fills.create(createBody);
108
+ }
109
+ /**
110
+ * Pull historical funding payments since the funding watermark and post them.
111
+ * Independent of the fill sync cycle (`synchronizeFills`) — funding rows are
112
+ * immutable and upsert idempotently, so this is driven on its own cadence.
113
+ */
114
+ async synchronizeFunding() {
115
+ const [{ instruments }, watermark] = await Promise.all([
116
+ this.sdk.core.instruments.list({ connector: this.connector }),
117
+ this.sdk.core.sync.funding.since({ overlapMs: 6e4 })
118
+ ]);
119
+ const payments = await this.listExternalFunding({ since: new Date(watermark.sinceMs), instruments });
120
+ if (payments.length === 0) {
121
+ return;
122
+ }
123
+ const externalMaxTs = Math.max(...payments.map((payment) => payment.timestamp));
124
+ const createBody = { payments, meta: { externalMaxTs } };
125
+ const bytes = new Blob([JSON.stringify(createBody)]).size;
126
+ if (bytes > 50 * 1024) {
127
+ await this.sdk.core.sync.funding.createBulk(createBody);
128
+ return;
54
129
  }
55
- if (payload.creatables) {
56
- await this.sdk.core.sync.create(payload.creatables);
130
+ await this.sdk.core.sync.funding.create(createBody);
131
+ }
132
+ async synchronizeFills() {
133
+ const state = await this.loadPearState();
134
+ let snapshot;
135
+ try {
136
+ snapshot = await this.snapshotExchange(state);
137
+ } catch (error) {
138
+ console.error(`${this.connector}: position/fill fetch failed \u2014 sync cycle aborted, retry next tick`, error);
139
+ return;
57
140
  }
141
+ await this.computeAndPostSync(state, snapshot);
58
142
  }
59
143
  }
60
144
 
@@ -0,0 +1,132 @@
1
+ import { describe, afterEach, vi, it, expect } from 'vitest';
2
+ import { BaseAccountSyncer } from './base';
3
+
4
+ function extFill(overrides = {}) {
5
+ return {
6
+ exchangeFillId: "tx-default",
7
+ cloid: null,
8
+ connector: "hyperliquid",
9
+ symbol: "BTC",
10
+ price: "100",
11
+ quantity: "1",
12
+ side: "BUY",
13
+ reduceOnly: false,
14
+ timestamp: 1e3,
15
+ tradeFee: "0",
16
+ builderFee: "0",
17
+ liquidation: null,
18
+ ...overrides
19
+ };
20
+ }
21
+ function exchangePos(overrides = {}) {
22
+ return { symbol: "BTC", size: "1", side: "BUY", entryPrice: "100", markPrice: "100", ...overrides };
23
+ }
24
+ class FakeSyncer extends BaseAccountSyncer {
25
+ positionsFn = vi.fn();
26
+ fillsFn = vi.fn();
27
+ listExternalPositions(params) {
28
+ return this.positionsFn(params);
29
+ }
30
+ listExternalFills(params) {
31
+ return this.fillsFn(params);
32
+ }
33
+ listExternalFunding() {
34
+ return Promise.resolve([]);
35
+ }
36
+ isReduceOnly() {
37
+ return false;
38
+ }
39
+ }
40
+ function makeSyncer(args) {
41
+ const fills = {
42
+ since: vi.fn().mockResolvedValue({ sinceMs: 0, neverSynced: args.neverSynced, confirmedExchangeFillIds: [] }),
43
+ reconcile: vi.fn().mockResolvedValue({}),
44
+ confirm: vi.fn().mockResolvedValue({}),
45
+ create: vi.fn().mockResolvedValue({}),
46
+ createBulk: vi.fn().mockResolvedValue({})
47
+ };
48
+ const sync = { fills };
49
+ const sdk = {
50
+ core: {
51
+ positions: { openLite: vi.fn().mockResolvedValue({ positions: [] }) },
52
+ fills: { provisionals: vi.fn().mockResolvedValue({ fills: [] }), details: vi.fn() },
53
+ instruments: { list: vi.fn().mockResolvedValue({ instruments: [] }) },
54
+ sync
55
+ }
56
+ };
57
+ const config = {
58
+ sdk,
59
+ connector: "hyperliquid",
60
+ account: { id: "acct-1" },
61
+ credentials: {},
62
+ ws: {},
63
+ demo: false
64
+ };
65
+ return { syncer: new FakeSyncer(config), sync };
66
+ }
67
+ const NEWEST_TS = 105e4;
68
+ function trimmableFills(btcEraTs) {
69
+ return [
70
+ extFill({ exchangeFillId: "a", symbol: "BTC", side: "BUY", quantity: "1", timestamp: btcEraTs.open }),
71
+ extFill({ exchangeFillId: "b", symbol: "BTC", side: "SELL", quantity: "1", timestamp: btcEraTs.close }),
72
+ extFill({ exchangeFillId: "e", symbol: "SOL", side: "BUY", quantity: "2", timestamp: NEWEST_TS })
73
+ ];
74
+ }
75
+ const SOL_SNAPSHOT = [exchangePos({ symbol: "SOL", size: "2" })];
76
+ describe("BaseAccountSyncer \u2014 first-sync plumbing", () => {
77
+ afterEach(() => {
78
+ vi.restoreAllMocks();
79
+ });
80
+ it("neverSynced=true: the posted snapshot is fetched AFTER the fill stream", async () => {
81
+ const { syncer, sync } = makeSyncer({ neverSynced: true });
82
+ syncer.positionsFn.mockResolvedValue(SOL_SNAPSHOT);
83
+ syncer.fillsFn.mockResolvedValue([
84
+ extFill({ exchangeFillId: "e", symbol: "SOL", side: "BUY", quantity: "2", timestamp: NEWEST_TS })
85
+ ]);
86
+ await syncer.synchronizeFills();
87
+ expect(syncer.positionsFn).toHaveBeenCalledTimes(1);
88
+ expect(syncer.fillsFn.mock.invocationCallOrder[0]).toBeLessThan(
89
+ syncer.positionsFn.mock.invocationCallOrder[0] ?? 0
90
+ );
91
+ expect(sync.fills.create).toHaveBeenCalledTimes(1);
92
+ expect(sync.fills.create.mock.calls[0]?.[0]?.exchangePositions).toBe(SOL_SNAPSHOT);
93
+ });
94
+ it("bumps the posted watermark past the overlap window when trimmed fills fall inside it", async () => {
95
+ const { syncer, sync } = makeSyncer({ neverSynced: true });
96
+ syncer.positionsFn.mockResolvedValue(SOL_SNAPSHOT);
97
+ syncer.fillsFn.mockResolvedValue(trimmableFills({ open: 1e6, close: NEWEST_TS - 2e4 }));
98
+ await syncer.synchronizeFills();
99
+ expect(sync.fills.create.mock.calls[0]?.[0]?.meta.externalMaxTs).toBe(NEWEST_TS + 6e4 + 1);
100
+ });
101
+ it("leaves the watermark unbumped when the trimmed era is entirely older than the overlap window", async () => {
102
+ const { syncer, sync } = makeSyncer({ neverSynced: true });
103
+ syncer.positionsFn.mockResolvedValue(SOL_SNAPSHOT);
104
+ syncer.fillsFn.mockResolvedValue(trimmableFills({ open: 1e5, close: 2e5 }));
105
+ await syncer.synchronizeFills();
106
+ expect(sync.fills.create.mock.calls[0]?.[0]?.meta.externalMaxTs).toBe(NEWEST_TS);
107
+ });
108
+ it("logs the trim exactly once when a trim happened", async () => {
109
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {
110
+ });
111
+ const { syncer } = makeSyncer({ neverSynced: true });
112
+ syncer.positionsFn.mockResolvedValue(SOL_SNAPSHOT);
113
+ syncer.fillsFn.mockResolvedValue(trimmableFills({ open: 1e5, close: 2e5 }));
114
+ await syncer.synchronizeFills();
115
+ const trimLogs = logSpy.mock.calls.filter((call) => String(call[0]).includes("first-sync trim"));
116
+ expect(trimLogs).toHaveLength(1);
117
+ expect(String(trimLogs[0]?.[0])).toContain("hyperliquid");
118
+ });
119
+ it("neverSynced=false: positions fetched once, no bump, no trim log", async () => {
120
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {
121
+ });
122
+ const { syncer, sync } = makeSyncer({ neverSynced: false });
123
+ syncer.positionsFn.mockResolvedValue(SOL_SNAPSHOT);
124
+ syncer.fillsFn.mockResolvedValue([
125
+ extFill({ exchangeFillId: "e", symbol: "SOL", side: "BUY", quantity: "2", timestamp: NEWEST_TS })
126
+ ]);
127
+ await syncer.synchronizeFills();
128
+ expect(syncer.positionsFn).toHaveBeenCalledTimes(1);
129
+ expect(sync.fills.create.mock.calls[0]?.[0]?.meta.externalMaxTs).toBe(NEWEST_TS);
130
+ expect(logSpy).not.toHaveBeenCalled();
131
+ });
132
+ });
@@ -1,5 +1,5 @@
1
1
  import BigNumber from 'bignumber.js';
2
- import { fetchUserTradesSince, fetchAllOrdersSince } from '../../client/binance/rest';
2
+ import { fetchAllPositionRisk, fetchUserTradesSince, fetchAllOrdersSince } from '../../client/binance/rest';
3
3
  import { BaseAccountSyncer } from '../base';
4
4
 
5
5
  class BinanceAccountSyncer extends BaseAccountSyncer {
@@ -8,6 +8,31 @@ class BinanceAccountSyncer extends BaseAccountSyncer {
8
8
  super(config);
9
9
  this.creds = config.credentials;
10
10
  }
11
+ async listExternalPositions({
12
+ instruments
13
+ }) {
14
+ const contractSizeByInstrumentId = new Map(
15
+ instruments.map((instrument) => [instrument.id, instrument.precision.quantity.contractSize])
16
+ );
17
+ const rawPositions = await fetchAllPositionRisk(this.creds, this.demo);
18
+ return rawPositions.reduce((positions, rawPosition) => {
19
+ const signedSize = new BigNumber(rawPosition.positionAmt);
20
+ if (signedSize.isZero()) {
21
+ return positions;
22
+ }
23
+ const contractSize = contractSizeByInstrumentId.get(rawPosition.symbol) ?? 1;
24
+ const size = signedSize.abs().times(contractSize).toFixed();
25
+ const side = signedSize.isGreaterThan(0) ? "BUY" : "SELL";
26
+ positions.push({
27
+ symbol: rawPosition.symbol,
28
+ size,
29
+ side,
30
+ entryPrice: rawPosition.entryPrice,
31
+ markPrice: rawPosition.markPrice
32
+ });
33
+ return positions;
34
+ }, []);
35
+ }
11
36
  async listExternalFills({
12
37
  since,
13
38
  instruments,
@@ -25,33 +50,36 @@ class BinanceAccountSyncer extends BaseAccountSyncer {
25
50
  fetchAllOrdersSince(this.creds, instrumentIds, since, this.demo)
26
51
  ]);
27
52
  const ordersBySymbolOrderId = this.mapOrdersBySymbolOrderId(orders);
28
- return trades.reduce((acc, trade) => {
29
- if (!requestedInstrumentIds.has(trade.symbol)) {
30
- return acc;
53
+ return trades.reduce((fills, rawFill) => {
54
+ if (!requestedInstrumentIds.has(rawFill.symbol)) {
55
+ return fills;
31
56
  }
32
- const order = ordersBySymbolOrderId.get(this.orderKey(trade.symbol, trade.orderId));
33
- acc.push({
57
+ const order = ordersBySymbolOrderId.get(this.orderKey(rawFill.symbol, rawFill.orderId));
58
+ fills.push({
34
59
  connector: "binance",
35
- exchangeFillId: trade.id.toString(),
60
+ exchangeFillId: rawFill.id.toString(),
36
61
  cloid: order?.clientOrderId || null,
37
- symbol: trade.symbol,
38
- price: trade.price,
39
- quantity: trade.qty,
40
- side: trade.side,
41
- reduceOnly: this.isReduceOnly(trade),
42
- timestamp: trade.time,
43
- tradeFee: new BigNumber(trade.commission || 0).abs().toFixed(),
62
+ symbol: rawFill.symbol,
63
+ price: rawFill.price,
64
+ quantity: rawFill.qty,
65
+ side: rawFill.side,
66
+ reduceOnly: this.isReduceOnly(rawFill),
67
+ timestamp: rawFill.time,
68
+ tradeFee: new BigNumber(rawFill.commission || 0).abs().toFixed(),
44
69
  builderFee: "0",
45
70
  liquidation: null
46
71
  });
47
- return acc;
72
+ return fills;
48
73
  }, []);
49
74
  }
50
- isReduceOnly(trade) {
51
- if (trade.positionSide === "LONG") return trade.side === "SELL";
52
- if (trade.positionSide === "SHORT") return trade.side === "BUY";
53
- if (trade.positionSide === "BOTH") {
54
- return !new BigNumber(trade.realizedPnl || 0).isZero();
75
+ async listExternalFunding(_params) {
76
+ throw new Error("funding sync is not implemented for binance");
77
+ }
78
+ isReduceOnly(rawFill) {
79
+ if (rawFill.positionSide === "LONG") return rawFill.side === "SELL";
80
+ if (rawFill.positionSide === "SHORT") return rawFill.side === "BUY";
81
+ if (rawFill.positionSide === "BOTH") {
82
+ return !new BigNumber(rawFill.realizedPnl || 0).isZero();
55
83
  }
56
84
  return false;
57
85
  }
@@ -1,5 +1,5 @@
1
1
  import BigNumber from 'bignumber.js';
2
- import { fetchExecutionsSince } from '../../client/bybit/rest';
2
+ import { fetchPositions, fetchExecutionsSince } from '../../client/bybit/rest';
3
3
  import { BaseAccountSyncer } from '../base';
4
4
 
5
5
  class BybitAccountSyncer extends BaseAccountSyncer {
@@ -8,28 +8,52 @@ class BybitAccountSyncer extends BaseAccountSyncer {
8
8
  super(config);
9
9
  this.creds = config.credentials;
10
10
  }
11
+ async listExternalPositions({
12
+ instruments
13
+ }) {
14
+ const rawPositions = await fetchPositions(this.creds, "USDT", this.demo);
15
+ const contractSizeByInstrumentId = new Map(
16
+ instruments.map((instrument) => [instrument.id, instrument.precision.quantity.contractSize])
17
+ );
18
+ return rawPositions.reduce((positions, rawPosition) => {
19
+ if (!rawPosition.size || rawPosition.size === "0" || !rawPosition.side) return positions;
20
+ const contractSize = contractSizeByInstrumentId.get(rawPosition.symbol) ?? 1;
21
+ const size = new BigNumber(rawPosition.size).abs().times(contractSize).toFixed();
22
+ positions.push({
23
+ symbol: rawPosition.symbol,
24
+ size,
25
+ side: rawPosition.side === "Buy" ? "BUY" : "SELL",
26
+ entryPrice: rawPosition.avgPrice,
27
+ markPrice: rawPosition.avgPrice
28
+ });
29
+ return positions;
30
+ }, []);
31
+ }
11
32
  async listExternalFills({ since }) {
12
33
  const executions = await fetchExecutionsSince(this.creds, since, this.demo);
13
- return executions.reduce((acc, execution) => {
14
- acc.push({
34
+ return executions.reduce((fills, rawFill) => {
35
+ fills.push({
15
36
  connector: "bybit",
16
- exchangeFillId: execution.execId,
17
- cloid: execution.orderLinkId || null,
18
- symbol: execution.symbol,
19
- price: execution.execPrice,
20
- quantity: execution.execQty,
21
- side: execution.side === "Buy" ? "BUY" : "SELL",
22
- reduceOnly: this.isReduceOnly(execution),
23
- timestamp: Number(execution.execTime),
24
- tradeFee: new BigNumber(execution.execFee || 0).abs().toFixed(),
37
+ exchangeFillId: rawFill.execId,
38
+ cloid: rawFill.orderLinkId || null,
39
+ symbol: rawFill.symbol,
40
+ price: rawFill.execPrice,
41
+ quantity: rawFill.execQty,
42
+ side: rawFill.side === "Buy" ? "BUY" : "SELL",
43
+ reduceOnly: this.isReduceOnly(rawFill),
44
+ timestamp: Number(rawFill.execTime),
45
+ tradeFee: new BigNumber(rawFill.execFee || 0).abs().toFixed(),
25
46
  builderFee: "0",
26
47
  liquidation: null
27
48
  });
28
- return acc;
49
+ return fills;
29
50
  }, []);
30
51
  }
31
- isReduceOnly(execution) {
32
- return Number(execution.closedSize || "0") > 0;
52
+ async listExternalFunding(_params) {
53
+ throw new Error("funding sync is not implemented for bybit");
54
+ }
55
+ isReduceOnly(rawFill) {
56
+ return Number(rawFill.closedSize || "0") > 0;
33
57
  }
34
58
  }
35
59
 
@@ -0,0 +1,24 @@
1
+ import BigNumber from 'bignumber.js';
2
+
3
+ function toFundingPaymentEntries(events, symbolToInstrumentId) {
4
+ return events.reduce((entries, event) => {
5
+ const instrumentId = symbolToInstrumentId[event.delta.coin];
6
+ if (!instrumentId) {
7
+ console.warn(`Instrument symbol ${event.delta.coin} not found in instrument symbol to id map.`);
8
+ return entries;
9
+ }
10
+ const size = new BigNumber(event.delta.szi);
11
+ const fundingPerUnit = size.isZero() ? "0" : new BigNumber(event.delta.usdc).div(size).toFixed();
12
+ entries.push({
13
+ connector: "hyperliquid",
14
+ instrumentId,
15
+ fundingRate: event.delta.fundingRate,
16
+ notional: event.delta.usdc,
17
+ fundingPerUnit,
18
+ timestamp: event.time
19
+ });
20
+ return entries;
21
+ }, []);
22
+ }
23
+
24
+ export { toFundingPaymentEntries };
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { toFundingPaymentEntries } from './funding';
3
+
4
+ const fundingEvent = (over) => ({
5
+ time: over.time,
6
+ hash: "0xabc",
7
+ delta: {
8
+ type: "funding",
9
+ coin: over.coin,
10
+ usdc: over.usdc ?? "1.5",
11
+ szi: over.szi ?? "10",
12
+ fundingRate: over.fundingRate,
13
+ nSamples: 1
14
+ }
15
+ });
16
+ describe("toFundingPaymentEntries", () => {
17
+ const map = {
18
+ BTC: "HL-BTC",
19
+ ETH: "HL-ETH"
20
+ };
21
+ it("maps a funding event to a funding-payment entry (rate + notional + per-size)", () => {
22
+ const result = toFundingPaymentEntries(
23
+ [fundingEvent({ coin: "BTC", fundingRate: "-0.0001", usdc: "-2.5", szi: "5", time: 1e3 })],
24
+ map
25
+ );
26
+ expect(result).toEqual([
27
+ {
28
+ connector: "hyperliquid",
29
+ instrumentId: "HL-BTC",
30
+ fundingRate: "-0.0001",
31
+ notional: "-2.5",
32
+ // notional / szi = -2.5 / 5
33
+ fundingPerUnit: "-0.5",
34
+ timestamp: 1e3
35
+ }
36
+ ]);
37
+ });
38
+ it("uses signed division by szi (per-size is the attribution coefficient)", () => {
39
+ const result = toFundingPaymentEntries(
40
+ [fundingEvent({ coin: "BTC", fundingRate: "0.0001", usdc: "2.5", szi: "-5", time: 1e3 })],
41
+ map
42
+ );
43
+ expect(result[0]?.fundingPerUnit).toBe("-0.5");
44
+ });
45
+ it("uses zero per-size when size is zero (no divide by zero)", () => {
46
+ const result = toFundingPaymentEntries(
47
+ [fundingEvent({ coin: "BTC", fundingRate: "0.0001", usdc: "0", szi: "0", time: 1e3 })],
48
+ map
49
+ );
50
+ expect(result[0]?.fundingPerUnit).toBe("0");
51
+ });
52
+ it("skips events whose coin is not in the instrument map", () => {
53
+ const result = toFundingPaymentEntries(
54
+ [
55
+ fundingEvent({ coin: "BTC", fundingRate: "0.0001", time: 1e3 }),
56
+ fundingEvent({ coin: "DOGE", fundingRate: "0.0002", time: 2e3 }),
57
+ fundingEvent({ coin: "ETH", fundingRate: "0.0003", time: 3e3 })
58
+ ],
59
+ map
60
+ );
61
+ expect(result.map((entry) => entry.instrumentId)).toEqual(["HL-BTC", "HL-ETH"]);
62
+ });
63
+ });
@@ -1,5 +1,7 @@
1
+ import BigNumber from 'bignumber.js';
1
2
  import { fetchInfo } from '../../client/hyperliquid/rest';
2
3
  import { BaseAccountSyncer } from '../base';
4
+ import { toFundingPaymentEntries } from './funding';
3
5
 
4
6
  class HyperliquidAccountSyncer extends BaseAccountSyncer {
5
7
  address;
@@ -7,6 +9,47 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
7
9
  super(config);
8
10
  this.address = config.account.exchangeIdentifier;
9
11
  }
12
+ async listExternalPositions({
13
+ instruments
14
+ }) {
15
+ const instrumentByBase = new Map(
16
+ instruments.map((instrument) => [
17
+ instrument.base,
18
+ { id: instrument.id, contractSize: instrument.precision.quantity.contractSize }
19
+ ])
20
+ );
21
+ const perpDexs = await fetchInfo({ type: "perpDexs" });
22
+ const dexNames = perpDexs.map((perpDex) => perpDex?.name ?? "");
23
+ const states = await Promise.all(
24
+ dexNames.map(
25
+ (dex) => fetchInfo({
26
+ type: "clearinghouseState",
27
+ user: this.address,
28
+ dex
29
+ })
30
+ )
31
+ );
32
+ const assetPositions = states.flatMap((state) => state.assetPositions);
33
+ return assetPositions.reduce((positions, { position: rawPosition }) => {
34
+ const szi = new BigNumber(rawPosition.szi);
35
+ if (szi.isZero()) {
36
+ return positions;
37
+ }
38
+ const instrument = instrumentByBase.get(rawPosition.coin);
39
+ if (!instrument) {
40
+ console.warn(`Instrument for coin ${rawPosition.coin} not found.`);
41
+ return positions;
42
+ }
43
+ positions.push({
44
+ symbol: instrument.id,
45
+ size: szi.abs().times(instrument.contractSize).toFixed(),
46
+ side: szi.isPositive() ? "BUY" : "SELL",
47
+ entryPrice: rawPosition.entryPx,
48
+ markPrice: rawPosition.entryPx
49
+ });
50
+ return positions;
51
+ }, []);
52
+ }
10
53
  async listExternalFills({
11
54
  since,
12
55
  instruments
@@ -15,37 +58,100 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
15
58
  acc[instrument.base] = instrument.id;
16
59
  return acc;
17
60
  }, {});
18
- const result = await fetchInfo({
19
- type: "userFillsByTime",
20
- user: this.address,
21
- startTime: since.getTime(),
22
- aggregateByTime: true
23
- });
24
- return result.reduce((acc, fill) => {
25
- const instrumentId = instrumentSymbolToIdMap[fill.coin];
61
+ const PAGE_LIMIT = 2e3;
62
+ const allFills = [];
63
+ const seenTids = /* @__PURE__ */ new Set();
64
+ const addFill = (fill) => {
65
+ if (!seenTids.has(fill.tid)) {
66
+ seenTids.add(fill.tid);
67
+ allFills.push(fill);
68
+ }
69
+ };
70
+ let cursor = since.getTime();
71
+ let batchSize;
72
+ do {
73
+ const batch = await fetchInfo({
74
+ type: "userFillsByTime",
75
+ user: this.address,
76
+ startTime: cursor,
77
+ aggregateByTime: true
78
+ });
79
+ batchSize = batch.length;
80
+ for (const rawFill of batch) {
81
+ addFill(rawFill);
82
+ }
83
+ if (batchSize === PAGE_LIMIT) {
84
+ cursor = batch[batch.length - 1]?.time ?? cursor;
85
+ }
86
+ } while (batchSize === PAGE_LIMIT);
87
+ cursor = since.getTime();
88
+ do {
89
+ const twapBatch = await fetchInfo({
90
+ type: "userTwapSliceFillsByTime",
91
+ user: this.address,
92
+ startTime: cursor,
93
+ aggregateByTime: true
94
+ });
95
+ batchSize = twapBatch.length;
96
+ for (const item of twapBatch) {
97
+ addFill(item.fill);
98
+ }
99
+ if (batchSize === PAGE_LIMIT) {
100
+ cursor = twapBatch[twapBatch.length - 1]?.fill.time ?? cursor;
101
+ }
102
+ } while (batchSize === PAGE_LIMIT);
103
+ allFills.sort((a, b) => a.time - b.time);
104
+ return allFills.reduce((fills, rawFill) => {
105
+ const instrumentId = instrumentSymbolToIdMap[rawFill.coin];
26
106
  if (!instrumentId) {
27
- console.warn(`Instrument symbol ${fill.coin} not found in instrument symbol to id map.`);
28
- return acc;
107
+ console.warn(`Instrument symbol ${rawFill.coin} not found in instrument symbol to id map.`);
108
+ return fills;
29
109
  }
30
- acc.push({
110
+ fills.push({
31
111
  connector: "hyperliquid",
32
- exchangeFillId: fill.tid.toFixed(),
33
- cloid: fill.cloid ?? null,
112
+ exchangeFillId: rawFill.tid.toFixed(),
113
+ cloid: rawFill.cloid ?? null,
34
114
  symbol: instrumentId,
35
- price: fill.px,
36
- quantity: fill.sz,
37
- side: fill.side === "B" ? "BUY" : "SELL",
38
- reduceOnly: this.isReduceOnly(fill),
39
- timestamp: fill.time,
40
- tradeFee: fill.fee,
41
- builderFee: fill.builderFee ?? "0",
42
- liquidation: fill.liquidation ? { mark: fill.liquidation.markPx } : null
115
+ price: rawFill.px,
116
+ quantity: rawFill.sz,
117
+ side: rawFill.side === "B" ? "BUY" : "SELL",
118
+ reduceOnly: this.isReduceOnly(rawFill),
119
+ timestamp: rawFill.time,
120
+ tradeFee: rawFill.fee,
121
+ builderFee: rawFill.builderFee ?? "0",
122
+ liquidation: rawFill.liquidation ? { mark: rawFill.liquidation.markPx } : null
43
123
  });
44
- return acc;
124
+ return fills;
45
125
  }, []);
46
126
  }
47
- isReduceOnly(fill) {
48
- return fill.dir.includes("Close");
127
+ async listExternalFunding({
128
+ since,
129
+ instruments
130
+ }) {
131
+ const instrumentSymbolToIdMap = instruments.reduce((acc, instrument) => {
132
+ acc[instrument.base] = instrument.id;
133
+ return acc;
134
+ }, {});
135
+ const PAGE_LIMIT = 500;
136
+ const allEvents = [];
137
+ let cursor = since.getTime();
138
+ let batchSize;
139
+ do {
140
+ const batch = await fetchInfo({
141
+ type: "userFunding",
142
+ user: this.address,
143
+ startTime: cursor
144
+ });
145
+ batchSize = batch.length;
146
+ allEvents.push(...batch);
147
+ if (batchSize === PAGE_LIMIT) {
148
+ cursor = batch[batch.length - 1].time;
149
+ }
150
+ } while (batchSize === PAGE_LIMIT);
151
+ return toFundingPaymentEntries(allEvents, instrumentSymbolToIdMap);
152
+ }
153
+ isReduceOnly(rawFill) {
154
+ return rawFill.dir.includes("Close");
49
155
  }
50
156
  }
51
157
 
@@ -1,5 +1,5 @@
1
1
  import BigNumber from 'bignumber.js';
2
- import { fetchTradesSince } from '../../client/lighter/rest';
2
+ import { fetchAccount, fetchTradesSince } from '../../client/lighter/rest';
3
3
  import { BaseAccountSyncer } from '../base';
4
4
 
5
5
  const LIGHTER_FEE_TICK_DENOMINATOR = new BigNumber(1e6);
@@ -9,10 +9,35 @@ class LighterAccountSyncer extends BaseAccountSyncer {
9
9
  apiPrivateKey;
10
10
  constructor(config) {
11
11
  super(config);
12
- const creds = config.credentials;
12
+ const credentials = config.credentials;
13
13
  this.accountIndex = Number(config.account.exchangeIdentifier);
14
- this.apiKeyIndex = creds.api_key_index;
15
- this.apiPrivateKey = creds.api_private_key;
14
+ this.apiKeyIndex = credentials.api_key_index;
15
+ this.apiPrivateKey = credentials.api_private_key;
16
+ }
17
+ async listExternalPositions({
18
+ instruments
19
+ }) {
20
+ const contractSizeByInstrumentId = new Map(
21
+ instruments.map((instrument) => [instrument.id, instrument.precision.quantity.contractSize])
22
+ );
23
+ const response = await fetchAccount(this.accountIndex);
24
+ const rawPositions = response.accounts?.[0]?.positions ?? [];
25
+ return rawPositions.reduce((positions, rawPosition) => {
26
+ const instrumentId = rawPosition.market_id.toString();
27
+ const signedSize = new BigNumber(rawPosition.position).times(rawPosition.sign);
28
+ if (signedSize.isZero()) return positions;
29
+ const contractSize = contractSizeByInstrumentId.get(instrumentId) ?? 1;
30
+ const size = signedSize.abs().times(contractSize).toFixed();
31
+ const side = rawPosition.sign === 1 ? "BUY" : "SELL";
32
+ positions.push({
33
+ symbol: instrumentId,
34
+ size,
35
+ side,
36
+ entryPrice: rawPosition.avg_entry_price,
37
+ markPrice: rawPosition.avg_entry_price
38
+ });
39
+ return positions;
40
+ }, []);
16
41
  }
17
42
  async listExternalFills({
18
43
  since,
@@ -25,68 +50,71 @@ class LighterAccountSyncer extends BaseAccountSyncer {
25
50
  });
26
51
  const trades = await fetchTradesSince(this.accountIndex, token, since);
27
52
  const instrumentIds = new Set(instruments.map((instrument) => instrument.id));
28
- return trades.toSorted((a, b) => a.timestamp - b.timestamp).reduce((acc, trade) => {
29
- const instrumentId = trade.market_id.toString();
53
+ return trades.toSorted((a, b) => a.timestamp - b.timestamp).reduce((fills, rawFill) => {
54
+ const instrumentId = rawFill.market_id.toString();
30
55
  if (!instrumentIds.has(instrumentId)) {
31
- console.warn(`Lighter market id ${trade.market_id} not found in instrument id map.`);
32
- return acc;
56
+ console.warn(`Lighter market id ${rawFill.market_id} not found in instrument id map.`);
57
+ return fills;
33
58
  }
34
- const accountSide = this.getAccountSide(trade);
35
- if (!accountSide) return acc;
59
+ const accountSide = this.getAccountSide(rawFill);
60
+ if (!accountSide) return fills;
36
61
  const side = accountSide === "ask" ? "SELL" : "BUY";
37
- acc.push({
62
+ fills.push({
38
63
  connector: "lighter",
39
- exchangeFillId: trade.trade_id_str ?? trade.trade_id.toString(),
40
- cloid: this.getClientOrderId(trade, accountSide),
64
+ exchangeFillId: rawFill.trade_id_str ?? rawFill.trade_id.toString(),
65
+ cloid: this.getClientOrderId(rawFill, accountSide),
41
66
  symbol: instrumentId,
42
- price: trade.price,
43
- quantity: trade.size,
67
+ price: rawFill.price,
68
+ quantity: rawFill.size,
44
69
  side,
45
- reduceOnly: this.isReduceOnly(trade),
46
- timestamp: trade.timestamp,
47
- tradeFee: this.getTradeFee(trade, accountSide),
48
- builderFee: this.getIntegratorFee(trade, accountSide),
49
- liquidation: trade.type === "liquidation" ? { mark: trade.price } : null
70
+ reduceOnly: this.isReduceOnly(rawFill),
71
+ timestamp: rawFill.timestamp,
72
+ tradeFee: this.getTradeFee(rawFill, accountSide),
73
+ builderFee: this.getIntegratorFee(rawFill, accountSide),
74
+ liquidation: rawFill.type === "liquidation" ? { mark: rawFill.price } : null
50
75
  });
51
- return acc;
76
+ return fills;
52
77
  }, []);
53
78
  }
54
- isReduceOnly(trade) {
55
- if (trade.type === "liquidation" || trade.type === "deleverage" || trade.type === "market-settlement") {
79
+ async listExternalFunding(_params) {
80
+ throw new Error("funding sync is not implemented for lighter");
81
+ }
82
+ isReduceOnly(rawFill) {
83
+ if (rawFill.type === "liquidation" || rawFill.type === "deleverage" || rawFill.type === "market-settlement") {
56
84
  return true;
57
85
  }
58
- const accountSide = this.getAccountSide(trade);
86
+ const accountSide = this.getAccountSide(rawFill);
59
87
  if (!accountSide) {
60
88
  return false;
61
89
  }
62
- const positionSizeBefore = this.getPositionSizeBefore(trade, accountSide);
90
+ const positionSizeBefore = this.getPositionSizeBefore(rawFill, accountSide);
63
91
  if (positionSizeBefore.isZero()) {
64
92
  return false;
65
93
  }
66
94
  return positionSizeBefore.isPositive() && accountSide === "ask" || positionSizeBefore.isNegative() && accountSide === "bid";
67
95
  }
68
- getAccountSide(trade) {
69
- if (trade.ask_account_id === this.accountIndex) return "ask";
70
- if (trade.bid_account_id === this.accountIndex) return "bid";
96
+ getAccountSide(rawFill) {
97
+ if (rawFill.ask_account_id === this.accountIndex) return "ask";
98
+ if (rawFill.bid_account_id === this.accountIndex) return "bid";
71
99
  return null;
72
100
  }
73
- getClientOrderId(trade, side) {
74
- return (side === "ask" ? trade.ask_client_id_str : trade.bid_client_id_str) || null;
101
+ getClientOrderId(rawFill, side) {
102
+ return (side === "ask" ? rawFill.ask_client_id_str : rawFill.bid_client_id_str) || null;
75
103
  }
76
- isMaker(trade, side) {
77
- return side === "ask" ? trade.is_maker_ask : !trade.is_maker_ask;
104
+ isMaker(rawFill, side) {
105
+ return side === "ask" ? rawFill.is_maker_ask : !rawFill.is_maker_ask;
78
106
  }
79
- getPositionSizeBefore(trade, side) {
80
- const value = this.isMaker(trade, side) ? trade.maker_position_size_before : trade.taker_position_size_before;
107
+ getPositionSizeBefore(rawFill, side) {
108
+ const value = this.isMaker(rawFill, side) ? rawFill.maker_position_size_before : rawFill.taker_position_size_before;
81
109
  return new BigNumber(value ?? 0);
82
110
  }
83
- getTradeFee(trade, side) {
84
- const rateTick = this.isMaker(trade, side) ? trade.maker_fee : trade.taker_fee;
85
- return this.feeTickToAmount(rateTick, trade.usd_amount);
111
+ getTradeFee(rawFill, side) {
112
+ const rateTick = this.isMaker(rawFill, side) ? rawFill.maker_fee : rawFill.taker_fee;
113
+ return this.feeTickToAmount(rateTick, rawFill.usd_amount);
86
114
  }
87
- getIntegratorFee(trade, side) {
88
- const rateTick = this.isMaker(trade, side) ? trade.integrator_maker_fee : trade.integrator_taker_fee;
89
- return this.feeTickToAmount(rateTick, trade.usd_amount);
115
+ getIntegratorFee(rawFill, side) {
116
+ const rateTick = this.isMaker(rawFill, side) ? rawFill.integrator_maker_fee : rawFill.integrator_taker_fee;
117
+ return this.feeTickToAmount(rateTick, rawFill.usd_amount);
90
118
  }
91
119
  // Lighter fees come as ppm rates (÷1e6), not amounts — the charged fee is that rate times the trade's USD notional.
92
120
  feeTickToAmount(rateTick, usdAmount) {
@@ -1,5 +1,5 @@
1
1
  import BigNumber from 'bignumber.js';
2
- import { fetchFillsHistorySince } from '../../client/okx/rest';
2
+ import { fetchPositions, fetchFillsHistorySince } from '../../client/okx/rest';
3
3
  import { BaseAccountSyncer } from '../base';
4
4
 
5
5
  class OkxAccountSyncer extends BaseAccountSyncer {
@@ -8,6 +8,27 @@ class OkxAccountSyncer extends BaseAccountSyncer {
8
8
  super(config);
9
9
  this.creds = config.credentials;
10
10
  }
11
+ async listExternalPositions({
12
+ instruments
13
+ }) {
14
+ const rawPositions = await fetchPositions(this.creds, this.demo);
15
+ const contractSizeByInstrumentId = new Map(
16
+ instruments.map((instrument) => [instrument.id, instrument.precision.quantity.contractSize])
17
+ );
18
+ return rawPositions.reduce((positions, rawPosition) => {
19
+ if (!rawPosition.pos || rawPosition.pos === "0") return positions;
20
+ const contractSize = contractSizeByInstrumentId.get(rawPosition.instId) ?? 1;
21
+ const signedSize = new BigNumber(rawPosition.pos);
22
+ positions.push({
23
+ symbol: rawPosition.instId,
24
+ size: signedSize.abs().times(contractSize).toFixed(),
25
+ side: signedSize.isGreaterThan(0) ? "BUY" : "SELL",
26
+ entryPrice: rawPosition.avgPx,
27
+ markPrice: rawPosition.avgPx
28
+ });
29
+ return positions;
30
+ }, []);
31
+ }
11
32
  async listExternalFills({
12
33
  since,
13
34
  instruments
@@ -16,28 +37,31 @@ class OkxAccountSyncer extends BaseAccountSyncer {
16
37
  const contractSizeByInstrumentId = new Map(
17
38
  instruments.map((instrument) => [instrument.id, instrument.precision.quantity.contractSize])
18
39
  );
19
- return fills.reduce((acc, fill) => {
20
- const contractSize = contractSizeByInstrumentId.get(fill.instId) ?? 1;
21
- acc.push({
40
+ return fills.reduce((result, rawFill) => {
41
+ const contractSize = contractSizeByInstrumentId.get(rawFill.instId) ?? 1;
42
+ result.push({
22
43
  connector: "okx",
23
- exchangeFillId: fill.billId || fill.tradeId,
24
- cloid: fill.clOrdId || null,
25
- symbol: fill.instId,
26
- price: fill.fillPx,
27
- quantity: this.contractsToBase(fill.fillSz, contractSize),
28
- side: fill.side === "buy" ? "BUY" : "SELL",
29
- reduceOnly: this.isReduceOnly(fill),
30
- timestamp: Number(fill.fillTime || fill.ts),
31
- tradeFee: new BigNumber(fill.fee || 0).abs().toFixed(),
44
+ exchangeFillId: rawFill.billId || rawFill.tradeId,
45
+ cloid: rawFill.clOrdId || null,
46
+ symbol: rawFill.instId,
47
+ price: rawFill.fillPx,
48
+ quantity: this.contractsToBase(rawFill.fillSz, contractSize),
49
+ side: rawFill.side === "buy" ? "BUY" : "SELL",
50
+ reduceOnly: this.isReduceOnly(rawFill),
51
+ timestamp: Number(rawFill.fillTime || rawFill.ts),
52
+ tradeFee: new BigNumber(rawFill.fee || 0).abs().toFixed(),
32
53
  builderFee: "0",
33
54
  liquidation: null
34
55
  });
35
- return acc;
56
+ return result;
36
57
  }, []);
37
58
  }
38
- isReduceOnly(fill) {
39
- if (fill.posSide === "long") return fill.side === "sell";
40
- if (fill.posSide === "short") return fill.side === "buy";
59
+ async listExternalFunding(_params) {
60
+ throw new Error("funding sync is not implemented for okx");
61
+ }
62
+ isReduceOnly(rawFill) {
63
+ if (rawFill.posSide === "long") return rawFill.side === "sell";
64
+ if (rawFill.posSide === "short") return rawFill.side === "buy";
41
65
  return false;
42
66
  }
43
67
  contractsToBase(quantity, contractSize) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pear-protocol/exchanges-sdk",
3
- "version": "0.1.12",
3
+ "version": "0.3.0",
4
4
  "description": "Pear Protocol Exchanges SDK",
5
5
  "private": false,
6
6
  "type": "module",
@@ -26,9 +26,9 @@
26
26
  "typecheck": "tsc --noEmit"
27
27
  },
28
28
  "dependencies": {
29
- "@pear-protocol/core-sdk": "^1.0.0",
30
- "@pear-protocol/types": "^1.0.0",
31
- "@pear-protocol/utils": "^0.0.23",
29
+ "@pear-protocol/core-sdk": "^1.5.0",
30
+ "@pear-protocol/types": "^1.6.0",
31
+ "@pear-protocol/utils": "^0.1.0",
32
32
  "bignumber.js": "^11.1.1",
33
33
  "partysocket": "^1.0.3",
34
34
  "viem": "^2.48.8"