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 sources15d ago| #1 | /** |
| #2 | * Vulcan CLI bridge — wraps the real `vulcan` binary (Ellipsis-Labs/vulcan-cli). |
| #3 | * |
| #4 | * All commands emit `{ ok, data, meta }` or `{ ok, error }` JSON on stdout. |
| #5 | * We pass `-o json` and parse the envelope; callers never see raw output. |
| #6 | */ |
| #7 | import { spawn, spawnSync } from 'node:child_process'; |
| #8 | |
| #9 | // ─── Types ──────────────────────────────────────────────────────────────────── |
| #10 | |
| #11 | export interface VulcanMarket { |
| #12 | symbol: string; |
| #13 | markPrice: number; |
| #14 | fundingRate: number; |
| #15 | openInterest: number; |
| #16 | volume24h: number; |
| #17 | change24h: number; |
| #18 | maxLeverage: number; |
| #19 | status: string; |
| #20 | } |
| #21 | |
| #22 | export interface VulcanResult { |
| #23 | ok: boolean; |
| #24 | stdout: string; |
| #25 | stderr: string; |
| #26 | status: number | null; |
| #27 | data?: unknown; |
| #28 | json?: unknown; |
| #29 | } |
| #30 | |
| #31 | export interface PaperStatus { |
| #32 | balance: number; |
| #33 | equity: number; |
| #34 | unrealizedPnl: number; |
| #35 | realizedPnl: number; |
| #36 | openPositions: number; |
| #37 | openOrders: number; |
| #38 | fills: number; |
| #39 | feesPaid: number; |
| #40 | exposureRatio: number; |
| #41 | } |
| #42 | |
| #43 | export interface PaperPosition { |
| #44 | symbol: string; |
| #45 | side: string; |
| #46 | sizeTokens: number; |
| #47 | entryPrice: number; |
| #48 | markPrice: number; |
| #49 | unrealizedPnl: number; |
| #50 | marginUsdc: number; |
| #51 | leverage: number; |
| #52 | } |
| #53 | |
| #54 | // Typed shape returned by `vulcan market list` |
| #55 | interface RawMarketEntry { |
| #56 | symbol?: string; |
| #57 | status?: string; |
| #58 | max_leverage?: number; |
| #59 | isolated_only?: boolean; |
| #60 | } |
| #61 | |
| #62 | // Typed shape returned by `vulcan market ticker` |
| #63 | interface RawTicker { |
| #64 | symbol?: string; |
| #65 | mark_price?: number; |
| #66 | funding_rate?: number; |
| #67 | open_interest?: number; |
| #68 | volume_24h_usd?: number; |
| #69 | change_24h_pct?: number; |
| #70 | } |
| #71 | |
| #72 | // Typed shape returned by `vulcan paper status` |
| #73 | interface RawPaperStatus { |
| #74 | balance?: number; |
| #75 | equity?: number; |
| #76 | unrealized_pnl?: number; |
| #77 | realized_pnl?: number; |
| #78 | open_positions?: number; |
| #79 | open_orders?: number; |
| #80 | fills?: number; |
| #81 | fees_paid?: number; |
| #82 | exposure_ratio?: number; |
| #83 | } |
| #84 | |
| #85 | // Typed shape returned by `vulcan paper positions` |
| #86 | interface RawPaperPosition { |
| #87 | symbol?: string; |
| #88 | side?: string; |
| #89 | size_tokens?: number; |
| #90 | size?: number; |
| #91 | entry_price?: number; |
| #92 | mark_price?: number; |
| #93 | unrealized_pnl?: number; |
| #94 | margin_usdc?: number; |
| #95 | margin?: number; |
| #96 | leverage?: number; |
| #97 | } |
| #98 | |
| #99 | // ─── Binary resolution ──────────────────────────────────────────────────────── |
| #100 | |
| #101 | function resolveVulcanBinary(): string | null { |
| #102 | const probe = spawnSync('which', ['vulcan'], { encoding: 'utf8' }); |
| #103 | if (probe.status === 0 && probe.stdout.trim()) return 'vulcan'; |
| #104 | const home = process.env.HOME ?? ''; |
| #105 | for (const p of [`${home}/.local/bin/vulcan`, `${home}/.cargo/bin/vulcan`, '/usr/local/bin/vulcan']) { |
| #106 | if (spawnSync('test', ['-x', p]).status === 0) return p; |
| #107 | } |
| #108 | return null; |
| #109 | } |
| #110 | |
| #111 | const VULCAN_BIN = resolveVulcanBinary(); |
| #112 | |
| #113 | export function vulcanInstallHint(): string { |
| #114 | return 'curl -sSfL https://vulcan.ellipsis.markets/install.sh | sh (or: cargo install vulcan-cli)'; |
| #115 | } |
| #116 | |
| #117 | export function normalizeSymbol(symbol?: string): string { |
| #118 | return (symbol ?? 'SOL').replace(/-PERP$/i, '').toUpperCase(); |
| #119 | } |
| #120 | |
| #121 | // ─── Core runner ────────────────────────────────────────────────────────────── |
| #122 | |
| #123 | export function runVulcanJson(args: string[], timeoutMs = 15_000): Promise<VulcanResult> { |
| #124 | if (!VULCAN_BIN) { |
| #125 | return Promise.resolve({ |
| #126 | ok: false, stdout: '', stderr: `vulcan binary not found — ${vulcanInstallHint()}`, status: 127, |
| #127 | }); |
| #128 | } |
| #129 | |
| #130 | return new Promise(resolve => { |
| #131 | const child = spawn(VULCAN_BIN, [...args, '-o', 'json'], { |
| #132 | stdio: ['ignore', 'pipe', 'pipe'], |
| #133 | env: { ...process.env }, |
| #134 | }); |
| #135 | |
| #136 | let stdout = ''; |
| #137 | let stderr = ''; |
| #138 | const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs); |
| #139 | |
| #140 | child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); |
| #141 | child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); |
| #142 | |
| #143 | child.on('close', exitCode => { |
| #144 | clearTimeout(timer); |
| #145 | let parsed: { ok?: boolean; data?: unknown } | undefined; |
| #146 | try { parsed = stdout.trim() ? JSON.parse(stdout) as typeof parsed : undefined; } catch { /* ignore */ } |
| #147 | const ok = exitCode === 0 && parsed?.ok !== false; |
| #148 | resolve({ ok, stdout, stderr, status: exitCode, data: parsed?.data, json: parsed }); |
| #149 | }); |
| #150 | }); |
| #151 | } |
| #152 | |
| #153 | // ─── Market data ────────────────────────────────────────────────────────────── |
| #154 | |
| #155 | const PRIORITY_SYMBOLS = ['SOL', 'BTC', 'ETH', 'SUI', 'DOGE', 'XRP', 'BNB', 'HYPE']; |
| #156 | |
| #157 | export async function loadVulcanMarkets(): Promise<{ |
| #158 | markets: VulcanMarket[]; |
| #159 | source: 'live' | 'fallback'; |
| #160 | status: string; |
| #161 | }> { |
| #162 | const listResult = await runVulcanJson(['market', 'list']); |
| #163 | if (!listResult.ok || !listResult.data) { |
| #164 | return { markets: fallbackMarkets(), source: 'fallback', status: listResult.stderr || 'vulcan unavailable' }; |
| #165 | } |
| #166 | |
| #167 | const raw = listResult.data as { markets?: RawMarketEntry[] }; |
| #168 | const marketList = Array.isArray(raw.markets) ? raw.markets : []; |
| #169 | if (marketList.length === 0) { |
| #170 | return { markets: fallbackMarkets(), source: 'fallback', status: 'No markets returned' }; |
| #171 | } |
| #172 | |
| #173 | const sorted = [...marketList].sort((a, b) => { |
| #174 | const ai = PRIORITY_SYMBOLS.indexOf(a.symbol ?? ''); |
| #175 | const bi = PRIORITY_SYMBOLS.indexOf(b.symbol ?? ''); |
| #176 | if (ai !== -1 && bi !== -1) return ai - bi; |
| #177 | if (ai !== -1) return -1; |
| #178 | if (bi !== -1) return 1; |
| #179 | return (a.symbol ?? '').localeCompare(b.symbol ?? ''); |
| #180 | }); |
| #181 | |
| #182 | const toFetch = sorted.slice(0, 12); |
| #183 | const tickerResults = await Promise.all( |
| #184 | toFetch.map(m => runVulcanJson(['market', 'ticker', m.symbol ?? 'SOL'], 8_000)), |
| #185 | ); |
| #186 | |
| #187 | const markets: VulcanMarket[] = toFetch.map((m, i) => { |
| #188 | const ticker = tickerResults[i]?.data as RawTicker | undefined; |
| #189 | return { |
| #190 | symbol: normalizeSymbol(m.symbol), |
| #191 | status: m.status ?? 'Active', |
| #192 | maxLeverage: m.max_leverage ?? 0, |
| #193 | markPrice: ticker?.mark_price ?? 0, |
| #194 | fundingRate: ticker?.funding_rate ?? 0, |
| #195 | openInterest: ticker?.open_interest ?? 0, |
| #196 | volume24h: ticker?.volume_24h_usd ?? 0, |
| #197 | change24h: ticker?.change_24h_pct ?? 0, |
| #198 | }; |
| #199 | }); |
| #200 | |
| #201 | const live = markets.filter(m => m.markPrice > 0).length; |
| #202 | return { |
| #203 | markets, |
| #204 | source: live > 0 ? 'live' : 'fallback', |
| #205 | status: `${markets.length} Phoenix markets · ${live} with live price`, |
| #206 | }; |
| #207 | } |
| #208 | |
| #209 | // ─── Paper trading ──────────────────────────────────────────────────────────── |
| #210 | |
| #211 | export async function getPaperStatus(): Promise<PaperStatus | null> { |
| #212 | const result = await runVulcanJson(['paper', 'status']); |
| #213 | if (!result.ok || !result.data) return null; |
| #214 | const d = result.data as RawPaperStatus; |
| #215 | return { |
| #216 | balance: d.balance ?? 0, |
| #217 | equity: d.equity ?? 0, |
| #218 | unrealizedPnl: d.unrealized_pnl ?? 0, |
| #219 | realizedPnl: d.realized_pnl ?? 0, |
| #220 | openPositions: d.open_positions ?? 0, |
| #221 | openOrders: d.open_orders ?? 0, |
| #222 | fills: d.fills ?? 0, |
| #223 | feesPaid: d.fees_paid ?? 0, |
| #224 | exposureRatio: d.exposure_ratio ?? 0, |
| #225 | }; |
| #226 | } |
| #227 | |
| #228 | export async function getPaperPositions(): Promise<PaperPosition[]> { |
| #229 | const result = await runVulcanJson(['paper', 'positions']); |
| #230 | if (!result.ok || !result.data) return []; |
| #231 | const rows: RawPaperPosition[] = Array.isArray(result.data) |
| #232 | ? result.data as RawPaperPosition[] |
| #233 | : Array.isArray((result.data as { positions?: RawPaperPosition[] }).positions) |
| #234 | ? (result.data as { positions: RawPaperPosition[] }).positions |
| #235 | : []; |
| #236 | return rows.map(r => ({ |
| #237 | symbol: normalizeSymbol(r.symbol), |
| #238 | side: r.side ?? 'long', |
| #239 | sizeTokens: r.size_tokens ?? r.size ?? 0, |
| #240 | entryPrice: r.entry_price ?? 0, |
| #241 | markPrice: r.mark_price ?? 0, |
| #242 | unrealizedPnl: r.unrealized_pnl ?? 0, |
| #243 | marginUsdc: r.margin_usdc ?? r.margin ?? 0, |
| #244 | leverage: r.leverage ?? 0, |
| #245 | })); |
| #246 | } |
| #247 | |
| #248 | // ─── Trader snapshot ────────────────────────────────────────────────────────── |
| #249 | |
| #250 | export async function getTraderSnapshot(symbol = 'SOL'): Promise<{ |
| #251 | markets: VulcanMarket[]; |
| #252 | ticker?: RawTicker; |
| #253 | paperStatus?: PaperStatus | null; |
| #254 | paperPositions?: PaperPosition[]; |
| #255 | source: 'live' | 'fallback'; |
| #256 | }> { |
| #257 | const sym = normalizeSymbol(symbol); |
| #258 | const [marketData, tickerResult, paperStatus, paperPositions] = await Promise.all([ |
| #259 | loadVulcanMarkets(), |
| #260 | runVulcanJson(['market', 'ticker', sym]), |
| #261 | getPaperStatus(), |
| #262 | getPaperPositions(), |
| #263 | ]); |
| #264 | return { |
| #265 | markets: marketData.markets, |
| #266 | ticker: tickerResult.data as RawTicker | undefined, |
| #267 | paperStatus, |
| #268 | paperPositions, |
| #269 | source: tickerResult.ok ? 'live' : marketData.source, |
| #270 | }; |
| #271 | } |
| #272 | |
| #273 | // ─── Helpers ────────────────────────────────────────────────────────────────── |
| #274 | |
| #275 | function fallbackMarkets(): VulcanMarket[] { |
| #276 | return [ |
| #277 | { symbol: 'SOL', markPrice: 0, fundingRate: 0, openInterest: 0, volume24h: 0, change24h: 0, maxLeverage: 15, status: 'unknown' }, |
| #278 | { symbol: 'BTC', markPrice: 0, fundingRate: 0, openInterest: 0, volume24h: 0, change24h: 0, maxLeverage: 15, status: 'unknown' }, |
| #279 | { symbol: 'ETH', markPrice: 0, fundingRate: 0, openInterest: 0, volume24h: 0, change24h: 0, maxLeverage: 10, status: 'unknown' }, |
| #280 | ]; |
| #281 | } |
| #282 |