repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources16d ago| #1 | /** |
| #2 | * Imperial WebSocket Client |
| #3 | * |
| #4 | * Two persistent connections: |
| #5 | * /ws — wallet-scoped invalidation (positions_updated, orders_updated) |
| #6 | * /ws/market — public market data stream (funding, marks, depth) |
| #7 | * |
| #8 | * Typed event emitter with automatic ping/pong keepalive and exponential-backoff |
| #9 | * reconnect. Works in both Node (ws package) and browser (native WebSocket). |
| #10 | */ |
| #11 | |
| #12 | type Listener<T> = (data: T) => void; |
| #13 | |
| #14 | // ─── Event payload types ────────────────────────────────────────────────────── |
| #15 | |
| #16 | export interface FundingUpdateEvent { |
| #17 | type: "funding_rate_update"; |
| #18 | symbol: string; |
| #19 | venue: string; |
| #20 | source: string; |
| #21 | longFundingRatePerHourPercent: number | null; |
| #22 | shortFundingRatePerHourPercent: number | null; |
| #23 | longBorrowRatePerHourPercent: number | null; |
| #24 | shortBorrowRatePerHourPercent: number | null; |
| #25 | } |
| #26 | |
| #27 | export interface MarkPriceUpdateEvent { |
| #28 | type: "mark_price_update"; |
| #29 | symbol: string; |
| #30 | venue: string; |
| #31 | source: string; |
| #32 | price: number; |
| #33 | fetchedAtUnixMs: number; |
| #34 | } |
| #35 | |
| #36 | export interface DepthUpdateEvent { |
| #37 | type: "phoenix_depth_update"; |
| #38 | symbol: string; |
| #39 | snapshot: { |
| #40 | bids: [number, number][]; |
| #41 | asks: [number, number][]; |
| #42 | }; |
| #43 | } |
| #44 | |
| #45 | export interface WalletEvent { |
| #46 | type: "positions_updated" | "orders_updated"; |
| #47 | } |
| #48 | |
| #49 | export type MarketEvent = FundingUpdateEvent | MarkPriceUpdateEvent | DepthUpdateEvent; |
| #50 | |
| #51 | // ─── In-memory market cache ─────────────────────────────────────────────────── |
| #52 | |
| #53 | export interface MarketCache { |
| #54 | funding: Map<string, FundingUpdateEvent>; // key: `${symbol}:${venue}` |
| #55 | markPrices: Map<string, MarkPriceUpdateEvent>; // key: `${symbol}:${venue}` |
| #56 | depth: Map<string, DepthUpdateEvent["snapshot"]>; // key: symbol |
| #57 | lastUpdated: number; |
| #58 | } |
| #59 | |
| #60 | export function createMarketCache(): MarketCache { |
| #61 | return { |
| #62 | funding: new Map(), |
| #63 | markPrices: new Map(), |
| #64 | depth: new Map(), |
| #65 | lastUpdated: 0, |
| #66 | }; |
| #67 | } |
| #68 | |
| #69 | // ─── Simple typed event emitter ─────────────────────────────────────────────── |
| #70 | |
| #71 | class TypedEmitter<Events extends object> { |
| #72 | private listeners: Partial<{ [K in keyof Events]: Listener<Events[K]>[] }> = {}; |
| #73 | |
| #74 | on<K extends keyof Events>(event: K, listener: Listener<Events[K]>): this { |
| #75 | if (!this.listeners[event]) this.listeners[event] = []; |
| #76 | (this.listeners[event] as Listener<Events[K]>[]).push(listener); |
| #77 | return this; |
| #78 | } |
| #79 | |
| #80 | off<K extends keyof Events>(event: K, listener: Listener<Events[K]>): this { |
| #81 | const arr = this.listeners[event] as Listener<Events[K]>[] | undefined; |
| #82 | if (arr) { |
| #83 | const idx = arr.indexOf(listener); |
| #84 | if (idx >= 0) arr.splice(idx, 1); |
| #85 | } |
| #86 | return this; |
| #87 | } |
| #88 | |
| #89 | emit<K extends keyof Events>(event: K, data: Events[K]): void { |
| #90 | const arr = this.listeners[event] as Listener<Events[K]>[] | undefined; |
| #91 | if (arr) { |
| #92 | for (const fn of arr) fn(data); |
| #93 | } |
| #94 | } |
| #95 | } |
| #96 | |
| #97 | // ─── Market WS events ───────────────────────────────────────────────────────── |
| #98 | |
| #99 | interface MarketWsEventMap { |
| #100 | funding: FundingUpdateEvent; |
| #101 | mark: MarkPriceUpdateEvent; |
| #102 | depth: DepthUpdateEvent; |
| #103 | connected: void; |
| #104 | disconnected: { code: number; reason: string }; |
| #105 | error: Error; |
| #106 | } |
| #107 | |
| #108 | // ─── WebSocket factory (Node ws or browser native) ──────────────────────────── |
| #109 | |
| #110 | type WsLike = { |
| #111 | onopen: (() => void) | null; |
| #112 | onclose: ((ev: { code: number; reason: string }) => void) | null; |
| #113 | onerror: ((ev: unknown) => void) | null; |
| #114 | onmessage: ((ev: { data: string }) => void) | null; |
| #115 | send(data: string): void; |
| #116 | close(): void; |
| #117 | readyState: number; |
| #118 | }; |
| #119 | |
| #120 | const WS_OPEN = 1; |
| #121 | |
| #122 | async function openWs(url: string): Promise<WsLike> { |
| #123 | // Try native global WebSocket first (browser / Bun) |
| #124 | const NativeWs = (globalThis as unknown as { WebSocket?: new (url: string) => WsLike }).WebSocket; |
| #125 | if (NativeWs) return new NativeWs(url); |
| #126 | |
| #127 | // Fall back to 'ws' package in Node |
| #128 | try { |
| #129 | const { WebSocket: NodeWs } = await import("ws" as string) as { WebSocket: new (url: string) => WsLike }; |
| #130 | return new NodeWs(url); |
| #131 | } catch { |
| #132 | throw new Error("No WebSocket implementation available. Install the 'ws' package."); |
| #133 | } |
| #134 | } |
| #135 | |
| #136 | // ─── ImperialMarketWs ───────────────────────────────────────────────────────── |
| #137 | |
| #138 | export class ImperialMarketWs extends TypedEmitter<MarketWsEventMap> { |
| #139 | private ws: WsLike | null = null; |
| #140 | private stopped = false; |
| #141 | private reconnectDelay = 1000; |
| #142 | private pingTimer: ReturnType<typeof setInterval> | null = null; |
| #143 | readonly cache: MarketCache = createMarketCache(); |
| #144 | |
| #145 | constructor( |
| #146 | private readonly wsUrl: string, |
| #147 | private readonly subscribeSymbols?: string[], |
| #148 | ) { |
| #149 | super(); |
| #150 | } |
| #151 | |
| #152 | async connect(): Promise<void> { |
| #153 | if (this.stopped) return; |
| #154 | try { |
| #155 | this.ws = await openWs(this.wsUrl); |
| #156 | } catch (err) { |
| #157 | this.emit("error", err instanceof Error ? err : new Error(String(err))); |
| #158 | this.scheduleReconnect(); |
| #159 | return; |
| #160 | } |
| #161 | |
| #162 | this.ws.onopen = () => { |
| #163 | this.reconnectDelay = 1000; |
| #164 | this.emit("connected", undefined); |
| #165 | this.subscribe(); |
| #166 | this.startPing(); |
| #167 | }; |
| #168 | |
| #169 | this.ws.onmessage = (ev) => { |
| #170 | try { |
| #171 | const msg = JSON.parse(ev.data) as MarketEvent | { type: "pong" }; |
| #172 | this.handleMessage(msg); |
| #173 | } catch { |
| #174 | // ignore parse errors |
| #175 | } |
| #176 | }; |
| #177 | |
| #178 | this.ws.onerror = (ev) => { |
| #179 | const err = ev instanceof Error ? ev : new Error("WebSocket error"); |
| #180 | this.emit("error", err); |
| #181 | }; |
| #182 | |
| #183 | this.ws.onclose = (ev) => { |
| #184 | this.stopPing(); |
| #185 | this.emit("disconnected", { code: ev.code, reason: String(ev.reason ?? "") }); |
| #186 | if (!this.stopped) this.scheduleReconnect(); |
| #187 | }; |
| #188 | } |
| #189 | |
| #190 | private subscribe(): void { |
| #191 | if (!this.ws || this.ws.readyState !== WS_OPEN) return; |
| #192 | this.ws.send(JSON.stringify({ type: "subscribe_funding_rates" })); |
| #193 | this.ws.send(JSON.stringify({ type: "subscribe_mark_prices" })); |
| #194 | this.ws.send( |
| #195 | JSON.stringify({ |
| #196 | type: "subscribe_phoenix_depth", |
| #197 | ...(this.subscribeSymbols ? { symbols: this.subscribeSymbols } : {}), |
| #198 | }), |
| #199 | ); |
| #200 | } |
| #201 | |
| #202 | private handleMessage(msg: MarketEvent | { type: "pong" }): void { |
| #203 | switch (msg.type) { |
| #204 | case "funding_rate_update": { |
| #205 | const key = `${msg.symbol}:${msg.venue}`; |
| #206 | this.cache.funding.set(key, msg); |
| #207 | this.cache.lastUpdated = Date.now(); |
| #208 | this.emit("funding", msg); |
| #209 | break; |
| #210 | } |
| #211 | case "mark_price_update": { |
| #212 | const key = `${msg.symbol}:${msg.venue}`; |
| #213 | this.cache.markPrices.set(key, msg); |
| #214 | this.cache.lastUpdated = Date.now(); |
| #215 | this.emit("mark", msg); |
| #216 | break; |
| #217 | } |
| #218 | case "phoenix_depth_update": { |
| #219 | this.cache.depth.set(msg.symbol, msg.snapshot); |
| #220 | this.cache.lastUpdated = Date.now(); |
| #221 | this.emit("depth", msg); |
| #222 | break; |
| #223 | } |
| #224 | } |
| #225 | } |
| #226 | |
| #227 | private startPing(): void { |
| #228 | this.pingTimer = setInterval(() => { |
| #229 | if (this.ws?.readyState === WS_OPEN) { |
| #230 | this.ws.send(JSON.stringify({ type: "ping" })); |
| #231 | } |
| #232 | }, 20_000); |
| #233 | } |
| #234 | |
| #235 | private stopPing(): void { |
| #236 | if (this.pingTimer) { |
| #237 | clearInterval(this.pingTimer); |
| #238 | this.pingTimer = null; |
| #239 | } |
| #240 | } |
| #241 | |
| #242 | private scheduleReconnect(): void { |
| #243 | const delay = this.reconnectDelay; |
| #244 | this.reconnectDelay = Math.min(delay * 2, 30_000); |
| #245 | setTimeout(() => this.connect(), delay); |
| #246 | } |
| #247 | |
| #248 | /** Get latest mark price for a symbol from cache. */ |
| #249 | getMarkPrice(symbol: string, venue = "phoenix"): number | null { |
| #250 | return this.cache.markPrices.get(`${symbol}:${venue}`)?.price ?? null; |
| #251 | } |
| #252 | |
| #253 | /** Get latest funding for a symbol+venue from cache. */ |
| #254 | getFunding(symbol: string, venue = "phoenix"): FundingUpdateEvent | null { |
| #255 | return this.cache.funding.get(`${symbol}:${venue}`) ?? null; |
| #256 | } |
| #257 | |
| #258 | /** Get latest depth snapshot for a symbol. */ |
| #259 | getDepth(symbol: string): DepthUpdateEvent["snapshot"] | null { |
| #260 | return this.cache.depth.get(symbol) ?? null; |
| #261 | } |
| #262 | |
| #263 | /** Best mid price from depth cache. */ |
| #264 | getMid(symbol: string): number | null { |
| #265 | const d = this.getDepth(symbol); |
| #266 | if (!d?.bids.length || !d?.asks.length) return null; |
| #267 | const bid = d.bids[0]?.[0] ?? 0; |
| #268 | const ask = d.asks[0]?.[0] ?? 0; |
| #269 | return bid > 0 && ask > 0 ? (bid + ask) / 2 : null; |
| #270 | } |
| #271 | |
| #272 | disconnect(): void { |
| #273 | this.stopped = true; |
| #274 | this.stopPing(); |
| #275 | this.ws?.close(); |
| #276 | this.ws = null; |
| #277 | } |
| #278 | } |
| #279 | |
| #280 | // ─── Wallet invalidation WS ─────────────────────────────────────────────────── |
| #281 | |
| #282 | interface WalletWsEventMap { |
| #283 | positions_updated: void; |
| #284 | orders_updated: void; |
| #285 | connected: void; |
| #286 | disconnected: { code: number; reason: string }; |
| #287 | error: Error; |
| #288 | } |
| #289 | |
| #290 | export class ImperialWalletWs extends TypedEmitter<WalletWsEventMap> { |
| #291 | private ws: WsLike | null = null; |
| #292 | private stopped = false; |
| #293 | private reconnectDelay = 1000; |
| #294 | private pingTimer: ReturnType<typeof setInterval> | null = null; |
| #295 | |
| #296 | constructor( |
| #297 | private readonly wsUrl: string, |
| #298 | private readonly wallet: string, |
| #299 | ) { |
| #300 | super(); |
| #301 | } |
| #302 | |
| #303 | async connect(): Promise<void> { |
| #304 | if (this.stopped) return; |
| #305 | try { |
| #306 | this.ws = await openWs(this.wsUrl); |
| #307 | } catch (err) { |
| #308 | this.emit("error", err instanceof Error ? err : new Error(String(err))); |
| #309 | this.scheduleReconnect(); |
| #310 | return; |
| #311 | } |
| #312 | |
| #313 | this.ws.onopen = () => { |
| #314 | this.reconnectDelay = 1000; |
| #315 | this.emit("connected", undefined); |
| #316 | this.ws?.send(JSON.stringify({ type: "subscribe", wallet: this.wallet })); |
| #317 | this.pingTimer = setInterval(() => { |
| #318 | if (this.ws?.readyState === WS_OPEN) { |
| #319 | this.ws.send(JSON.stringify({ type: "ping" })); |
| #320 | } |
| #321 | }, 20_000); |
| #322 | }; |
| #323 | |
| #324 | this.ws.onmessage = (ev) => { |
| #325 | try { |
| #326 | const msg = JSON.parse(ev.data) as WalletEvent | { type: "pong" }; |
| #327 | if (msg.type === "positions_updated") this.emit("positions_updated", undefined); |
| #328 | if (msg.type === "orders_updated") this.emit("orders_updated", undefined); |
| #329 | } catch { |
| #330 | // ignore |
| #331 | } |
| #332 | }; |
| #333 | |
| #334 | this.ws.onerror = (ev) => { |
| #335 | this.emit("error", ev instanceof Error ? ev : new Error("WS error")); |
| #336 | }; |
| #337 | |
| #338 | this.ws.onclose = (ev) => { |
| #339 | if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } |
| #340 | this.emit("disconnected", { code: ev.code, reason: String(ev.reason ?? "") }); |
| #341 | if (!this.stopped) this.scheduleReconnect(); |
| #342 | }; |
| #343 | } |
| #344 | |
| #345 | private scheduleReconnect(): void { |
| #346 | const delay = this.reconnectDelay; |
| #347 | this.reconnectDelay = Math.min(delay * 2, 30_000); |
| #348 | setTimeout(() => this.connect(), delay); |
| #349 | } |
| #350 | |
| #351 | disconnect(): void { |
| #352 | this.stopped = true; |
| #353 | if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } |
| #354 | this.ws?.close(); |
| #355 | this.ws = null; |
| #356 | } |
| #357 | } |
| #358 | |
| #359 | // ─── Factory ────────────────────────────────────────────────────────────────── |
| #360 | |
| #361 | export function createImperialMarketWs( |
| #362 | base: string, |
| #363 | symbols?: string[], |
| #364 | ): ImperialMarketWs { |
| #365 | const wsBase = base.replace(/^http/, "ws").replace(/\/api\/v1\/?$/, ""); |
| #366 | return new ImperialMarketWs(`${wsBase}/ws/market`, symbols); |
| #367 | } |
| #368 | |
| #369 | export function createImperialWalletWs(base: string, wallet: string): ImperialWalletWs { |
| #370 | const wsBase = base.replace(/^http/, "ws").replace(/\/api\/v1\/?$/, ""); |
| #371 | return new ImperialWalletWs(`${wsBase}/ws`, wallet); |
| #372 | } |
| #373 |