@pear-protocol/exchanges-sdk 0.2.0 → 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.
package/dist/index.d.ts CHANGED
@@ -311,7 +311,8 @@ interface AccountSyncerConfig {
311
311
  demo: boolean;
312
312
  }
313
313
  interface AccountSyncer {
314
- synchronize(): Promise<void>;
314
+ synchronizeFills(): Promise<void>;
315
+ synchronizeFunding(): Promise<void>;
315
316
  }
316
317
  interface ListExternalFillsParams {
317
318
  since: Date;
@@ -319,6 +320,13 @@ interface ListExternalFillsParams {
319
320
  openPositions: HttpTypes.OpenPositionLite[];
320
321
  provisionalFills: HttpTypes.ProvisionalFill[];
321
322
  }
323
+ interface ListExternalPositionsParams {
324
+ instruments: HttpTypes.Instrument[];
325
+ }
326
+ interface ListExternalFundingParams {
327
+ since: Date;
328
+ instruments: HttpTypes.Instrument[];
329
+ }
322
330
  declare abstract class BaseAccountSyncer<ExternalFill> implements AccountSyncer {
323
331
  protected sdk: PearSDK;
324
332
  protected connector: Connector;
@@ -327,9 +335,36 @@ declare abstract class BaseAccountSyncer<ExternalFill> implements AccountSyncer
327
335
  protected ws: ExchangeWsByConnector[Connector];
328
336
  protected demo: boolean;
329
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[]>;
330
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[]>;
331
351
  protected abstract isReduceOnly(fill: ExternalFill): boolean;
332
- 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>;
333
368
  }
334
369
 
335
370
  interface BinanceRawUserTrade {
@@ -352,8 +387,10 @@ interface BinanceRawUserTrade {
352
387
  declare class BinanceAccountSyncer extends BaseAccountSyncer<BinanceRawUserTrade> {
353
388
  private readonly creds;
354
389
  constructor(config: AccountSyncerConfig);
390
+ protected listExternalPositions({ instruments, }: ListExternalPositionsParams): Promise<HttpTypes.ExternalPosition[]>;
355
391
  protected listExternalFills({ since, instruments, openPositions, provisionalFills, }: ListExternalFillsParams): Promise<HttpTypes.UnknownExternalFill[]>;
356
- protected isReduceOnly(trade: BinanceRawUserTrade): boolean;
392
+ protected listExternalFunding(_params: ListExternalFundingParams): Promise<HttpTypes.FundingPaymentEntry[]>;
393
+ protected isReduceOnly(rawFill: BinanceRawUserTrade): boolean;
357
394
  private collectRequestedInstrumentIds;
358
395
  private mapOrdersBySymbolOrderId;
359
396
  private orderKey;
@@ -376,8 +413,10 @@ interface BybitRawExecution {
376
413
  declare class BybitAccountSyncer extends BaseAccountSyncer<BybitRawExecution> {
377
414
  private readonly creds;
378
415
  constructor(config: AccountSyncerConfig);
416
+ protected listExternalPositions({ instruments, }: ListExternalPositionsParams): Promise<HttpTypes.ExternalPosition[]>;
379
417
  protected listExternalFills({ since }: ListExternalFillsParams): Promise<HttpTypes.UnknownExternalFill[]>;
380
- protected isReduceOnly(execution: BybitRawExecution): boolean;
418
+ protected listExternalFunding(_params: ListExternalFundingParams): Promise<HttpTypes.FundingPaymentEntry[]>;
419
+ protected isReduceOnly(rawFill: BybitRawExecution): boolean;
381
420
  }
382
421
 
383
422
  declare function createAccountSyncer(exchange: Connector, config: AccountSyncerConfig): AccountSyncer;
@@ -458,20 +497,26 @@ type UserFillsByTimeResponse = {
458
497
  };
459
498
  };
460
499
 
461
- declare class HyperliquidAccountSyncer extends BaseAccountSyncer<UserFillsByTimeResponse> {
500
+ type HyperliquidRawFill = UserFillsByTimeResponse;
501
+ declare class HyperliquidAccountSyncer extends BaseAccountSyncer<HyperliquidRawFill> {
462
502
  private readonly address;
463
503
  constructor(config: AccountSyncerConfig);
504
+ protected listExternalPositions({ instruments, }: ListExternalPositionsParams): Promise<HttpTypes.ExternalPosition[]>;
464
505
  protected listExternalFills({ since, instruments, }: ListExternalFillsParams): Promise<HttpTypes.UnknownExternalFill[]>;
465
- protected isReduceOnly(fill: UserFillsByTimeResponse): boolean;
506
+ protected listExternalFunding({ since, instruments, }: ListExternalFundingParams): Promise<HttpTypes.FundingPaymentEntry[]>;
507
+ protected isReduceOnly(rawFill: HyperliquidRawFill): boolean;
466
508
  }
467
509
 
468
- declare class LighterAccountSyncer extends BaseAccountSyncer<LighterRawTrade> {
510
+ type RawFill$1 = LighterRawTrade;
511
+ declare class LighterAccountSyncer extends BaseAccountSyncer<RawFill$1> {
469
512
  private readonly accountIndex;
470
513
  private readonly apiKeyIndex;
471
514
  private readonly apiPrivateKey;
472
515
  constructor(config: AccountSyncerConfig);
516
+ protected listExternalPositions({ instruments, }: ListExternalPositionsParams): Promise<HttpTypes.ExternalPosition[]>;
473
517
  protected listExternalFills({ since, instruments, }: ListExternalFillsParams): Promise<HttpTypes.UnknownExternalFill[]>;
474
- protected isReduceOnly(trade: LighterRawTrade): boolean;
518
+ protected listExternalFunding(_params: ListExternalFundingParams): Promise<HttpTypes.FundingPaymentEntry[]>;
519
+ protected isReduceOnly(rawFill: RawFill$1): boolean;
475
520
  private getAccountSide;
476
521
  private getClientOrderId;
477
522
  private isMaker;
@@ -499,11 +544,14 @@ interface OkxRawFill {
499
544
  ts: string;
500
545
  }
501
546
 
502
- declare class OkxAccountSyncer extends BaseAccountSyncer<OkxRawFill> {
547
+ type RawFill = OkxRawFill;
548
+ declare class OkxAccountSyncer extends BaseAccountSyncer<RawFill> {
503
549
  private readonly creds;
504
550
  constructor(config: AccountSyncerConfig);
551
+ protected listExternalPositions({ instruments, }: ListExternalPositionsParams): Promise<HttpTypes.ExternalPosition[]>;
505
552
  protected listExternalFills({ since, instruments, }: ListExternalFillsParams): Promise<HttpTypes.UnknownExternalFill[]>;
506
- protected isReduceOnly(fill: OkxRawFill): boolean;
553
+ protected listExternalFunding(_params: ListExternalFundingParams): Promise<HttpTypes.FundingPaymentEntry[]>;
554
+ protected isReduceOnly(rawFill: RawFill): boolean;
507
555
  private contractsToBase;
508
556
  }
509
557
 
@@ -822,4 +870,4 @@ declare class ExchangesSDK {
822
870
  private fetchCredentials;
823
871
  }
824
872
 
825
- 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 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 };
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.2.0",
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"