@hypurrquant/perp-ws 0.15.0 → 0.16.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 +30 -9
- package/dist/index.js +180 -15
- package/dist/node.d.ts +30 -9
- package/dist/node.js +204 -16
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
6
|
* Every perp-cli venue id. "ondo" and "qfex" are included for type stability but have no perp-cli/ws
|
|
7
|
-
* codec
|
|
7
|
+
* codec (planned, not yet available: there is no `ondo` / `qfex` export to pass to createVenueStream).
|
|
8
8
|
*/
|
|
9
9
|
type VenueId = "pacifica" | "hyperliquid" | "lighter" | "aster" | "lighter-rh" | "ondo" | "arcus" | "qfex";
|
|
10
10
|
declare const VENUE_IDS: readonly VenueId[];
|
|
@@ -74,6 +74,10 @@ declare class WsError extends Error {
|
|
|
74
74
|
remediation?: string;
|
|
75
75
|
details?: Record<string, unknown>;
|
|
76
76
|
});
|
|
77
|
+
/** `structured.code` — the same place as `code` on error events (`e.code`). */
|
|
78
|
+
get code(): WsErrorCode;
|
|
79
|
+
/** `structured.remediation` — the same place as `remediation` on error events; null when none. */
|
|
80
|
+
get remediation(): string | null;
|
|
77
81
|
}
|
|
78
82
|
/** Structural check: a PerpError (CLI) or a WsError — anything carrying `structured.{code,message}`. */
|
|
79
83
|
declare function isPerpError(e: unknown): e is {
|
|
@@ -109,6 +113,11 @@ interface WsLike {
|
|
|
109
113
|
wasClean?: boolean;
|
|
110
114
|
}) => void) | null;
|
|
111
115
|
onerror: ((ev: unknown) => void) | null;
|
|
116
|
+
/**
|
|
117
|
+
* Optional: settles once the underlying socket is fully closed (no timer or socket left). The
|
|
118
|
+
* Node transport sets it; `stream.close()` awaits it (bounded) so the process is idle afterwards.
|
|
119
|
+
*/
|
|
120
|
+
readonly whenClosed?: Promise<void>;
|
|
112
121
|
}
|
|
113
122
|
type WsFactory = (url: string) => WsLike;
|
|
114
123
|
type FetchLike = (input: string, init?: {
|
|
@@ -384,7 +393,7 @@ type UserRef = {
|
|
|
384
393
|
venue: "lighter" | "lighter-rh";
|
|
385
394
|
accountIndex: number;
|
|
386
395
|
}
|
|
387
|
-
/** KNOWN GAP
|
|
396
|
+
/** KNOWN GAP: Aster user channels are not implemented (CAPABILITY_UNSUPPORTED; planned, not yet available). */
|
|
388
397
|
| {
|
|
389
398
|
venue: "aster";
|
|
390
399
|
user: `0x${string}`;
|
|
@@ -527,7 +536,7 @@ interface VenueStreamOptions {
|
|
|
527
536
|
canonicalize?: Canonicalize;
|
|
528
537
|
/** Needed for kind:"user". */
|
|
529
538
|
credentials?: CredentialProvider;
|
|
530
|
-
/** Default false. Required for user channels whose auth needs a signing secret in the page (Aster/Ondo/QFEX,
|
|
539
|
+
/** Default false. Required for user channels whose auth needs a signing secret in the page (Aster / Ondo / QFEX — planned, not yet available). */
|
|
531
540
|
allowInPageSecrets?: boolean;
|
|
532
541
|
/** ≤ venue limits. */
|
|
533
542
|
budget?: {
|
|
@@ -807,8 +816,8 @@ declare function createVenueStream(codec: VenueCodec, opts?: VenueStreamOptions)
|
|
|
807
816
|
*/
|
|
808
817
|
declare const USD_LIKE_QUOTES: ReadonlySet<string>;
|
|
809
818
|
/**
|
|
810
|
-
* HIP-3 (dex set) → "<dex>:<BASE>".
|
|
811
|
-
* quote (null), → null (not comparable by default).
|
|
819
|
+
* HIP-3 (dex set) → "<dex>:<BASE>". An ambiguous ticker (isUnclassifiedTicker) → null. A USD-like
|
|
820
|
+
* quote → "<BASE>". Any other quote, or an unknown quote (null), → null (not comparable by default).
|
|
812
821
|
*/
|
|
813
822
|
declare const defaultCanonicalize: Canonicalize;
|
|
814
823
|
|
|
@@ -871,9 +880,17 @@ interface FundingComparator {
|
|
|
871
880
|
subscribe(cb: (rows: FundingRow[], changed: string[]) => void): () => void;
|
|
872
881
|
close(): void;
|
|
873
882
|
}
|
|
874
|
-
/**
|
|
883
|
+
/**
|
|
884
|
+
* Rate per hour as a FRACTION (0.0000125 = 0.00125 %/h): `rate / rateIntervalHours`.
|
|
885
|
+
* @experimental 0.15.x (stable from 0.16.0 after consumer sign-off)
|
|
886
|
+
*/
|
|
875
887
|
declare function toHourly(rate: Dec | null, rateIntervalHours: number | null): number | null;
|
|
876
|
-
/**
|
|
888
|
+
/**
|
|
889
|
+
* Simple annualized rate as a FRACTION, not a percent: `toHourly × 24 × 365` (0.0000125/h → 0.1095,
|
|
890
|
+
* i.e. 10.95 %). The CLI's `annualizedPct` (`arb scan --rates`, MCP `get_funding_rates`) is the same
|
|
891
|
+
* quantity in PERCENT (10.95): multiply `toApr` by 100 to compare the two.
|
|
892
|
+
* @experimental 0.15.x (stable from 0.16.0 after consumer sign-off)
|
|
893
|
+
*/
|
|
877
894
|
declare function toApr(rate: Dec | null, rateIntervalHours: number | null): number | null;
|
|
878
895
|
/** @experimental 0.15.x (stable from 0.16.0 after consumer sign-off) */
|
|
879
896
|
declare function createFundingComparator(opts: FundingComparatorOptions): FundingComparator;
|
|
@@ -916,7 +933,11 @@ declare const pacifica: VenueCodec;
|
|
|
916
933
|
*
|
|
917
934
|
* Units (Phase 0, the key correction): WS `funding_rate` (last settled) and `current_funding_rate`
|
|
918
935
|
* (predicted) are PERCENT per 1 h → converted to a fraction by an exact 2-place decimal shift,
|
|
919
|
-
* rateIntervalHours = settlementIntervalHours = 1. `open_interest` is USD notional (not base)
|
|
936
|
+
* rateIntervalHours = settlementIntervalHours = 1. `open_interest` is USD notional (not base) and
|
|
937
|
+
* one side — INFERRED, not documented by the venue: WS open_interest / mark = REST orderBookDetails
|
|
938
|
+
* `open_interest` (base) (2026-10-19, BTC: 174.88 M USD / 82,387 = 2,122.7 BTC), and the venue's
|
|
939
|
+
* exchangeMetrics open_interest — a DAILY snapshot, two days old when compared — was ≈ 2× the
|
|
940
|
+
* current one-side figure (336.6 M USD).
|
|
920
941
|
* Errors arrive as {error:{code,message}} with no channel field (matched by the channel name in
|
|
921
942
|
* the message). Spot markets (market_type "spot" in /api/v1/orderBooks) are not perps and skipped.
|
|
922
943
|
*
|
|
@@ -928,7 +949,7 @@ declare const lighter: VenueCodec;
|
|
|
928
949
|
declare const lighterRh: VenueCodec;
|
|
929
950
|
|
|
930
951
|
/**
|
|
931
|
-
* Aster codec (§6.1, public only
|
|
952
|
+
* Aster codec (§6.1, public channels only). One combined-stream socket (`/stream`, frames
|
|
932
953
|
* {stream, data}) with `!markPrice@arr@1s` (every symbol, 1 s) + `!ticker@arr` (only changed symbols).
|
|
933
954
|
* r = accruing current-period rate (Phase 0: == premiumIndex.lastFundingRate ≠ last settled).
|
|
934
955
|
* The funding interval is per market from the LABELLED REST source `rest:/fapi/v3/fundingInfo`
|
package/dist/index.js
CHANGED
|
@@ -37,6 +37,14 @@ var WsError = class extends Error {
|
|
|
37
37
|
...opts?.details !== void 0 ? { details: opts.details } : {}
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
|
+
/** `structured.code` — the same place as `code` on error events (`e.code`). */
|
|
41
|
+
get code() {
|
|
42
|
+
return this.structured.code;
|
|
43
|
+
}
|
|
44
|
+
/** `structured.remediation` — the same place as `remediation` on error events; null when none. */
|
|
45
|
+
get remediation() {
|
|
46
|
+
return this.structured.remediation === void 0 ? null : this.structured.remediation;
|
|
47
|
+
}
|
|
40
48
|
};
|
|
41
49
|
function isPerpError(e) {
|
|
42
50
|
if (e === null || typeof e !== "object") return false;
|
|
@@ -45,11 +53,89 @@ function isPerpError(e) {
|
|
|
45
53
|
return typeof s.code === "string" && typeof s.message === "string";
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
// src/ws/unclassified.ts
|
|
57
|
+
var UNCLASSIFIED_TICKERS = {
|
|
58
|
+
hyperliquid: /* @__PURE__ */ new Set(["SPX"]),
|
|
59
|
+
aster: /* @__PURE__ */ new Set(["SPX"]),
|
|
60
|
+
pacifica: /* @__PURE__ */ new Set(["SPX", "BP", "CHIP"]),
|
|
61
|
+
lighter: /* @__PURE__ */ new Set(["BB", "BOT", "WEN", "QNT", "SPX"])
|
|
62
|
+
};
|
|
63
|
+
var LIGHTER_RH_CLASS = {
|
|
64
|
+
// crypto
|
|
65
|
+
BTC: "crypto",
|
|
66
|
+
ETH: "crypto",
|
|
67
|
+
SOL: "crypto",
|
|
68
|
+
HYPE: "crypto",
|
|
69
|
+
ZEC: "crypto",
|
|
70
|
+
LIT: "crypto",
|
|
71
|
+
VVV: "crypto",
|
|
72
|
+
SUI: "crypto",
|
|
73
|
+
NEAR: "crypto",
|
|
74
|
+
XRP: "crypto",
|
|
75
|
+
CASHCAT: "crypto",
|
|
76
|
+
PONS: "crypto",
|
|
77
|
+
// commodity (canonical via EXPLICIT_CANONICAL: XAU → GOLD, XAG → SILVER)
|
|
78
|
+
XAU: "commodity",
|
|
79
|
+
XAG: "commodity",
|
|
80
|
+
// etf
|
|
81
|
+
SPY: "etf",
|
|
82
|
+
QQQ: "etf",
|
|
83
|
+
SOXL: "etf",
|
|
84
|
+
SLV: "etf",
|
|
85
|
+
USO: "etf",
|
|
86
|
+
SGOV: "etf",
|
|
87
|
+
// equity
|
|
88
|
+
AAPL: "equity",
|
|
89
|
+
AMZN: "equity",
|
|
90
|
+
GOOGL: "equity",
|
|
91
|
+
META: "equity",
|
|
92
|
+
MSFT: "equity",
|
|
93
|
+
NVDA: "equity",
|
|
94
|
+
TSLA: "equity",
|
|
95
|
+
AMD: "equity",
|
|
96
|
+
INTC: "equity",
|
|
97
|
+
MU: "equity",
|
|
98
|
+
TSM: "equity",
|
|
99
|
+
ORCL: "equity",
|
|
100
|
+
PLTR: "equity",
|
|
101
|
+
COIN: "equity",
|
|
102
|
+
CRCL: "equity",
|
|
103
|
+
CRWV: "equity",
|
|
104
|
+
BABA: "equity",
|
|
105
|
+
SNDK: "equity",
|
|
106
|
+
SMCI: "equity",
|
|
107
|
+
SOFI: "equity",
|
|
108
|
+
AMC: "equity",
|
|
109
|
+
RGTI: "equity",
|
|
110
|
+
QBTS: "equity",
|
|
111
|
+
CLSK: "equity",
|
|
112
|
+
IREN: "equity",
|
|
113
|
+
WULF: "equity",
|
|
114
|
+
LUNR: "equity",
|
|
115
|
+
ASTS: "equity",
|
|
116
|
+
BE: "equity",
|
|
117
|
+
USAR: "equity",
|
|
118
|
+
SPCX: "equity",
|
|
119
|
+
SKHY: "equity",
|
|
120
|
+
// pre-IPO / private (explicit-only canonical → null unless mapped)
|
|
121
|
+
OPENAI: "preIpo",
|
|
122
|
+
ANTHROPIC: "preIpo",
|
|
123
|
+
SHEIN: "preIpo",
|
|
124
|
+
ANSEM: "preIpo",
|
|
125
|
+
AI: "preIpo"
|
|
126
|
+
};
|
|
127
|
+
function isUnclassifiedTicker(venue, base) {
|
|
128
|
+
const up = base.toUpperCase();
|
|
129
|
+
if (venue === "lighter-rh") return LIGHTER_RH_CLASS[up] === void 0;
|
|
130
|
+
return UNCLASSIFIED_TICKERS[venue]?.has(up) === true;
|
|
131
|
+
}
|
|
132
|
+
|
|
48
133
|
// src/ws/canonical.ts
|
|
49
134
|
var USD_LIKE_QUOTES = /* @__PURE__ */ new Set(["USDT", "USD1", "USDC", "USD", "USDG"]);
|
|
50
|
-
var defaultCanonicalize = (
|
|
135
|
+
var defaultCanonicalize = (venue, m) => {
|
|
51
136
|
const base = m.base.toUpperCase();
|
|
52
137
|
if (m.dex !== null && m.dex !== "") return `${m.dex}:${base}`;
|
|
138
|
+
if (isUnclassifiedTicker(venue, base)) return null;
|
|
53
139
|
if (m.quote === null) return null;
|
|
54
140
|
return USD_LIKE_QUOTES.has(m.quote.toUpperCase()) ? base : null;
|
|
55
141
|
};
|
|
@@ -122,6 +208,7 @@ var Connection = class {
|
|
|
122
208
|
this.sendQueue = [];
|
|
123
209
|
this.sendTimer = null;
|
|
124
210
|
this.reconnectTimer = null;
|
|
211
|
+
this.closing = /* @__PURE__ */ new Set();
|
|
125
212
|
this.staleTimer = null;
|
|
126
213
|
this.pingTimer = null;
|
|
127
214
|
this.awaitingPongSince = null;
|
|
@@ -446,15 +533,26 @@ var Connection = class {
|
|
|
446
533
|
ws.close(code, reason);
|
|
447
534
|
} catch {
|
|
448
535
|
}
|
|
536
|
+
if (ws.whenClosed !== void 0) {
|
|
537
|
+
const p = ws.whenClosed;
|
|
538
|
+
this.closing.add(p);
|
|
539
|
+
void p.then(() => {
|
|
540
|
+
this.closing.delete(p);
|
|
541
|
+
});
|
|
542
|
+
}
|
|
449
543
|
}
|
|
450
544
|
}
|
|
545
|
+
/** Settles when every socket this connection closed is fully gone (transports that report it). */
|
|
546
|
+
drained() {
|
|
547
|
+
return Promise.all(Array.from(this.closing)).then(() => void 0);
|
|
548
|
+
}
|
|
451
549
|
};
|
|
452
550
|
function stateDetails(s) {
|
|
453
551
|
const { status: _status, ...rest } = s;
|
|
454
552
|
return rest;
|
|
455
553
|
}
|
|
456
554
|
function closedError(venue) {
|
|
457
|
-
return new WsError("INVALID_PARAMS", `${venue} stream is closed`, { exchange: venue, remediation: "Create a new stream" });
|
|
555
|
+
return new WsError("INVALID_PARAMS", `${venue} stream is closed`, { exchange: venue, remediation: "Create a new stream", details: { closeReason: "CLIENT_CLOSE" } });
|
|
458
556
|
}
|
|
459
557
|
|
|
460
558
|
// src/ws/market-state.ts
|
|
@@ -972,6 +1070,7 @@ function checkCodec(codec) {
|
|
|
972
1070
|
throw new WsError("INVALID_PARAMS", `createVenueStream: first argument must be a venue codec (${problem})`, { remediation: CODEC_REMEDIATION });
|
|
973
1071
|
}
|
|
974
1072
|
}
|
|
1073
|
+
var CLOSE_DRAIN_MS = 2e3;
|
|
975
1074
|
function suggestCanonicals(missing, entries) {
|
|
976
1075
|
const m = missing.toUpperCase();
|
|
977
1076
|
const out = entries.filter((e) => e.canonical !== null && (e.canonical.toUpperCase() === m || e.base.toUpperCase() === m || e.canonical.toUpperCase().endsWith(`:${m}`))).map((e) => e.canonical);
|
|
@@ -979,7 +1078,7 @@ function suggestCanonicals(missing, entries) {
|
|
|
979
1078
|
}
|
|
980
1079
|
function suggestSymbols(missing, entries) {
|
|
981
1080
|
const m = missing.toUpperCase();
|
|
982
|
-
const out = entries.filter((e) => e.symbol.toUpperCase() === m || e.canonical !== null && e.canonical.toUpperCase() === m || e.base.toUpperCase() === m).map((e) => e.symbol);
|
|
1081
|
+
const out = entries.filter((e) => e.symbol.toUpperCase() === m || e.canonical !== null && (e.canonical.toUpperCase() === m || e.base.toUpperCase() === m)).map((e) => e.symbol);
|
|
983
1082
|
return Array.from(new Set(out)).slice(0, 5);
|
|
984
1083
|
}
|
|
985
1084
|
var EVENT_CHANNEL = { account: "account", positions: "positions", orders: "orders", fill: "fills", fills: "fills" };
|
|
@@ -1738,7 +1837,7 @@ function createVenueStream(codec, opts = {}) {
|
|
|
1738
1837
|
}
|
|
1739
1838
|
if (target.kind === "market") {
|
|
1740
1839
|
const channels = target.channels;
|
|
1741
|
-
const valid = `Use channels ${MARKET_CHANNELS.map((c) => `"${c}"`).join(" / ")} (book and trades
|
|
1840
|
+
const valid = `Use channels ${MARKET_CHANNELS.map((c) => `"${c}"`).join(" / ")} (book and trades: planned, not yet available)`;
|
|
1742
1841
|
if (!Array.isArray(channels) || channels.length === 0) {
|
|
1743
1842
|
throw wsError("INVALID_PARAMS", 'kind "market" needs at least one channel', valid);
|
|
1744
1843
|
}
|
|
@@ -1749,7 +1848,7 @@ function createVenueStream(codec, opts = {}) {
|
|
|
1749
1848
|
throw wsError(
|
|
1750
1849
|
"CAPABILITY_UNSUPPORTED",
|
|
1751
1850
|
`${venue} channel(s) ${later.join(", ")} are not available in this release`,
|
|
1752
|
-
'Use channels "market" / "bbo" (book and trades
|
|
1851
|
+
'Use channels "market" / "bbo" (book and trades: planned, not yet available)'
|
|
1753
1852
|
);
|
|
1754
1853
|
}
|
|
1755
1854
|
if (target.channels.includes("bbo") && !codec.capabilities.channels.bbo) {
|
|
@@ -2063,6 +2162,14 @@ function createVenueStream(codec, opts = {}) {
|
|
|
2063
2162
|
sub.end();
|
|
2064
2163
|
}
|
|
2065
2164
|
stateListeners.clear();
|
|
2165
|
+
let bound = null;
|
|
2166
|
+
await Promise.race([
|
|
2167
|
+
Promise.all(shards.map((sh) => sh.conn.drained())),
|
|
2168
|
+
new Promise((resolve) => {
|
|
2169
|
+
bound = setTimeout(resolve, CLOSE_DRAIN_MS);
|
|
2170
|
+
})
|
|
2171
|
+
]);
|
|
2172
|
+
if (bound !== null) clearTimeout(bound);
|
|
2066
2173
|
}
|
|
2067
2174
|
};
|
|
2068
2175
|
return stream;
|
|
@@ -2424,6 +2531,23 @@ function mulDecimalInt(s, n, shift) {
|
|
|
2424
2531
|
const abs2 = (neg ? -product : product).toString();
|
|
2425
2532
|
return shiftDecimal(`${neg ? "-" : ""}${abs2}`, shift - frac.length);
|
|
2426
2533
|
}
|
|
2534
|
+
function sumDecimals(values) {
|
|
2535
|
+
let scale = 0;
|
|
2536
|
+
const parts = [];
|
|
2537
|
+
for (const v of values) {
|
|
2538
|
+
const m = DECIMAL_RE.exec(v);
|
|
2539
|
+
if (m === null || m[4] !== void 0 || m[2] === "" && (m[3] === void 0 || m[3] === "")) return void 0;
|
|
2540
|
+
const frac = m[3] === void 0 ? "" : m[3];
|
|
2541
|
+
const digits = `${m[2]}${frac}`.replace(/^0+(?=\d)/, "");
|
|
2542
|
+
parts.push({ int: BigInt(digits === "" ? "0" : digits) * (m[1] === "-" ? -1n : 1n), scale: frac.length });
|
|
2543
|
+
if (frac.length > scale) scale = frac.length;
|
|
2544
|
+
}
|
|
2545
|
+
let total = 0n;
|
|
2546
|
+
for (const p of parts) total += p.int * 10n ** BigInt(scale - p.scale);
|
|
2547
|
+
const neg = total < 0n;
|
|
2548
|
+
const abs2 = (neg ? -total : total).toString();
|
|
2549
|
+
return shiftDecimal(`${neg ? "-" : ""}${abs2}`, -scale);
|
|
2550
|
+
}
|
|
2427
2551
|
|
|
2428
2552
|
// src/ws/codecs/common.ts
|
|
2429
2553
|
function isObj(v) {
|
|
@@ -2504,9 +2628,9 @@ function capabilities(p) {
|
|
|
2504
2628
|
channels: { allMarkets: true, market: true, bbo: true, book: false, trades: false, user: p.user },
|
|
2505
2629
|
fields: p.fields,
|
|
2506
2630
|
notes: {
|
|
2507
|
-
book: "
|
|
2508
|
-
trades: "
|
|
2509
|
-
...p.user ? {} : { user: "
|
|
2631
|
+
book: "Planned, not yet available",
|
|
2632
|
+
trades: "Planned, not yet available",
|
|
2633
|
+
...p.user ? {} : { user: "Planned, not yet available" },
|
|
2510
2634
|
...p.notes
|
|
2511
2635
|
}
|
|
2512
2636
|
};
|
|
@@ -2746,6 +2870,16 @@ var SIDE = { B: "buy", A: "sell" };
|
|
|
2746
2870
|
function abs(d) {
|
|
2747
2871
|
return d === null ? null : d.replace(/^-/, "");
|
|
2748
2872
|
}
|
|
2873
|
+
function accountUnrealized(rows, bad) {
|
|
2874
|
+
if (bad.includes("assetPositions") || bad.includes("assetPositions[]")) return null;
|
|
2875
|
+
const values = [];
|
|
2876
|
+
for (const r of rows) {
|
|
2877
|
+
if (r.unrealizedPnl === null) return null;
|
|
2878
|
+
values.push(r.unrealizedPnl);
|
|
2879
|
+
}
|
|
2880
|
+
const sum = sumDecimals(values);
|
|
2881
|
+
return sum === void 0 ? null : sum;
|
|
2882
|
+
}
|
|
2749
2883
|
function decodeClearinghouse(d) {
|
|
2750
2884
|
if (!isObj(d) || typeof d.dex !== "string" || !isObj(d.clearinghouseState)) return [protocolError("hyperliquid clearinghouseState frame without dex/clearinghouseState")];
|
|
2751
2885
|
const wire = `clearinghouseState:${d.dex}`;
|
|
@@ -2788,8 +2922,10 @@ function decodeClearinghouse(d) {
|
|
|
2788
2922
|
equity: ms === null ? null : dec(ms, "accountValue", bad),
|
|
2789
2923
|
available: dec(cs, "withdrawable", bad),
|
|
2790
2924
|
marginUsed: ms === null ? null : dec(ms, "totalMarginUsed", bad),
|
|
2791
|
-
unrealizedPnl
|
|
2792
|
-
//
|
|
2925
|
+
// clearinghouseState has no account-level unrealizedPnl, but the frame carries every
|
|
2926
|
+
// position of this dex with its own `unrealizedPnl`: the account value is their exact sum
|
|
2927
|
+
// ("0" with no position). null when any position's value is missing (unknown, never guessed).
|
|
2928
|
+
unrealizedPnl: accountUnrealized(rows, bad)
|
|
2793
2929
|
},
|
|
2794
2930
|
{ kind: "positions", snapshot: true, scope: d.dex, rows, remove: [] }
|
|
2795
2931
|
];
|
|
@@ -3284,6 +3420,11 @@ function decodeBbo2(d) {
|
|
|
3284
3420
|
return out;
|
|
3285
3421
|
}
|
|
3286
3422
|
|
|
3423
|
+
// src/ws/lighter-market.ts
|
|
3424
|
+
function isLighterMarketLive(row) {
|
|
3425
|
+
return row.status === "active" && row.is_frozen !== true;
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3287
3428
|
// src/ws/codecs/lighter.ts
|
|
3288
3429
|
var ALL2 = "market_stats/all";
|
|
3289
3430
|
var ORDERS_TOKEN_TTL_SEC = 3600;
|
|
@@ -3292,6 +3433,10 @@ var ZERO = /^-?0*(\.0*)?$/;
|
|
|
3292
3433
|
function bookSide(o, key, bad) {
|
|
3293
3434
|
return o[key] === "" ? null : readDec(o, key, bad);
|
|
3294
3435
|
}
|
|
3436
|
+
function lastTrade(o, bad) {
|
|
3437
|
+
const v = readDec(o, "last_trade_price", bad);
|
|
3438
|
+
return v !== void 0 && v !== null && ZERO.test(v) ? null : v;
|
|
3439
|
+
}
|
|
3295
3440
|
function percentToFraction(o, key, bad) {
|
|
3296
3441
|
const v = readDec(o, key, bad);
|
|
3297
3442
|
if (v === void 0 || v === null) return v;
|
|
@@ -3324,7 +3469,7 @@ function makeLighter(venue, quote) {
|
|
|
3324
3469
|
notes: {
|
|
3325
3470
|
rate: "WS funding is PERCENT per 1 h on the wire; emitted as a fraction per 1 h",
|
|
3326
3471
|
nextFundingTime: "Hourly settlement rule (venue-definition)",
|
|
3327
|
-
openInterest: "USD notional
|
|
3472
|
+
openInterest: "USD notional, one side \u2014 inferred, not documented by the venue (= REST orderBookDetails open_interest, base, \xD7 mark; the venue's exchangeMetrics kind=open_interest, a daily snapshot, read \u2248 2\xD7 when compared with the current value)",
|
|
3328
3473
|
user: "By accountIndex. user_stats / account_all_positions / account_all_trades need no auth; account_all_orders needs CredentialProvider.authToken (re-called at 80 % TTL). account_all_trades does not replay \u2192 gap refetch-required for fills after a reconnect",
|
|
3329
3474
|
clientId: "orders: client_order_id; fills: ask_/bid_client_id_str of our side; 0 = no client id \u2192 null",
|
|
3330
3475
|
liquidationPrice: 'liquidation_price; "0" (no liquidation price, e.g. well-covered cross margin) \u2192 null, as in the CLI REST path',
|
|
@@ -3341,7 +3486,7 @@ function makeLighter(venue, quote) {
|
|
|
3341
3486
|
limits: S5.limits,
|
|
3342
3487
|
closesOnBadSubscribe: false,
|
|
3343
3488
|
staleAfterMs: S5.staleAfterMs,
|
|
3344
|
-
openInterest: { unit: "usd", sides:
|
|
3489
|
+
openInterest: { unit: "usd", sides: "one" },
|
|
3345
3490
|
refresh: null,
|
|
3346
3491
|
async loadDirectory(ctx) {
|
|
3347
3492
|
const raw = await fetchJson(ctx.fetch, venue, `${ctx.restUrl}/api/v1/orderBooks`);
|
|
@@ -3359,6 +3504,10 @@ function makeLighter(venue, quote) {
|
|
|
3359
3504
|
continue;
|
|
3360
3505
|
}
|
|
3361
3506
|
idToSymbol.set(id, m.symbol);
|
|
3507
|
+
if (!isLighterMarketLive(m)) {
|
|
3508
|
+
nonPerp.add(id);
|
|
3509
|
+
continue;
|
|
3510
|
+
}
|
|
3362
3511
|
entries.push({
|
|
3363
3512
|
venue,
|
|
3364
3513
|
dex: null,
|
|
@@ -3494,7 +3643,7 @@ function decodeStats(venue, f, d, recvTs) {
|
|
|
3494
3643
|
index: readDec(r, "index_price", bad),
|
|
3495
3644
|
bestBid: bookSide(r, "best_bid_price", bad),
|
|
3496
3645
|
bestAsk: bookSide(r, "best_ask_price", bad),
|
|
3497
|
-
last:
|
|
3646
|
+
last: lastTrade(r, bad),
|
|
3498
3647
|
rate: percentToFraction(r, "funding_rate", bad),
|
|
3499
3648
|
predictedRate: percentToFraction(r, "current_funding_rate", bad),
|
|
3500
3649
|
openInterest: readDec(r, "open_interest", bad),
|
|
@@ -3710,6 +3859,14 @@ function decodeTicker(venue, wire, f, d) {
|
|
|
3710
3859
|
return out;
|
|
3711
3860
|
}
|
|
3712
3861
|
|
|
3862
|
+
// src/ws/aster-market.ts
|
|
3863
|
+
function isAsterListedPerpetual(row) {
|
|
3864
|
+
return row.contractType === "PERPETUAL" && row.status === "TRADING";
|
|
3865
|
+
}
|
|
3866
|
+
function asterMatchable(row) {
|
|
3867
|
+
return row.quoteAsset === "USDT";
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3713
3870
|
// src/ws/codecs/aster.ts
|
|
3714
3871
|
var S3 = VENUE_STATIC.aster;
|
|
3715
3872
|
var MARK = "!markPrice@arr@1s";
|
|
@@ -3757,7 +3914,7 @@ var aster = {
|
|
|
3757
3914
|
openInterest: "Not on WS (REST only)",
|
|
3758
3915
|
fundingInterval: `Labelled REST source ${FUNDING_INFO}, refreshed hourly; missing \u2192 null`,
|
|
3759
3916
|
volume24hNotional: "!ticker@arr q (quote-asset volume)",
|
|
3760
|
-
user: 'KNOWN GAP
|
|
3917
|
+
user: 'KNOWN GAP: no Aster user channels (signed USER_STREAM listenKey) \u2014 kind:"user" rejects CAPABILITY_UNSUPPORTED; planned, not yet available',
|
|
3761
3918
|
bbo: "<symbol>@bookTicker; `u` orders frames (non-contiguous)"
|
|
3762
3919
|
}
|
|
3763
3920
|
}),
|
|
@@ -3797,6 +3954,7 @@ var aster = {
|
|
|
3797
3954
|
if (!isObj(s) || typeof s.symbol !== "string" || typeof s.baseAsset !== "string") {
|
|
3798
3955
|
throw badShape("aster", "exchangeInfo symbol without symbol/baseAsset");
|
|
3799
3956
|
}
|
|
3957
|
+
if (!isAsterListedPerpetual(s)) continue;
|
|
3800
3958
|
const quote = typeof s.quoteAsset === "string" && s.quoteAsset !== "" ? s.quoteAsset : null;
|
|
3801
3959
|
listed.add(s.symbol);
|
|
3802
3960
|
const hours = intervals.get(s.symbol);
|
|
@@ -3807,7 +3965,8 @@ var aster = {
|
|
|
3807
3965
|
venueSymbol: s.symbol,
|
|
3808
3966
|
base: s.baseAsset,
|
|
3809
3967
|
quote,
|
|
3810
|
-
|
|
3968
|
+
// Only the USDT listing is matchable (asterMatchable — the CLI rule): BTCUSD1 is never a second BTC leg.
|
|
3969
|
+
canonical: asterMatchable(s) ? ctx.canonicalize("aster", { venueSymbol: s.symbol, dex: null, base: s.baseAsset, quote }) : null,
|
|
3811
3970
|
rateIntervalHours: hours === void 0 ? null : hours,
|
|
3812
3971
|
settlementIntervalHours: hours === void 0 ? null : hours,
|
|
3813
3972
|
source: "rest:/fapi/v3/exchangeInfo"
|
|
@@ -3930,6 +4089,11 @@ function decodeArr(wire, data2, d, fn) {
|
|
|
3930
4089
|
return out;
|
|
3931
4090
|
}
|
|
3932
4091
|
|
|
4092
|
+
// src/ws/arcus-market.ts
|
|
4093
|
+
function isArcusOnlineMarket(row) {
|
|
4094
|
+
return row.status === "ONLINE";
|
|
4095
|
+
}
|
|
4096
|
+
|
|
3933
4097
|
// src/ws/codecs/arcus.ts
|
|
3934
4098
|
var S4 = VENUE_STATIC.arcus;
|
|
3935
4099
|
var MARKETS = "markets";
|
|
@@ -3980,6 +4144,7 @@ var arcus = {
|
|
|
3980
4144
|
if (!isObj(m) || typeof m.marketDisplayName !== "string" || typeof m.marketId !== "number" || typeof m.baseAsset !== "string") {
|
|
3981
4145
|
throw badShape("arcus", "market without marketDisplayName/marketId/baseAsset");
|
|
3982
4146
|
}
|
|
4147
|
+
if (!isArcusOnlineMarket(m)) continue;
|
|
3983
4148
|
const quote = typeof m.quoteAsset === "string" ? m.quoteAsset : null;
|
|
3984
4149
|
idToSymbol.set(String(m.marketId), m.marketDisplayName);
|
|
3985
4150
|
entries.push({
|
package/dist/node.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
6
|
* Every perp-cli venue id. "ondo" and "qfex" are included for type stability but have no perp-cli/ws
|
|
7
|
-
* codec
|
|
7
|
+
* codec (planned, not yet available: there is no `ondo` / `qfex` export to pass to createVenueStream).
|
|
8
8
|
*/
|
|
9
9
|
type VenueId = "pacifica" | "hyperliquid" | "lighter" | "aster" | "lighter-rh" | "ondo" | "arcus" | "qfex";
|
|
10
10
|
declare const VENUE_IDS: readonly VenueId[];
|
|
@@ -74,6 +74,10 @@ declare class WsError extends Error {
|
|
|
74
74
|
remediation?: string;
|
|
75
75
|
details?: Record<string, unknown>;
|
|
76
76
|
});
|
|
77
|
+
/** `structured.code` — the same place as `code` on error events (`e.code`). */
|
|
78
|
+
get code(): WsErrorCode;
|
|
79
|
+
/** `structured.remediation` — the same place as `remediation` on error events; null when none. */
|
|
80
|
+
get remediation(): string | null;
|
|
77
81
|
}
|
|
78
82
|
/** Structural check: a PerpError (CLI) or a WsError — anything carrying `structured.{code,message}`. */
|
|
79
83
|
declare function isPerpError(e: unknown): e is {
|
|
@@ -109,6 +113,11 @@ interface WsLike {
|
|
|
109
113
|
wasClean?: boolean;
|
|
110
114
|
}) => void) | null;
|
|
111
115
|
onerror: ((ev: unknown) => void) | null;
|
|
116
|
+
/**
|
|
117
|
+
* Optional: settles once the underlying socket is fully closed (no timer or socket left). The
|
|
118
|
+
* Node transport sets it; `stream.close()` awaits it (bounded) so the process is idle afterwards.
|
|
119
|
+
*/
|
|
120
|
+
readonly whenClosed?: Promise<void>;
|
|
112
121
|
}
|
|
113
122
|
type WsFactory = (url: string) => WsLike;
|
|
114
123
|
type FetchLike = (input: string, init?: {
|
|
@@ -384,7 +393,7 @@ type UserRef = {
|
|
|
384
393
|
venue: "lighter" | "lighter-rh";
|
|
385
394
|
accountIndex: number;
|
|
386
395
|
}
|
|
387
|
-
/** KNOWN GAP
|
|
396
|
+
/** KNOWN GAP: Aster user channels are not implemented (CAPABILITY_UNSUPPORTED; planned, not yet available). */
|
|
388
397
|
| {
|
|
389
398
|
venue: "aster";
|
|
390
399
|
user: `0x${string}`;
|
|
@@ -527,7 +536,7 @@ interface VenueStreamOptions {
|
|
|
527
536
|
canonicalize?: Canonicalize;
|
|
528
537
|
/** Needed for kind:"user". */
|
|
529
538
|
credentials?: CredentialProvider;
|
|
530
|
-
/** Default false. Required for user channels whose auth needs a signing secret in the page (Aster/Ondo/QFEX,
|
|
539
|
+
/** Default false. Required for user channels whose auth needs a signing secret in the page (Aster / Ondo / QFEX — planned, not yet available). */
|
|
531
540
|
allowInPageSecrets?: boolean;
|
|
532
541
|
/** ≤ venue limits. */
|
|
533
542
|
budget?: {
|
|
@@ -792,8 +801,8 @@ interface VenueCodec {
|
|
|
792
801
|
*/
|
|
793
802
|
declare const USD_LIKE_QUOTES: ReadonlySet<string>;
|
|
794
803
|
/**
|
|
795
|
-
* HIP-3 (dex set) → "<dex>:<BASE>".
|
|
796
|
-
* quote (null), → null (not comparable by default).
|
|
804
|
+
* HIP-3 (dex set) → "<dex>:<BASE>". An ambiguous ticker (isUnclassifiedTicker) → null. A USD-like
|
|
805
|
+
* quote → "<BASE>". Any other quote, or an unknown quote (null), → null (not comparable by default).
|
|
797
806
|
*/
|
|
798
807
|
declare const defaultCanonicalize: Canonicalize;
|
|
799
808
|
|
|
@@ -856,9 +865,17 @@ interface FundingComparator {
|
|
|
856
865
|
subscribe(cb: (rows: FundingRow[], changed: string[]) => void): () => void;
|
|
857
866
|
close(): void;
|
|
858
867
|
}
|
|
859
|
-
/**
|
|
868
|
+
/**
|
|
869
|
+
* Rate per hour as a FRACTION (0.0000125 = 0.00125 %/h): `rate / rateIntervalHours`.
|
|
870
|
+
* @experimental 0.15.x (stable from 0.16.0 after consumer sign-off)
|
|
871
|
+
*/
|
|
860
872
|
declare function toHourly(rate: Dec | null, rateIntervalHours: number | null): number | null;
|
|
861
|
-
/**
|
|
873
|
+
/**
|
|
874
|
+
* Simple annualized rate as a FRACTION, not a percent: `toHourly × 24 × 365` (0.0000125/h → 0.1095,
|
|
875
|
+
* i.e. 10.95 %). The CLI's `annualizedPct` (`arb scan --rates`, MCP `get_funding_rates`) is the same
|
|
876
|
+
* quantity in PERCENT (10.95): multiply `toApr` by 100 to compare the two.
|
|
877
|
+
* @experimental 0.15.x (stable from 0.16.0 after consumer sign-off)
|
|
878
|
+
*/
|
|
862
879
|
declare function toApr(rate: Dec | null, rateIntervalHours: number | null): number | null;
|
|
863
880
|
/** @experimental 0.15.x (stable from 0.16.0 after consumer sign-off) */
|
|
864
881
|
declare function createFundingComparator(opts: FundingComparatorOptions): FundingComparator;
|
|
@@ -901,7 +918,11 @@ declare const pacifica: VenueCodec;
|
|
|
901
918
|
*
|
|
902
919
|
* Units (Phase 0, the key correction): WS `funding_rate` (last settled) and `current_funding_rate`
|
|
903
920
|
* (predicted) are PERCENT per 1 h → converted to a fraction by an exact 2-place decimal shift,
|
|
904
|
-
* rateIntervalHours = settlementIntervalHours = 1. `open_interest` is USD notional (not base)
|
|
921
|
+
* rateIntervalHours = settlementIntervalHours = 1. `open_interest` is USD notional (not base) and
|
|
922
|
+
* one side — INFERRED, not documented by the venue: WS open_interest / mark = REST orderBookDetails
|
|
923
|
+
* `open_interest` (base) (2026-10-19, BTC: 174.88 M USD / 82,387 = 2,122.7 BTC), and the venue's
|
|
924
|
+
* exchangeMetrics open_interest — a DAILY snapshot, two days old when compared — was ≈ 2× the
|
|
925
|
+
* current one-side figure (336.6 M USD).
|
|
905
926
|
* Errors arrive as {error:{code,message}} with no channel field (matched by the channel name in
|
|
906
927
|
* the message). Spot markets (market_type "spot" in /api/v1/orderBooks) are not perps and skipped.
|
|
907
928
|
*
|
|
@@ -913,7 +934,7 @@ declare const lighter: VenueCodec;
|
|
|
913
934
|
declare const lighterRh: VenueCodec;
|
|
914
935
|
|
|
915
936
|
/**
|
|
916
|
-
* Aster codec (§6.1, public only
|
|
937
|
+
* Aster codec (§6.1, public channels only). One combined-stream socket (`/stream`, frames
|
|
917
938
|
* {stream, data}) with `!markPrice@arr@1s` (every symbol, 1 s) + `!ticker@arr` (only changed symbols).
|
|
918
939
|
* r = accruing current-period rate (Phase 0: == premiumIndex.lastFundingRate ≠ last settled).
|
|
919
940
|
* The funding interval is per market from the LABELLED REST source `rest:/fapi/v3/fundingInfo`
|
package/dist/node.js
CHANGED
|
@@ -29,6 +29,14 @@ var WsError = class extends Error {
|
|
|
29
29
|
...opts?.details !== void 0 ? { details: opts.details } : {}
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
+
/** `structured.code` — the same place as `code` on error events (`e.code`). */
|
|
33
|
+
get code() {
|
|
34
|
+
return this.structured.code;
|
|
35
|
+
}
|
|
36
|
+
/** `structured.remediation` — the same place as `remediation` on error events; null when none. */
|
|
37
|
+
get remediation() {
|
|
38
|
+
return this.structured.remediation === void 0 ? null : this.structured.remediation;
|
|
39
|
+
}
|
|
32
40
|
};
|
|
33
41
|
function isPerpError(e) {
|
|
34
42
|
if (e === null || typeof e !== "object") return false;
|
|
@@ -37,11 +45,89 @@ function isPerpError(e) {
|
|
|
37
45
|
return typeof s.code === "string" && typeof s.message === "string";
|
|
38
46
|
}
|
|
39
47
|
|
|
48
|
+
// src/ws/unclassified.ts
|
|
49
|
+
var UNCLASSIFIED_TICKERS = {
|
|
50
|
+
hyperliquid: /* @__PURE__ */ new Set(["SPX"]),
|
|
51
|
+
aster: /* @__PURE__ */ new Set(["SPX"]),
|
|
52
|
+
pacifica: /* @__PURE__ */ new Set(["SPX", "BP", "CHIP"]),
|
|
53
|
+
lighter: /* @__PURE__ */ new Set(["BB", "BOT", "WEN", "QNT", "SPX"])
|
|
54
|
+
};
|
|
55
|
+
var LIGHTER_RH_CLASS = {
|
|
56
|
+
// crypto
|
|
57
|
+
BTC: "crypto",
|
|
58
|
+
ETH: "crypto",
|
|
59
|
+
SOL: "crypto",
|
|
60
|
+
HYPE: "crypto",
|
|
61
|
+
ZEC: "crypto",
|
|
62
|
+
LIT: "crypto",
|
|
63
|
+
VVV: "crypto",
|
|
64
|
+
SUI: "crypto",
|
|
65
|
+
NEAR: "crypto",
|
|
66
|
+
XRP: "crypto",
|
|
67
|
+
CASHCAT: "crypto",
|
|
68
|
+
PONS: "crypto",
|
|
69
|
+
// commodity (canonical via EXPLICIT_CANONICAL: XAU → GOLD, XAG → SILVER)
|
|
70
|
+
XAU: "commodity",
|
|
71
|
+
XAG: "commodity",
|
|
72
|
+
// etf
|
|
73
|
+
SPY: "etf",
|
|
74
|
+
QQQ: "etf",
|
|
75
|
+
SOXL: "etf",
|
|
76
|
+
SLV: "etf",
|
|
77
|
+
USO: "etf",
|
|
78
|
+
SGOV: "etf",
|
|
79
|
+
// equity
|
|
80
|
+
AAPL: "equity",
|
|
81
|
+
AMZN: "equity",
|
|
82
|
+
GOOGL: "equity",
|
|
83
|
+
META: "equity",
|
|
84
|
+
MSFT: "equity",
|
|
85
|
+
NVDA: "equity",
|
|
86
|
+
TSLA: "equity",
|
|
87
|
+
AMD: "equity",
|
|
88
|
+
INTC: "equity",
|
|
89
|
+
MU: "equity",
|
|
90
|
+
TSM: "equity",
|
|
91
|
+
ORCL: "equity",
|
|
92
|
+
PLTR: "equity",
|
|
93
|
+
COIN: "equity",
|
|
94
|
+
CRCL: "equity",
|
|
95
|
+
CRWV: "equity",
|
|
96
|
+
BABA: "equity",
|
|
97
|
+
SNDK: "equity",
|
|
98
|
+
SMCI: "equity",
|
|
99
|
+
SOFI: "equity",
|
|
100
|
+
AMC: "equity",
|
|
101
|
+
RGTI: "equity",
|
|
102
|
+
QBTS: "equity",
|
|
103
|
+
CLSK: "equity",
|
|
104
|
+
IREN: "equity",
|
|
105
|
+
WULF: "equity",
|
|
106
|
+
LUNR: "equity",
|
|
107
|
+
ASTS: "equity",
|
|
108
|
+
BE: "equity",
|
|
109
|
+
USAR: "equity",
|
|
110
|
+
SPCX: "equity",
|
|
111
|
+
SKHY: "equity",
|
|
112
|
+
// pre-IPO / private (explicit-only canonical → null unless mapped)
|
|
113
|
+
OPENAI: "preIpo",
|
|
114
|
+
ANTHROPIC: "preIpo",
|
|
115
|
+
SHEIN: "preIpo",
|
|
116
|
+
ANSEM: "preIpo",
|
|
117
|
+
AI: "preIpo"
|
|
118
|
+
};
|
|
119
|
+
function isUnclassifiedTicker(venue, base) {
|
|
120
|
+
const up = base.toUpperCase();
|
|
121
|
+
if (venue === "lighter-rh") return LIGHTER_RH_CLASS[up] === void 0;
|
|
122
|
+
return UNCLASSIFIED_TICKERS[venue]?.has(up) === true;
|
|
123
|
+
}
|
|
124
|
+
|
|
40
125
|
// src/ws/canonical.ts
|
|
41
126
|
var USD_LIKE_QUOTES = /* @__PURE__ */ new Set(["USDT", "USD1", "USDC", "USD", "USDG"]);
|
|
42
|
-
var defaultCanonicalize = (
|
|
127
|
+
var defaultCanonicalize = (venue, m) => {
|
|
43
128
|
const base = m.base.toUpperCase();
|
|
44
129
|
if (m.dex !== null && m.dex !== "") return `${m.dex}:${base}`;
|
|
130
|
+
if (isUnclassifiedTicker(venue, base)) return null;
|
|
45
131
|
if (m.quote === null) return null;
|
|
46
132
|
return USD_LIKE_QUOTES.has(m.quote.toUpperCase()) ? base : null;
|
|
47
133
|
};
|
|
@@ -114,6 +200,7 @@ var Connection = class {
|
|
|
114
200
|
this.sendQueue = [];
|
|
115
201
|
this.sendTimer = null;
|
|
116
202
|
this.reconnectTimer = null;
|
|
203
|
+
this.closing = /* @__PURE__ */ new Set();
|
|
117
204
|
this.staleTimer = null;
|
|
118
205
|
this.pingTimer = null;
|
|
119
206
|
this.awaitingPongSince = null;
|
|
@@ -438,15 +525,26 @@ var Connection = class {
|
|
|
438
525
|
ws.close(code, reason);
|
|
439
526
|
} catch {
|
|
440
527
|
}
|
|
528
|
+
if (ws.whenClosed !== void 0) {
|
|
529
|
+
const p = ws.whenClosed;
|
|
530
|
+
this.closing.add(p);
|
|
531
|
+
void p.then(() => {
|
|
532
|
+
this.closing.delete(p);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
441
535
|
}
|
|
442
536
|
}
|
|
537
|
+
/** Settles when every socket this connection closed is fully gone (transports that report it). */
|
|
538
|
+
drained() {
|
|
539
|
+
return Promise.all(Array.from(this.closing)).then(() => void 0);
|
|
540
|
+
}
|
|
443
541
|
};
|
|
444
542
|
function stateDetails(s) {
|
|
445
543
|
const { status: _status, ...rest } = s;
|
|
446
544
|
return rest;
|
|
447
545
|
}
|
|
448
546
|
function closedError(venue) {
|
|
449
|
-
return new WsError("INVALID_PARAMS", `${venue} stream is closed`, { exchange: venue, remediation: "Create a new stream" });
|
|
547
|
+
return new WsError("INVALID_PARAMS", `${venue} stream is closed`, { exchange: venue, remediation: "Create a new stream", details: { closeReason: "CLIENT_CLOSE" } });
|
|
450
548
|
}
|
|
451
549
|
|
|
452
550
|
// src/ws/ids.ts
|
|
@@ -976,6 +1074,7 @@ function checkCodec(codec) {
|
|
|
976
1074
|
throw new WsError("INVALID_PARAMS", `createVenueStream: first argument must be a venue codec (${problem})`, { remediation: CODEC_REMEDIATION });
|
|
977
1075
|
}
|
|
978
1076
|
}
|
|
1077
|
+
var CLOSE_DRAIN_MS = 2e3;
|
|
979
1078
|
function suggestCanonicals(missing, entries) {
|
|
980
1079
|
const m = missing.toUpperCase();
|
|
981
1080
|
const out = entries.filter((e) => e.canonical !== null && (e.canonical.toUpperCase() === m || e.base.toUpperCase() === m || e.canonical.toUpperCase().endsWith(`:${m}`))).map((e) => e.canonical);
|
|
@@ -983,7 +1082,7 @@ function suggestCanonicals(missing, entries) {
|
|
|
983
1082
|
}
|
|
984
1083
|
function suggestSymbols(missing, entries) {
|
|
985
1084
|
const m = missing.toUpperCase();
|
|
986
|
-
const out = entries.filter((e) => e.symbol.toUpperCase() === m || e.canonical !== null && e.canonical.toUpperCase() === m || e.base.toUpperCase() === m).map((e) => e.symbol);
|
|
1085
|
+
const out = entries.filter((e) => e.symbol.toUpperCase() === m || e.canonical !== null && (e.canonical.toUpperCase() === m || e.base.toUpperCase() === m)).map((e) => e.symbol);
|
|
987
1086
|
return Array.from(new Set(out)).slice(0, 5);
|
|
988
1087
|
}
|
|
989
1088
|
var EVENT_CHANNEL = { account: "account", positions: "positions", orders: "orders", fill: "fills", fills: "fills" };
|
|
@@ -1742,7 +1841,7 @@ function createVenueStream(codec, opts = {}) {
|
|
|
1742
1841
|
}
|
|
1743
1842
|
if (target.kind === "market") {
|
|
1744
1843
|
const channels = target.channels;
|
|
1745
|
-
const valid = `Use channels ${MARKET_CHANNELS.map((c) => `"${c}"`).join(" / ")} (book and trades
|
|
1844
|
+
const valid = `Use channels ${MARKET_CHANNELS.map((c) => `"${c}"`).join(" / ")} (book and trades: planned, not yet available)`;
|
|
1746
1845
|
if (!Array.isArray(channels) || channels.length === 0) {
|
|
1747
1846
|
throw wsError("INVALID_PARAMS", 'kind "market" needs at least one channel', valid);
|
|
1748
1847
|
}
|
|
@@ -1753,7 +1852,7 @@ function createVenueStream(codec, opts = {}) {
|
|
|
1753
1852
|
throw wsError(
|
|
1754
1853
|
"CAPABILITY_UNSUPPORTED",
|
|
1755
1854
|
`${venue} channel(s) ${later.join(", ")} are not available in this release`,
|
|
1756
|
-
'Use channels "market" / "bbo" (book and trades
|
|
1855
|
+
'Use channels "market" / "bbo" (book and trades: planned, not yet available)'
|
|
1757
1856
|
);
|
|
1758
1857
|
}
|
|
1759
1858
|
if (target.channels.includes("bbo") && !codec.capabilities.channels.bbo) {
|
|
@@ -2067,6 +2166,14 @@ function createVenueStream(codec, opts = {}) {
|
|
|
2067
2166
|
sub.end();
|
|
2068
2167
|
}
|
|
2069
2168
|
stateListeners.clear();
|
|
2169
|
+
let bound = null;
|
|
2170
|
+
await Promise.race([
|
|
2171
|
+
Promise.all(shards.map((sh) => sh.conn.drained())),
|
|
2172
|
+
new Promise((resolve) => {
|
|
2173
|
+
bound = setTimeout(resolve, CLOSE_DRAIN_MS);
|
|
2174
|
+
})
|
|
2175
|
+
]);
|
|
2176
|
+
if (bound !== null) clearTimeout(bound);
|
|
2070
2177
|
}
|
|
2071
2178
|
};
|
|
2072
2179
|
return stream;
|
|
@@ -2428,6 +2535,23 @@ function mulDecimalInt(s, n, shift) {
|
|
|
2428
2535
|
const abs2 = (neg ? -product : product).toString();
|
|
2429
2536
|
return shiftDecimal(`${neg ? "-" : ""}${abs2}`, shift - frac.length);
|
|
2430
2537
|
}
|
|
2538
|
+
function sumDecimals(values) {
|
|
2539
|
+
let scale = 0;
|
|
2540
|
+
const parts = [];
|
|
2541
|
+
for (const v of values) {
|
|
2542
|
+
const m = DECIMAL_RE.exec(v);
|
|
2543
|
+
if (m === null || m[4] !== void 0 || m[2] === "" && (m[3] === void 0 || m[3] === "")) return void 0;
|
|
2544
|
+
const frac = m[3] === void 0 ? "" : m[3];
|
|
2545
|
+
const digits = `${m[2]}${frac}`.replace(/^0+(?=\d)/, "");
|
|
2546
|
+
parts.push({ int: BigInt(digits === "" ? "0" : digits) * (m[1] === "-" ? -1n : 1n), scale: frac.length });
|
|
2547
|
+
if (frac.length > scale) scale = frac.length;
|
|
2548
|
+
}
|
|
2549
|
+
let total = 0n;
|
|
2550
|
+
for (const p of parts) total += p.int * 10n ** BigInt(scale - p.scale);
|
|
2551
|
+
const neg = total < 0n;
|
|
2552
|
+
const abs2 = (neg ? -total : total).toString();
|
|
2553
|
+
return shiftDecimal(`${neg ? "-" : ""}${abs2}`, -scale);
|
|
2554
|
+
}
|
|
2431
2555
|
|
|
2432
2556
|
// src/ws/codecs/common.ts
|
|
2433
2557
|
function isObj(v) {
|
|
@@ -2508,9 +2632,9 @@ function capabilities(p) {
|
|
|
2508
2632
|
channels: { allMarkets: true, market: true, bbo: true, book: false, trades: false, user: p.user },
|
|
2509
2633
|
fields: p.fields,
|
|
2510
2634
|
notes: {
|
|
2511
|
-
book: "
|
|
2512
|
-
trades: "
|
|
2513
|
-
...p.user ? {} : { user: "
|
|
2635
|
+
book: "Planned, not yet available",
|
|
2636
|
+
trades: "Planned, not yet available",
|
|
2637
|
+
...p.user ? {} : { user: "Planned, not yet available" },
|
|
2514
2638
|
...p.notes
|
|
2515
2639
|
}
|
|
2516
2640
|
};
|
|
@@ -2750,6 +2874,16 @@ var SIDE = { B: "buy", A: "sell" };
|
|
|
2750
2874
|
function abs(d) {
|
|
2751
2875
|
return d === null ? null : d.replace(/^-/, "");
|
|
2752
2876
|
}
|
|
2877
|
+
function accountUnrealized(rows, bad) {
|
|
2878
|
+
if (bad.includes("assetPositions") || bad.includes("assetPositions[]")) return null;
|
|
2879
|
+
const values = [];
|
|
2880
|
+
for (const r of rows) {
|
|
2881
|
+
if (r.unrealizedPnl === null) return null;
|
|
2882
|
+
values.push(r.unrealizedPnl);
|
|
2883
|
+
}
|
|
2884
|
+
const sum = sumDecimals(values);
|
|
2885
|
+
return sum === void 0 ? null : sum;
|
|
2886
|
+
}
|
|
2753
2887
|
function decodeClearinghouse(d) {
|
|
2754
2888
|
if (!isObj(d) || typeof d.dex !== "string" || !isObj(d.clearinghouseState)) return [protocolError("hyperliquid clearinghouseState frame without dex/clearinghouseState")];
|
|
2755
2889
|
const wire = `clearinghouseState:${d.dex}`;
|
|
@@ -2792,8 +2926,10 @@ function decodeClearinghouse(d) {
|
|
|
2792
2926
|
equity: ms === null ? null : dec(ms, "accountValue", bad),
|
|
2793
2927
|
available: dec(cs, "withdrawable", bad),
|
|
2794
2928
|
marginUsed: ms === null ? null : dec(ms, "totalMarginUsed", bad),
|
|
2795
|
-
unrealizedPnl
|
|
2796
|
-
//
|
|
2929
|
+
// clearinghouseState has no account-level unrealizedPnl, but the frame carries every
|
|
2930
|
+
// position of this dex with its own `unrealizedPnl`: the account value is their exact sum
|
|
2931
|
+
// ("0" with no position). null when any position's value is missing (unknown, never guessed).
|
|
2932
|
+
unrealizedPnl: accountUnrealized(rows, bad)
|
|
2797
2933
|
},
|
|
2798
2934
|
{ kind: "positions", snapshot: true, scope: d.dex, rows, remove: [] }
|
|
2799
2935
|
];
|
|
@@ -3288,6 +3424,11 @@ function decodeBbo2(d) {
|
|
|
3288
3424
|
return out;
|
|
3289
3425
|
}
|
|
3290
3426
|
|
|
3427
|
+
// src/ws/lighter-market.ts
|
|
3428
|
+
function isLighterMarketLive(row) {
|
|
3429
|
+
return row.status === "active" && row.is_frozen !== true;
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3291
3432
|
// src/ws/codecs/lighter.ts
|
|
3292
3433
|
var ALL2 = "market_stats/all";
|
|
3293
3434
|
var ORDERS_TOKEN_TTL_SEC = 3600;
|
|
@@ -3296,6 +3437,10 @@ var ZERO = /^-?0*(\.0*)?$/;
|
|
|
3296
3437
|
function bookSide(o, key, bad) {
|
|
3297
3438
|
return o[key] === "" ? null : readDec(o, key, bad);
|
|
3298
3439
|
}
|
|
3440
|
+
function lastTrade(o, bad) {
|
|
3441
|
+
const v = readDec(o, "last_trade_price", bad);
|
|
3442
|
+
return v !== void 0 && v !== null && ZERO.test(v) ? null : v;
|
|
3443
|
+
}
|
|
3299
3444
|
function percentToFraction(o, key, bad) {
|
|
3300
3445
|
const v = readDec(o, key, bad);
|
|
3301
3446
|
if (v === void 0 || v === null) return v;
|
|
@@ -3328,7 +3473,7 @@ function makeLighter(venue, quote) {
|
|
|
3328
3473
|
notes: {
|
|
3329
3474
|
rate: "WS funding is PERCENT per 1 h on the wire; emitted as a fraction per 1 h",
|
|
3330
3475
|
nextFundingTime: "Hourly settlement rule (venue-definition)",
|
|
3331
|
-
openInterest: "USD notional
|
|
3476
|
+
openInterest: "USD notional, one side \u2014 inferred, not documented by the venue (= REST orderBookDetails open_interest, base, \xD7 mark; the venue's exchangeMetrics kind=open_interest, a daily snapshot, read \u2248 2\xD7 when compared with the current value)",
|
|
3332
3477
|
user: "By accountIndex. user_stats / account_all_positions / account_all_trades need no auth; account_all_orders needs CredentialProvider.authToken (re-called at 80 % TTL). account_all_trades does not replay \u2192 gap refetch-required for fills after a reconnect",
|
|
3333
3478
|
clientId: "orders: client_order_id; fills: ask_/bid_client_id_str of our side; 0 = no client id \u2192 null",
|
|
3334
3479
|
liquidationPrice: 'liquidation_price; "0" (no liquidation price, e.g. well-covered cross margin) \u2192 null, as in the CLI REST path',
|
|
@@ -3345,7 +3490,7 @@ function makeLighter(venue, quote) {
|
|
|
3345
3490
|
limits: S5.limits,
|
|
3346
3491
|
closesOnBadSubscribe: false,
|
|
3347
3492
|
staleAfterMs: S5.staleAfterMs,
|
|
3348
|
-
openInterest: { unit: "usd", sides:
|
|
3493
|
+
openInterest: { unit: "usd", sides: "one" },
|
|
3349
3494
|
refresh: null,
|
|
3350
3495
|
async loadDirectory(ctx) {
|
|
3351
3496
|
const raw = await fetchJson(ctx.fetch, venue, `${ctx.restUrl}/api/v1/orderBooks`);
|
|
@@ -3363,6 +3508,10 @@ function makeLighter(venue, quote) {
|
|
|
3363
3508
|
continue;
|
|
3364
3509
|
}
|
|
3365
3510
|
idToSymbol.set(id, m.symbol);
|
|
3511
|
+
if (!isLighterMarketLive(m)) {
|
|
3512
|
+
nonPerp.add(id);
|
|
3513
|
+
continue;
|
|
3514
|
+
}
|
|
3366
3515
|
entries.push({
|
|
3367
3516
|
venue,
|
|
3368
3517
|
dex: null,
|
|
@@ -3498,7 +3647,7 @@ function decodeStats(venue, f, d, recvTs) {
|
|
|
3498
3647
|
index: readDec(r, "index_price", bad),
|
|
3499
3648
|
bestBid: bookSide(r, "best_bid_price", bad),
|
|
3500
3649
|
bestAsk: bookSide(r, "best_ask_price", bad),
|
|
3501
|
-
last:
|
|
3650
|
+
last: lastTrade(r, bad),
|
|
3502
3651
|
rate: percentToFraction(r, "funding_rate", bad),
|
|
3503
3652
|
predictedRate: percentToFraction(r, "current_funding_rate", bad),
|
|
3504
3653
|
openInterest: readDec(r, "open_interest", bad),
|
|
@@ -3714,6 +3863,14 @@ function decodeTicker(venue, wire, f, d) {
|
|
|
3714
3863
|
return out;
|
|
3715
3864
|
}
|
|
3716
3865
|
|
|
3866
|
+
// src/ws/aster-market.ts
|
|
3867
|
+
function isAsterListedPerpetual(row) {
|
|
3868
|
+
return row.contractType === "PERPETUAL" && row.status === "TRADING";
|
|
3869
|
+
}
|
|
3870
|
+
function asterMatchable(row) {
|
|
3871
|
+
return row.quoteAsset === "USDT";
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3717
3874
|
// src/ws/codecs/aster.ts
|
|
3718
3875
|
var S3 = VENUE_STATIC.aster;
|
|
3719
3876
|
var MARK = "!markPrice@arr@1s";
|
|
@@ -3761,7 +3918,7 @@ var aster = {
|
|
|
3761
3918
|
openInterest: "Not on WS (REST only)",
|
|
3762
3919
|
fundingInterval: `Labelled REST source ${FUNDING_INFO}, refreshed hourly; missing \u2192 null`,
|
|
3763
3920
|
volume24hNotional: "!ticker@arr q (quote-asset volume)",
|
|
3764
|
-
user: 'KNOWN GAP
|
|
3921
|
+
user: 'KNOWN GAP: no Aster user channels (signed USER_STREAM listenKey) \u2014 kind:"user" rejects CAPABILITY_UNSUPPORTED; planned, not yet available',
|
|
3765
3922
|
bbo: "<symbol>@bookTicker; `u` orders frames (non-contiguous)"
|
|
3766
3923
|
}
|
|
3767
3924
|
}),
|
|
@@ -3801,6 +3958,7 @@ var aster = {
|
|
|
3801
3958
|
if (!isObj(s) || typeof s.symbol !== "string" || typeof s.baseAsset !== "string") {
|
|
3802
3959
|
throw badShape("aster", "exchangeInfo symbol without symbol/baseAsset");
|
|
3803
3960
|
}
|
|
3961
|
+
if (!isAsterListedPerpetual(s)) continue;
|
|
3804
3962
|
const quote = typeof s.quoteAsset === "string" && s.quoteAsset !== "" ? s.quoteAsset : null;
|
|
3805
3963
|
listed.add(s.symbol);
|
|
3806
3964
|
const hours = intervals.get(s.symbol);
|
|
@@ -3811,7 +3969,8 @@ var aster = {
|
|
|
3811
3969
|
venueSymbol: s.symbol,
|
|
3812
3970
|
base: s.baseAsset,
|
|
3813
3971
|
quote,
|
|
3814
|
-
|
|
3972
|
+
// Only the USDT listing is matchable (asterMatchable — the CLI rule): BTCUSD1 is never a second BTC leg.
|
|
3973
|
+
canonical: asterMatchable(s) ? ctx.canonicalize("aster", { venueSymbol: s.symbol, dex: null, base: s.baseAsset, quote }) : null,
|
|
3815
3974
|
rateIntervalHours: hours === void 0 ? null : hours,
|
|
3816
3975
|
settlementIntervalHours: hours === void 0 ? null : hours,
|
|
3817
3976
|
source: "rest:/fapi/v3/exchangeInfo"
|
|
@@ -3934,6 +4093,11 @@ function decodeArr(wire, data2, d, fn) {
|
|
|
3934
4093
|
return out;
|
|
3935
4094
|
}
|
|
3936
4095
|
|
|
4096
|
+
// src/ws/arcus-market.ts
|
|
4097
|
+
function isArcusOnlineMarket(row) {
|
|
4098
|
+
return row.status === "ONLINE";
|
|
4099
|
+
}
|
|
4100
|
+
|
|
3937
4101
|
// src/ws/codecs/arcus.ts
|
|
3938
4102
|
var S4 = VENUE_STATIC.arcus;
|
|
3939
4103
|
var MARKETS = "markets";
|
|
@@ -3984,6 +4148,7 @@ var arcus = {
|
|
|
3984
4148
|
if (!isObj(m) || typeof m.marketDisplayName !== "string" || typeof m.marketId !== "number" || typeof m.baseAsset !== "string") {
|
|
3985
4149
|
throw badShape("arcus", "market without marketDisplayName/marketId/baseAsset");
|
|
3986
4150
|
}
|
|
4151
|
+
if (!isArcusOnlineMarket(m)) continue;
|
|
3987
4152
|
const quote = typeof m.quoteAsset === "string" ? m.quoteAsset : null;
|
|
3988
4153
|
idToSymbol.set(String(m.marketId), m.marketDisplayName);
|
|
3989
4154
|
entries.push({
|
|
@@ -4322,6 +4487,7 @@ function loadWs() {
|
|
|
4322
4487
|
return wsModule;
|
|
4323
4488
|
}
|
|
4324
4489
|
var DEFAULT_MAX_PAYLOAD = 16 * 1024 * 1024;
|
|
4490
|
+
var CLOSE_FLUSH_MS = 250;
|
|
4325
4491
|
function createNodeTransport(opts = {}) {
|
|
4326
4492
|
const maxPayload = opts.maxPayload ?? DEFAULT_MAX_PAYLOAD;
|
|
4327
4493
|
if (!Number.isSafeInteger(maxPayload) || maxPayload <= 0) {
|
|
@@ -4331,12 +4497,34 @@ function createNodeTransport(opts = {}) {
|
|
|
4331
4497
|
return (url) => {
|
|
4332
4498
|
const ws = new WebSocket(url, { maxPayload });
|
|
4333
4499
|
ws.on("error", () => void 0);
|
|
4500
|
+
let terminateTimer = null;
|
|
4501
|
+
const whenClosed = new Promise((resolve) => {
|
|
4502
|
+
ws.once("close", () => {
|
|
4503
|
+
if (terminateTimer !== null) {
|
|
4504
|
+
clearTimeout(terminateTimer);
|
|
4505
|
+
terminateTimer = null;
|
|
4506
|
+
}
|
|
4507
|
+
resolve();
|
|
4508
|
+
});
|
|
4509
|
+
});
|
|
4334
4510
|
const like = {
|
|
4511
|
+
whenClosed,
|
|
4335
4512
|
get readyState() {
|
|
4336
4513
|
return ws.readyState;
|
|
4337
4514
|
},
|
|
4338
4515
|
send: (data2) => ws.send(data2),
|
|
4339
|
-
close
|
|
4516
|
+
// The close frame is sent, then the socket is destroyed at once: `ws` would otherwise keep a
|
|
4517
|
+
// 30 s close-handshake timer (and the TCP socket) alive until the server answers, so the
|
|
4518
|
+
// process was not idle after stream.close() (conformance ws:all:close-timers).
|
|
4519
|
+
close: (code, reason) => {
|
|
4520
|
+
ws.close(code, reason);
|
|
4521
|
+
if (terminateTimer === null && ws.readyState !== ws.CLOSED) {
|
|
4522
|
+
terminateTimer = setTimeout(() => {
|
|
4523
|
+
terminateTimer = null;
|
|
4524
|
+
ws.terminate();
|
|
4525
|
+
}, CLOSE_FLUSH_MS);
|
|
4526
|
+
}
|
|
4527
|
+
},
|
|
4340
4528
|
onopen: null,
|
|
4341
4529
|
onmessage: null,
|
|
4342
4530
|
onclose: null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypurrquant/perp-ws",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Live market, funding and account WebSocket streams for perp DEXes (Hyperliquid incl. HIP-3, Pacifica, Lighter, Lighter RH, Aster, Arcus) — zero-dependency browser + Node library, the same code as perp-cli/ws",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|