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 Perps Agent — real Imperial Trading API client |
| #3 | * |
| #4 | * Base: https://api.imperial.space/api/v1 |
| #5 | * Auth: JWT obtained via connect → exchange flow (or pre-set IMPERIAL_JWT env var) |
| #6 | * |
| #7 | * Environment: |
| #8 | * IMPERIAL_JWT — pre-issued JWT (skip auth flow) |
| #9 | * IMPERIAL_WALLET — operator wallet pubkey (base58, never private key) |
| #10 | * IMPERIAL_PROFILE_INDEX — subaccount index 0..5 (default 0) |
| #11 | * IMPERIAL_LIVE — "true" to enable live submission |
| #12 | * IMPERIAL_MAX_SIZE_USD — hard cap per order in USD (default 100) |
| #13 | * IMPERIAL_ALLOWED_SYMS — comma-separated allowlist (default SOL,ETH,BTC) |
| #14 | * IMPERIAL_SLIPPAGE_BPS — default slippage (default 50) |
| #15 | */ |
| #16 | |
| #17 | export const IMPERIAL_BASE = "https://api.imperial.space/api/v1"; |
| #18 | |
| #19 | // ─── Underwriter / venue codes ──────────────────────────────────────────────── |
| #20 | |
| #21 | export type Underwriter = 0 | 1 | 2 | 3; |
| #22 | export const Underwriter = { |
| #23 | Jupiter: 0 as Underwriter, |
| #24 | Flash: 1 as Underwriter, |
| #25 | Phoenix: 2 as Underwriter, |
| #26 | GMTrade: 3 as Underwriter, |
| #27 | } as const; |
| #28 | |
| #29 | export const UNDERWRITER_LABELS: Record<Underwriter, string> = { |
| #30 | 0: "Jupiter", |
| #31 | 1: "Flash Trade", |
| #32 | 2: "Phoenix", |
| #33 | 3: "GMTrade", |
| #34 | }; |
| #35 | |
| #36 | // ─── Order type codes ───────────────────────────────────────────────────────── |
| #37 | |
| #38 | export type OrderType = |
| #39 | | 0 // Market |
| #40 | | 1 // Limit |
| #41 | | 2 // StopLimit |
| #42 | | 3 // LandMine |
| #43 | | 4 // Ratchet |
| #44 | | 6 // RatchetEntry |
| #45 | | 9 // DCA |
| #46 | | 10 // FibRatchet |
| #47 | | 11 // FibRatchetEntry |
| #48 | | 12 // DcaClose |
| #49 | | 13 // DcaTimeClose |
| #50 | | 14 // DcaRatchetClose |
| #51 | | 15 // DcaTime |
| #52 | | 16;// DcaRatchet |
| #53 | |
| #54 | export const ORDER_TYPE_NAMES: Record<number, string> = { |
| #55 | 0: "Market", 1: "Limit", 2: "StopLimit", 3: "LandMine", |
| #56 | 4: "Ratchet", 6: "RatchetEntry", 9: "DCA", 10: "FibRatchet", |
| #57 | 11: "FibRatchetEntry", 12: "DcaClose", 13: "DcaTimeClose", |
| #58 | 14: "DcaRatchetClose", 15: "DcaTime", 16: "DcaRatchet", |
| #59 | }; |
| #60 | |
| #61 | // ─── Config ─────────────────────────────────────────────────────────────────── |
| #62 | |
| #63 | export interface ImperialConfig { |
| #64 | jwt: string; |
| #65 | wallet: string; |
| #66 | profileIndex: number; |
| #67 | live: boolean; |
| #68 | maxSizeUsd: number; |
| #69 | allowedSymbols: string[]; |
| #70 | slippageBps: number; |
| #71 | base: string; |
| #72 | } |
| #73 | |
| #74 | export function loadImperialConfig(env: NodeJS.ProcessEnv = process.env): ImperialConfig { |
| #75 | return { |
| #76 | jwt: env.IMPERIAL_JWT ?? "", |
| #77 | wallet: env.IMPERIAL_WALLET ?? "", |
| #78 | profileIndex: Number(env.IMPERIAL_PROFILE_INDEX ?? 0), |
| #79 | live: env.IMPERIAL_LIVE === "true", |
| #80 | maxSizeUsd: Number(env.IMPERIAL_MAX_SIZE_USD ?? 100), |
| #81 | allowedSymbols: (env.IMPERIAL_ALLOWED_SYMS ?? "SOL,ETH,BTC") |
| #82 | .split(",") |
| #83 | .map((s) => s.trim().toUpperCase()) |
| #84 | .filter(Boolean), |
| #85 | slippageBps: Number(env.IMPERIAL_SLIPPAGE_BPS ?? 50), |
| #86 | base: env.IMPERIAL_API_BASE ?? IMPERIAL_BASE, |
| #87 | }; |
| #88 | } |
| #89 | |
| #90 | // ─── HTTP helpers ───────────────────────────────────────────────────────────── |
| #91 | |
| #92 | /** sizeUsd in human dollars → 6-decimal fixed point (1_000_000 = $1) */ |
| #93 | export function usdToFixed(usd: number): number { |
| #94 | return Math.round(usd * 1_000_000); |
| #95 | } |
| #96 | |
| #97 | /** 6-decimal fixed point → human dollars */ |
| #98 | export function fixedToUsd(fixed: number): number { |
| #99 | return fixed / 1_000_000; |
| #100 | } |
| #101 | |
| #102 | async function imperialGet<T>(base: string, path: string, jwt?: string): Promise<T> { |
| #103 | const res = await fetch(`${base}${path}`, { |
| #104 | headers: jwt ? { Authorization: `Bearer ${jwt}` } : {}, |
| #105 | }); |
| #106 | if (!res.ok) { |
| #107 | const body = await res.text().catch(() => ""); |
| #108 | throw new Error(`GET ${path} → ${res.status}: ${body}`); |
| #109 | } |
| #110 | return res.json() as Promise<T>; |
| #111 | } |
| #112 | |
| #113 | async function imperialPost<T>( |
| #114 | base: string, |
| #115 | path: string, |
| #116 | body: unknown, |
| #117 | jwt?: string, |
| #118 | ): Promise<T> { |
| #119 | const res = await fetch(`${base}${path}`, { |
| #120 | method: "POST", |
| #121 | headers: { |
| #122 | "Content-Type": "application/json", |
| #123 | ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), |
| #124 | }, |
| #125 | body: JSON.stringify(body), |
| #126 | }); |
| #127 | if (!res.ok) { |
| #128 | const text = await res.text().catch(() => ""); |
| #129 | throw new Error(`POST ${path} → ${res.status}: ${text}`); |
| #130 | } |
| #131 | return res.json() as Promise<T>; |
| #132 | } |
| #133 | |
| #134 | // ─── Auth types ─────────────────────────────────────────────────────────────── |
| #135 | |
| #136 | export interface ConnectResponse { |
| #137 | code: string; |
| #138 | } |
| #139 | |
| #140 | export interface ExchangeResponse { |
| #141 | jwt: string; |
| #142 | expires_at: string; |
| #143 | } |
| #144 | |
| #145 | // ─── Order request shapes ───────────────────────────────────────────────────── |
| #146 | |
| #147 | export interface ExtraData { |
| #148 | // Ratchet / FibRatchet |
| #149 | worstPrice?: number; |
| #150 | ratchetSize?: number; |
| #151 | // LandMine |
| #152 | waitPrice?: number; |
| #153 | waitDuration?: number; |
| #154 | // DCA |
| #155 | dcaStartPrice?: number; |
| #156 | dcaEndPrice?: number; |
| #157 | dcaNumLegs?: number; |
| #158 | // DcaClose |
| #159 | dcaCloseStartPrice?: number; |
| #160 | dcaCloseEndPrice?: number; |
| #161 | dcaCloseNumLegs?: number; |
| #162 | // DcaTimeClose |
| #163 | dcaCloseIntervalSeconds?: number; |
| #164 | // DcaRatchetClose |
| #165 | dcaCloseRatchetSize?: number; |
| #166 | // DcaTime |
| #167 | dcaIntervalSeconds?: number; |
| #168 | // DcaRatchet |
| #169 | dcaRatchetSize?: number; |
| #170 | } |
| #171 | |
| #172 | export interface MobileCreateOrderRequest { |
| #173 | wallet: string; |
| #174 | profileIndex: number; |
| #175 | action: 0 | 1; // 0=Increase, 1=Decrease |
| #176 | side: 0 | 1; // 0=long, 1=short |
| #177 | underwriter: Underwriter; |
| #178 | orderType: OrderType; |
| #179 | sizeUsd: number; // 6-decimal fixed point |
| #180 | collateralAmount: number; // collateral mint native units |
| #181 | slippageBps: number; |
| #182 | fundingStatus: 0 | 1; // 0=funded, 1=pending |
| #183 | priority: number; |
| #184 | triggerPrice: number; // oracle scale 1e9 |
| #185 | triggerCondition: 0 | 1; // 0=Above, 1=Below |
| #186 | symbol?: string | null; |
| #187 | marketMint?: string | null; |
| #188 | marketPrice?: number; |
| #189 | parentOrderPda?: string | null; |
| #190 | phoenixNative?: unknown | null; |
| #191 | extraData?: ExtraData | null; |
| #192 | } |
| #193 | |
| #194 | export interface MobileOrderResponse { |
| #195 | success: boolean; |
| #196 | error: string | null; |
| #197 | orderPda: string | null; |
| #198 | signature: string | null; |
| #199 | } |
| #200 | |
| #201 | export interface MobileBatchRequest { |
| #202 | entry: MobileCreateOrderRequest; |
| #203 | closeOrders?: MobileCreateOrderRequest[]; |
| #204 | } |
| #205 | |
| #206 | export interface MobileBatchResponse { |
| #207 | entry: MobileOrderResponse; |
| #208 | closeOrders: MobileOrderResponse[]; |
| #209 | } |
| #210 | |
| #211 | export interface MobileCancelRequest { |
| #212 | wallet: string; |
| #213 | profileIndex: number; |
| #214 | orderPda: string; |
| #215 | } |
| #216 | |
| #217 | export interface MobileCollateralRequest { |
| #218 | wallet: string; |
| #219 | profileIndex: number; |
| #220 | action: 0 | 1; // 0=add, 1=remove |
| #221 | marketMint: string; |
| #222 | side: 0 | 1; |
| #223 | underwriter: Underwriter; |
| #224 | collateralAmount: number; |
| #225 | price: number; |
| #226 | slippageBps: number; |
| #227 | } |
| #228 | |
| #229 | export interface MobileUpdateRequest { |
| #230 | wallet: string; |
| #231 | profileIndex: number; |
| #232 | orderPda: string; |
| #233 | sizeUsd?: number; |
| #234 | triggerPrice?: number; |
| #235 | slippageBps?: number; |
| #236 | closeBps?: number; |
| #237 | priority?: number; |
| #238 | proOrderUpdate?: { |
| #239 | type: string; |
| #240 | worstPrice?: number; |
| #241 | ratchetSize?: number; |
| #242 | waitPrice?: number; |
| #243 | waitDurationSeconds?: number; |
| #244 | } | null; |
| #245 | } |
| #246 | |
| #247 | export interface DepositBuildTxRequest { |
| #248 | wallet: string; |
| #249 | profileIndex: number; |
| #250 | amount: number; // USDC native units (6-decimal) |
| #251 | mode: "deposit" | "withdraw"; |
| #252 | } |
| #253 | |
| #254 | // ─── Read response shapes ───────────────────────────────────────────────────── |
| #255 | |
| #256 | export interface ProfileBalance { |
| #257 | profileIndex: number; |
| #258 | profilePda: string; |
| #259 | usdc: number; |
| #260 | } |
| #261 | |
| #262 | export interface BalancesResponse { |
| #263 | wallet: string; |
| #264 | profiles: ProfileBalance[]; |
| #265 | } |
| #266 | |
| #267 | export interface FundingRateEntry { |
| #268 | symbol: string; |
| #269 | venue: string; |
| #270 | source: string; |
| #271 | longFundingRatePerHourPercent: number | null; |
| #272 | shortFundingRatePerHourPercent: number | null; |
| #273 | longBorrowRatePerHourPercent: number | null; |
| #274 | shortBorrowRatePerHourPercent: number | null; |
| #275 | } |
| #276 | |
| #277 | export interface MarkPriceEntry { |
| #278 | symbol: string; |
| #279 | venue: string; |
| #280 | source: string; |
| #281 | price: number; |
| #282 | fetchedAtUnixMs: number; |
| #283 | } |
| #284 | |
| #285 | export interface RouteRecommendation { |
| #286 | underwriter: Underwriter; |
| #287 | venue: string; |
| #288 | estimatedFee: number; |
| #289 | reason: string; |
| #290 | } |
| #291 | |
| #292 | export interface PhoenixDepthSnapshot { |
| #293 | symbol: string; |
| #294 | bids: [number, number][]; |
| #295 | asks: [number, number][]; |
| #296 | } |
| #297 | |
| #298 | // ─── Signal scoring ─────────────────────────────────────────────────────────── |
| #299 | |
| #300 | export type AgentDecision = "buy" | "sell" | "watch"; |
| #301 | |
| #302 | export interface ImperialMarketSnapshot { |
| #303 | symbol: string; |
| #304 | markPrice: number | null; |
| #305 | fundingRates: FundingRateEntry[]; |
| #306 | depth: PhoenixDepthSnapshot | null; |
| #307 | } |
| #308 | |
| #309 | function asArray<T>(value: unknown, keys: string[] = []): T[] { |
| #310 | if (Array.isArray(value)) return value as T[]; |
| #311 | if (value && typeof value === "object") { |
| #312 | for (const key of keys) { |
| #313 | const nested = (value as Record<string, unknown>)[key]; |
| #314 | if (Array.isArray(nested)) return nested as T[]; |
| #315 | } |
| #316 | } |
| #317 | return []; |
| #318 | } |
| #319 | |
| #320 | export interface AgentSignal { |
| #321 | symbol: string; |
| #322 | decision: AgentDecision; |
| #323 | confidence: number; |
| #324 | scores: { |
| #325 | momentum: number; |
| #326 | funding: number; |
| #327 | liquidity: number; |
| #328 | }; |
| #329 | rationale: string; |
| #330 | snapshot: ImperialMarketSnapshot; |
| #331 | } |
| #332 | |
| #333 | export function scoreImperialMarket(snap: ImperialMarketSnapshot): AgentSignal { |
| #334 | const scores = { momentum: 0, funding: 0, liquidity: 0 }; |
| #335 | |
| #336 | // Funding: find Phoenix funding, average long rate |
| #337 | const phoenixFunding = snap.fundingRates.filter((r) => r.venue === "phoenix"); |
| #338 | if (phoenixFunding.length > 0) { |
| #339 | const avgLong = |
| #340 | phoenixFunding.reduce((acc, r) => acc + (r.longFundingRatePerHourPercent ?? 0), 0) / |
| #341 | phoenixFunding.length; |
| #342 | // > 0 longs pay shorts → crowded long → sell bias |
| #343 | scores.funding = Math.max(-1, Math.min(1, -avgLong * 20)); |
| #344 | } |
| #345 | |
| #346 | // Liquidity: Phoenix depth spread |
| #347 | const depth = snap.depth; |
| #348 | if ((depth?.bids?.length ?? 0) > 0 && (depth?.asks?.length ?? 0) > 0) { |
| #349 | const bid = depth?.bids[0]?.[0] ?? 0; |
| #350 | const ask = depth?.asks[0]?.[0] ?? 0; |
| #351 | if (bid > 0) { |
| #352 | const spreadBps = ((ask - bid) / bid) * 10000; |
| #353 | scores.liquidity = spreadBps < 10 ? 1 : spreadBps < 30 ? 0.5 : 0; |
| #354 | } |
| #355 | } |
| #356 | |
| #357 | // Momentum: mark vs mid |
| #358 | if (snap.markPrice !== null && (depth?.bids?.length ?? 0) > 0 && (depth?.asks?.length ?? 0) > 0) { |
| #359 | const bid = depth?.bids[0]?.[0] ?? 0; |
| #360 | const ask = depth?.asks[0]?.[0] ?? 0; |
| #361 | if (bid > 0 && ask > 0) { |
| #362 | const mid = (bid + ask) / 2; |
| #363 | const drift = (snap.markPrice - mid) / mid; |
| #364 | scores.momentum = Math.max(-1, Math.min(1, -drift * 50)); |
| #365 | } |
| #366 | } |
| #367 | |
| #368 | const composite = |
| #369 | scores.momentum * 0.40 + |
| #370 | scores.funding * 0.40 + |
| #371 | scores.liquidity * 0.20; |
| #372 | |
| #373 | const confidence = Math.min(1, Math.abs(composite)); |
| #374 | const THRESHOLD = 0.25; |
| #375 | const decision: AgentDecision = |
| #376 | composite > THRESHOLD ? "buy" : composite < -THRESHOLD ? "sell" : "watch"; |
| #377 | |
| #378 | const phoenixRate = phoenixFunding[0]; |
| #379 | const parts: string[] = []; |
| #380 | if (phoenixRate?.longFundingRatePerHourPercent !== null && phoenixRate !== undefined) { |
| #381 | const ann = (phoenixRate.longFundingRatePerHourPercent ?? 0) * 8760; |
| #382 | parts.push(`funding ${ann.toFixed(1)}% ann`); |
| #383 | } |
| #384 | if (scores.liquidity > 0) { |
| #385 | parts.push(`liquidity ${scores.liquidity.toFixed(2)}`); |
| #386 | } |
| #387 | parts.push(`composite ${composite.toFixed(3)}`); |
| #388 | |
| #389 | return { |
| #390 | symbol: snap.symbol, |
| #391 | decision, |
| #392 | confidence, |
| #393 | scores, |
| #394 | rationale: `${decision.toUpperCase()} — ${parts.join(" | ")}`, |
| #395 | snapshot: snap, |
| #396 | }; |
| #397 | } |
| #398 | |
| #399 | // ─── Audit trail ────────────────────────────────────────────────────────────── |
| #400 | |
| #401 | export type ExecutionStatus = "preview" | "submitted" | "failed" | "blocked"; |
| #402 | |
| #403 | export interface ExecutionRecord { |
| #404 | id: string; |
| #405 | ts: number; |
| #406 | wallet: string; |
| #407 | profileIndex: number; |
| #408 | venue: string; |
| #409 | underwriter: Underwriter; |
| #410 | symbol: string; |
| #411 | side: "long" | "short"; |
| #412 | action: "increase" | "decrease"; |
| #413 | orderType: string; |
| #414 | sizeUsd: number; |
| #415 | dryRun: boolean; |
| #416 | request: unknown; |
| #417 | response: unknown; |
| #418 | status: ExecutionStatus; |
| #419 | error?: string; |
| #420 | txSignature?: string; |
| #421 | orderPda?: string; |
| #422 | } |
| #423 | |
| #424 | function makeId(): string { |
| #425 | return `imp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; |
| #426 | } |
| #427 | |
| #428 | // ─── Main client class ──────────────────────────────────────────────────────── |
| #429 | |
| #430 | export class ImperialClient { |
| #431 | config: ImperialConfig; |
| #432 | |
| #433 | constructor(config?: Partial<ImperialConfig>) { |
| #434 | this.config = { ...loadImperialConfig(), ...config }; |
| #435 | } |
| #436 | |
| #437 | get jwt(): string { |
| #438 | return this.config.jwt; |
| #439 | } |
| #440 | |
| #441 | get wallet(): string { |
| #442 | return this.config.wallet; |
| #443 | } |
| #444 | |
| #445 | // ── Auth ── |
| #446 | |
| #447 | /** |
| #448 | * Exchange a pre-signed connect code for a JWT. |
| #449 | * The caller must sign imperial:mobile-connect:{wallet}:{nonce} externally. |
| #450 | */ |
| #451 | async exchangeCode(code: string): Promise<ExchangeResponse> { |
| #452 | const data = await imperialPost<ExchangeResponse>( |
| #453 | this.config.base, |
| #454 | "/mobile/exchange", |
| #455 | { code }, |
| #456 | ); |
| #457 | this.config.jwt = data.jwt; |
| #458 | return data; |
| #459 | } |
| #460 | |
| #461 | /** Revoke the current JWT (call on bot shutdown). */ |
| #462 | async revokeJwt(): Promise<void> { |
| #463 | if (!this.config.jwt) return; |
| #464 | await imperialPost(this.config.base, "/mobile/revoke", { jwt: this.config.jwt }); |
| #465 | this.config.jwt = ""; |
| #466 | } |
| #467 | |
| #468 | // ── Public reads (no auth) ── |
| #469 | |
| #470 | async getStatus(): Promise<unknown> { |
| #471 | return imperialGet(this.config.base, "/status"); |
| #472 | } |
| #473 | |
| #474 | async getFundingRates(): Promise<FundingRateEntry[]> { |
| #475 | return imperialGet<FundingRateEntry[]>(this.config.base, "/funding-rates"); |
| #476 | } |
| #477 | |
| #478 | async getMarkPrices(): Promise<MarkPriceEntry[]> { |
| #479 | return imperialGet<MarkPriceEntry[]>(this.config.base, "/mark-prices"); |
| #480 | } |
| #481 | |
| #482 | async getPhoenixMarkPrices(): Promise<MarkPriceEntry[]> { |
| #483 | return imperialGet<MarkPriceEntry[]>(this.config.base, "/phoenix/mark-prices"); |
| #484 | } |
| #485 | |
| #486 | async getPhoenixDepth(symbol?: string): Promise<PhoenixDepthSnapshot | PhoenixDepthSnapshot[]> { |
| #487 | const path = symbol |
| #488 | ? `/phoenix/depth?symbol=${encodeURIComponent(symbol)}` |
| #489 | : "/phoenix/depth"; |
| #490 | return imperialGet(this.config.base, path); |
| #491 | } |
| #492 | |
| #493 | async getPhoenixMarkets(): Promise<unknown> { |
| #494 | return imperialGet(this.config.base, "/phoenix/markets"); |
| #495 | } |
| #496 | |
| #497 | async getFlashMarkets(): Promise<unknown> { |
| #498 | return imperialGet(this.config.base, "/flash/markets"); |
| #499 | } |
| #500 | |
| #501 | async getGMTradeMarkets(): Promise<unknown> { |
| #502 | return imperialGet(this.config.base, "/gmtrade/markets"); |
| #503 | } |
| #504 | |
| #505 | async getGMTradeFundingRates(): Promise<unknown> { |
| #506 | return imperialGet(this.config.base, "/gmtrade/funding-rates"); |
| #507 | } |
| #508 | |
| #509 | async getPositions(wallet?: string): Promise<unknown> { |
| #510 | const path = wallet ? `/positions?wallet=${wallet}` : `/positions?wallet=${this.config.wallet}`; |
| #511 | return imperialGet(this.config.base, path); |
| #512 | } |
| #513 | |
| #514 | async getOrders(wallet?: string): Promise<unknown> { |
| #515 | const path = wallet ? `/orders?wallet=${wallet}` : `/orders?wallet=${this.config.wallet}`; |
| #516 | return imperialGet(this.config.base, path); |
| #517 | } |
| #518 | |
| #519 | async getPassthroughOrders(wallet?: string): Promise<unknown> { |
| #520 | const w = wallet ?? this.config.wallet; |
| #521 | return imperialGet(this.config.base, `/passthrough/users/${w}/orders`); |
| #522 | } |
| #523 | |
| #524 | async getRoute(asset: string, side: 0 | 1, notional: number): Promise<RouteRecommendation> { |
| #525 | const params = new URLSearchParams({ |
| #526 | asset, |
| #527 | side: String(side), |
| #528 | notional: String(notional), |
| #529 | }); |
| #530 | return imperialGet<RouteRecommendation>(this.config.base, `/route?${params}`); |
| #531 | } |
| #532 | |
| #533 | async getPriorityFee(): Promise<unknown> { |
| #534 | return imperialGet(this.config.base, "/priority-fee"); |
| #535 | } |
| #536 | |
| #537 | async getTrades(wallet?: string): Promise<unknown> { |
| #538 | const path = wallet ? `/trades?wallet=${wallet}` : `/trades?wallet=${this.config.wallet}`; |
| #539 | return imperialGet(this.config.base, path); |
| #540 | } |
| #541 | |
| #542 | // ── Auth reads ── |
| #543 | |
| #544 | async getBalances(): Promise<BalancesResponse> { |
| #545 | this.requireJwt(); |
| #546 | return imperialGet<BalancesResponse>(this.config.base, "/mobile/balances", this.config.jwt); |
| #547 | } |
| #548 | |
| #549 | // ── Trading ── |
| #550 | |
| #551 | private requireJwt(): void { |
| #552 | if (!this.config.jwt) { |
| #553 | throw new Error( |
| #554 | "No Imperial JWT. Set IMPERIAL_JWT env var or complete connect/exchange flow.", |
| #555 | ); |
| #556 | } |
| #557 | } |
| #558 | |
| #559 | private requireLive(context: string): void { |
| #560 | if (!this.config.live) { |
| #561 | throw new Error( |
| #562 | `${context}: live mode not enabled. Set IMPERIAL_LIVE=true to submit real orders.`, |
| #563 | ); |
| #564 | } |
| #565 | } |
| #566 | |
| #567 | /** Build a baseline order request with all required fields. */ |
| #568 | buildOrderRequest(opts: { |
| #569 | symbol: string; |
| #570 | side: 0 | 1; |
| #571 | action: 0 | 1; |
| #572 | sizeUsd: number; |
| #573 | underwriter?: Underwriter; |
| #574 | orderType?: OrderType; |
| #575 | collateralAmount?: number; |
| #576 | slippageBps?: number; |
| #577 | triggerPrice?: number; |
| #578 | triggerCondition?: 0 | 1; |
| #579 | fundingStatus?: 0 | 1; |
| #580 | priority?: number; |
| #581 | extraData?: ExtraData | null; |
| #582 | parentOrderPda?: string | null; |
| #583 | }): MobileCreateOrderRequest { |
| #584 | const sym = opts.symbol.toUpperCase(); |
| #585 | if (!this.config.allowedSymbols.includes(sym)) { |
| #586 | throw new Error(`${sym} is not in IMPERIAL_ALLOWED_SYMS.`); |
| #587 | } |
| #588 | const sizeFixed = usdToFixed(Math.min(opts.sizeUsd, this.config.maxSizeUsd)); |
| #589 | if (sizeFixed <= 0) throw new Error("Order size must be positive."); |
| #590 | |
| #591 | return { |
| #592 | wallet: this.config.wallet, |
| #593 | profileIndex: this.config.profileIndex, |
| #594 | action: opts.action, |
| #595 | side: opts.side, |
| #596 | underwriter: opts.underwriter ?? Underwriter.Phoenix, |
| #597 | orderType: opts.orderType ?? 0, |
| #598 | sizeUsd: sizeFixed, |
| #599 | collateralAmount: opts.collateralAmount ?? sizeFixed, |
| #600 | slippageBps: opts.slippageBps ?? this.config.slippageBps, |
| #601 | fundingStatus: opts.fundingStatus ?? 0, |
| #602 | priority: opts.priority ?? 0, |
| #603 | triggerPrice: opts.triggerPrice ?? 0, |
| #604 | triggerCondition: opts.triggerCondition ?? 0, |
| #605 | symbol: sym, |
| #606 | extraData: opts.extraData ?? null, |
| #607 | parentOrderPda: opts.parentOrderPda ?? null, |
| #608 | }; |
| #609 | } |
| #610 | |
| #611 | /** Submit a single order. Dry-run when IMPERIAL_LIVE is not set. */ |
| #612 | async placeOrder( |
| #613 | req: MobileCreateOrderRequest, |
| #614 | dryRun = !this.config.live, |
| #615 | ): Promise<{ response: MobileOrderResponse; record: ExecutionRecord }> { |
| #616 | this.requireJwt(); |
| #617 | if (!dryRun) this.requireLive("placeOrder"); |
| #618 | |
| #619 | const record: ExecutionRecord = { |
| #620 | id: makeId(), |
| #621 | ts: Date.now(), |
| #622 | wallet: this.config.wallet, |
| #623 | profileIndex: this.config.profileIndex, |
| #624 | venue: UNDERWRITER_LABELS[req.underwriter], |
| #625 | underwriter: req.underwriter, |
| #626 | symbol: req.symbol ?? "?", |
| #627 | side: req.side === 0 ? "long" : "short", |
| #628 | action: req.action === 0 ? "increase" : "decrease", |
| #629 | orderType: ORDER_TYPE_NAMES[req.orderType] ?? String(req.orderType), |
| #630 | sizeUsd: fixedToUsd(req.sizeUsd), |
| #631 | dryRun, |
| #632 | request: req, |
| #633 | response: null, |
| #634 | status: dryRun ? "preview" : "submitted", |
| #635 | }; |
| #636 | |
| #637 | if (dryRun) { |
| #638 | record.response = { dry_run: true, payload: req }; |
| #639 | return { response: { success: true, error: null, orderPda: null, signature: null }, record }; |
| #640 | } |
| #641 | |
| #642 | try { |
| #643 | const response = await imperialPost<MobileOrderResponse>( |
| #644 | this.config.base, |
| #645 | "/mobile/orders", |
| #646 | req, |
| #647 | this.config.jwt, |
| #648 | ); |
| #649 | record.response = response; |
| #650 | record.status = response.success ? "submitted" : "failed"; |
| #651 | record.error = response.error ?? undefined; |
| #652 | record.txSignature = response.signature ?? undefined; |
| #653 | record.orderPda = response.orderPda ?? undefined; |
| #654 | return { response, record }; |
| #655 | } catch (err) { |
| #656 | record.status = "failed"; |
| #657 | record.error = err instanceof Error ? err.message : String(err); |
| #658 | record.response = null; |
| #659 | return { |
| #660 | response: { success: false, error: record.error, orderPda: null, signature: null }, |
| #661 | record, |
| #662 | }; |
| #663 | } |
| #664 | } |
| #665 | |
| #666 | /** Submit entry + TP/SL legs in one atomic batch request. */ |
| #667 | async placeBatch( |
| #668 | req: MobileBatchRequest, |
| #669 | dryRun = !this.config.live, |
| #670 | ): Promise<{ response: MobileBatchResponse; records: ExecutionRecord[] }> { |
| #671 | this.requireJwt(); |
| #672 | if (!dryRun) this.requireLive("placeBatch"); |
| #673 | |
| #674 | if (dryRun) { |
| #675 | const mock: MobileBatchResponse = { |
| #676 | entry: { success: true, error: null, orderPda: null, signature: null }, |
| #677 | closeOrders: (req.closeOrders ?? []).map(() => ({ |
| #678 | success: true, |
| #679 | error: null, |
| #680 | orderPda: null, |
| #681 | signature: null, |
| #682 | })), |
| #683 | }; |
| #684 | return { response: mock, records: [] }; |
| #685 | } |
| #686 | |
| #687 | const response = await imperialPost<MobileBatchResponse>( |
| #688 | this.config.base, |
| #689 | "/mobile/orders/batch", |
| #690 | req, |
| #691 | this.config.jwt, |
| #692 | ); |
| #693 | |
| #694 | const records: ExecutionRecord[] = []; |
| #695 | const entryRecord: ExecutionRecord = { |
| #696 | id: makeId(), |
| #697 | ts: Date.now(), |
| #698 | wallet: this.config.wallet, |
| #699 | profileIndex: this.config.profileIndex, |
| #700 | venue: UNDERWRITER_LABELS[req.entry.underwriter], |
| #701 | underwriter: req.entry.underwriter, |
| #702 | symbol: req.entry.symbol ?? "?", |
| #703 | side: req.entry.side === 0 ? "long" : "short", |
| #704 | action: "increase", |
| #705 | orderType: ORDER_TYPE_NAMES[req.entry.orderType] ?? String(req.entry.orderType), |
| #706 | sizeUsd: fixedToUsd(req.entry.sizeUsd), |
| #707 | dryRun: false, |
| #708 | request: req.entry, |
| #709 | response: response.entry, |
| #710 | status: response.entry.success ? "submitted" : "failed", |
| #711 | error: response.entry.error ?? undefined, |
| #712 | txSignature: response.entry.signature ?? undefined, |
| #713 | orderPda: response.entry.orderPda ?? undefined, |
| #714 | }; |
| #715 | records.push(entryRecord); |
| #716 | |
| #717 | return { response, records }; |
| #718 | } |
| #719 | |
| #720 | async cancelOrder(req: MobileCancelRequest): Promise<MobileOrderResponse> { |
| #721 | this.requireJwt(); |
| #722 | return imperialPost<MobileOrderResponse>( |
| #723 | this.config.base, |
| #724 | "/mobile/orders/cancel", |
| #725 | req, |
| #726 | this.config.jwt, |
| #727 | ); |
| #728 | } |
| #729 | |
| #730 | async updateOrder(req: MobileUpdateRequest): Promise<MobileOrderResponse> { |
| #731 | this.requireJwt(); |
| #732 | return imperialPost<MobileOrderResponse>( |
| #733 | this.config.base, |
| #734 | "/mobile/orders/update", |
| #735 | req, |
| #736 | this.config.jwt, |
| #737 | ); |
| #738 | } |
| #739 | |
| #740 | async editCollateral(req: MobileCollateralRequest): Promise<MobileOrderResponse> { |
| #741 | this.requireJwt(); |
| #742 | return imperialPost<MobileOrderResponse>( |
| #743 | this.config.base, |
| #744 | "/mobile/orders/collateral", |
| #745 | req, |
| #746 | this.config.jwt, |
| #747 | ); |
| #748 | } |
| #749 | |
| #750 | async buildDepositTx(req: DepositBuildTxRequest): Promise<{ transaction: string }> { |
| #751 | return imperialPost<{ transaction: string }>( |
| #752 | this.config.base, |
| #753 | "/deposit/build-tx", |
| #754 | req, |
| #755 | ); |
| #756 | } |
| #757 | |
| #758 | async syncProfile(wallet?: string, index?: number): Promise<unknown> { |
| #759 | const w = wallet ?? this.config.wallet; |
| #760 | const i = index ?? this.config.profileIndex; |
| #761 | return imperialPost(this.config.base, `/passthrough/users/${w}/profiles/${i}/sync`, {}); |
| #762 | } |
| #763 | |
| #764 | async registerPhoenix(wallet?: string, profileIndex?: number): Promise<unknown> { |
| #765 | return imperialPost(this.config.base, "/phoenix/register", { |
| #766 | wallet: wallet ?? this.config.wallet, |
| #767 | profileIndex: profileIndex ?? this.config.profileIndex, |
| #768 | }); |
| #769 | } |
| #770 | |
| #771 | // ── Market snapshot + OODA ── |
| #772 | |
| #773 | /** Fetch a full market snapshot for signal scoring. */ |
| #774 | async fetchSnapshot(symbol: string): Promise<ImperialMarketSnapshot> { |
| #775 | const sym = symbol.toUpperCase(); |
| #776 | const [fundingRates, marks, depth] = await Promise.allSettled([ |
| #777 | this.getFundingRates(), |
| #778 | this.getMarkPrices(), |
| #779 | this.getPhoenixDepth(sym).catch(() => null), |
| #780 | ]); |
| #781 | |
| #782 | const allFunding = |
| #783 | fundingRates.status === "fulfilled" |
| #784 | ? asArray<FundingRateEntry>(fundingRates.value, ["fundingRates", "data", "items"]) |
| #785 | : []; |
| #786 | const allMarks = |
| #787 | marks.status === "fulfilled" |
| #788 | ? asArray<MarkPriceEntry>(marks.value, ["markPrices", "marks", "data", "items"]) |
| #789 | : []; |
| #790 | const rawDepth = depth.status === "fulfilled" ? depth.value : null; |
| #791 | |
| #792 | const markEntry = allMarks.find( |
| #793 | (m) => m.symbol.toUpperCase() === sym && m.venue === "phoenix", |
| #794 | ) ?? allMarks.find((m) => m.symbol.toUpperCase() === sym); |
| #795 | |
| #796 | const depthSnapRaw = Array.isArray(rawDepth) |
| #797 | ? (rawDepth as PhoenixDepthSnapshot[]).find((d) => d.symbol.toUpperCase() === sym) ?? null |
| #798 | : (rawDepth as PhoenixDepthSnapshot | null); |
| #799 | const depthSnap = |
| #800 | Array.isArray(depthSnapRaw?.bids) && Array.isArray(depthSnapRaw?.asks) |
| #801 | ? depthSnapRaw |
| #802 | : null; |
| #803 | |
| #804 | return { |
| #805 | symbol: sym, |
| #806 | markPrice: markEntry?.price ?? null, |
| #807 | fundingRates: allFunding.filter((r) => r.symbol.toUpperCase() === sym), |
| #808 | depth: depthSnap, |
| #809 | }; |
| #810 | } |
| #811 | |
| #812 | /** Full OODA cycle: observe → score → decide → optionally route. */ |
| #813 | async runCycle( |
| #814 | symbol: string, |
| #815 | opts: { sizeUsd?: number; autoRoute?: boolean } = {}, |
| #816 | ): Promise<{ signal: AgentSignal; record: ExecutionRecord | null }> { |
| #817 | const snap = await this.fetchSnapshot(symbol); |
| #818 | const signal = scoreImperialMarket(snap); |
| #819 | |
| #820 | if (signal.decision === "watch" || !opts.autoRoute) { |
| #821 | return { signal, record: null }; |
| #822 | } |
| #823 | |
| #824 | const req = this.buildOrderRequest({ |
| #825 | symbol: signal.symbol, |
| #826 | side: signal.decision === "buy" ? 0 : 1, |
| #827 | action: 0, |
| #828 | sizeUsd: opts.sizeUsd ?? this.config.maxSizeUsd, |
| #829 | }); |
| #830 | const { record } = await this.placeOrder(req, !this.config.live); |
| #831 | return { signal, record }; |
| #832 | } |
| #833 | |
| #834 | /** Scan all allowed symbols and return ranked signals. */ |
| #835 | async runScan(opts: { sizeUsd?: number; autoRoute?: boolean } = {}): Promise<{ |
| #836 | signals: AgentSignal[]; |
| #837 | records: ExecutionRecord[]; |
| #838 | }> { |
| #839 | const snaps = await Promise.all( |
| #840 | this.config.allowedSymbols.map((sym) => this.fetchSnapshot(sym)), |
| #841 | ); |
| #842 | |
| #843 | const signals = snaps.map((s) => scoreImperialMarket(s)); |
| #844 | signals.sort((a, b) => b.confidence - a.confidence); |
| #845 | |
| #846 | const records: ExecutionRecord[] = []; |
| #847 | if (opts.autoRoute) { |
| #848 | for (const sig of signals.filter((s) => s.decision !== "watch")) { |
| #849 | try { |
| #850 | const req = this.buildOrderRequest({ |
| #851 | symbol: sig.symbol, |
| #852 | side: sig.decision === "buy" ? 0 : 1, |
| #853 | action: 0, |
| #854 | sizeUsd: opts.sizeUsd ?? this.config.maxSizeUsd, |
| #855 | }); |
| #856 | const { record } = await this.placeOrder(req); |
| #857 | if (record) records.push(record); |
| #858 | } catch { |
| #859 | // continue |
| #860 | } |
| #861 | } |
| #862 | } |
| #863 | |
| #864 | return { signals, records }; |
| #865 | } |
| #866 | |
| #867 | /** Health summary. */ |
| #868 | async healthCheck(): Promise<{ |
| #869 | configured: boolean; |
| #870 | live: boolean; |
| #871 | wallet: string; |
| #872 | profileIndex: number; |
| #873 | allowedSymbols: string[]; |
| #874 | maxSizeUsd: number; |
| #875 | slippageBps: number; |
| #876 | jwtPresent: boolean; |
| #877 | apiStatus: unknown; |
| #878 | warnings: string[]; |
| #879 | }> { |
| #880 | const warnings: string[] = []; |
| #881 | if (!this.config.jwt) warnings.push("No JWT — trading endpoints will fail."); |
| #882 | if (!this.config.wallet) warnings.push("No wallet configured."); |
| #883 | if (this.config.live) warnings.push("LIVE MODE — orders submit on-chain."); |
| #884 | |
| #885 | let apiStatus: unknown = null; |
| #886 | try { |
| #887 | apiStatus = await this.getStatus(); |
| #888 | } catch { |
| #889 | warnings.push("Imperial API /status unreachable."); |
| #890 | } |
| #891 | |
| #892 | return { |
| #893 | configured: Boolean(this.config.jwt && this.config.wallet), |
| #894 | live: this.config.live, |
| #895 | wallet: this.config.wallet, |
| #896 | profileIndex: this.config.profileIndex, |
| #897 | allowedSymbols: this.config.allowedSymbols, |
| #898 | maxSizeUsd: this.config.maxSizeUsd, |
| #899 | slippageBps: this.config.slippageBps, |
| #900 | jwtPresent: Boolean(this.config.jwt), |
| #901 | apiStatus, |
| #902 | warnings, |
| #903 | }; |
| #904 | } |
| #905 | } |
| #906 | |
| #907 | export function createImperialClient(config?: Partial<ImperialConfig>): ImperialClient { |
| #908 | return new ImperialClient(config); |
| #909 | } |
| #910 |