import 'dotenv/config' import { AsterAdapter } from '../src/exchanges/aster/asterAdapter' import { AsterWsClient } from '../src/exchanges/aster/wsClient' import { createHmac } from 'crypto' async function httpPostForm(url: string, form: Record) { const body = new URLSearchParams(form).toString() const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body, } as any) const text = await res.text() if (!res.ok) throw new Error(`HTTP ${res.status} ${url} - ${text.slice(0, 300)}`) try { return JSON.parse(text) } catch { return text } } async function fetchExchangeInfo(base: string): Promise { const url = `${base.replace(/\/$/, '')}/fapi/v3/exchangeInfo` const res = await fetch(url, { method: 'GET' } as any) if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`) return await res.json() } async function fetchPrice(base: string, symbol: string): Promise { const url = `${base.replace(/\/$/, '')}/fapi/v3/ticker/price?symbol=${encodeURIComponent(symbol)}` const res = await fetch(url, { method: 'GET' } as any) if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`) const data: any = await res.json() const p = Number(data?.price ?? data?.p ?? data?.lastPrice) if (!Number.isFinite(p) || p <= 0) throw new Error('invalid_price') return p } async function hmacGet( base: string, path: string, apiKey: string, apiSecret: string, extra: Record = {}, ) { const params: Record = { recvWindow: String(extra.recvWindow ?? 50000), timestamp: String(Date.now()), ...Object.fromEntries(Object.entries(extra).filter(([k]) => k !== 'recvWindow')), } as any const qs = new URLSearchParams(params as any).toString() const sig = createHmac('sha256', apiSecret).update(qs).digest('hex') const url = `${base.replace(/\/$/, '')}${path}?${qs}&signature=${sig}` const res = await fetch(url, { method: 'GET', headers: { 'X-MBX-APIKEY': apiKey } as any } as any) const text = await res.text() if (!res.ok) throw new Error(`HTTP ${res.status} ${path} - ${text.slice(0, 200)}`) try { return JSON.parse(text) } catch { return text } } async function fetchDualMode( base: string, creds?: { apiKey?: string; apiSecret?: string }, ): Promise { if (!creds?.apiKey || !creds?.apiSecret) return undefined try { const data: any = await hmacGet(base, '/fapi/v3/positionSide/dual', creds.apiKey, creds.apiSecret) const val = data?.dualSidePosition if (typeof val === 'boolean') return val } catch (e) { console.warn('fetchDualMode failed:', (e as any)?.message || e) } return undefined } function parseLotFilters(symInfo: any): { minQty: number; maxQty: number; stepSize: number } | null { if (!symInfo?.filters) return null const lot = symInfo.filters.find((f: any) => f.filterType === 'LOT_SIZE' || f.filterType === 'MARKET_LOT_SIZE') if (!lot) return null const minQty = Number(lot.minQty ?? lot.minQuantity ?? '0') const maxQty = Number(lot.maxQty ?? lot.maxQuantity ?? '0') const stepSize = Number(lot.stepSize ?? lot.step ?? '1') return { minQty, maxQty, stepSize } } function floorToStep(val: number, step: number): number { if (!Number.isFinite(step) || step <= 0) return val const mul = Math.round(1 / step) return Math.floor(val * mul) / mul } function alignQtyByFilters(rawQty: number, filters: { minQty: number; maxQty: number; stepSize: number }): number { let q = rawQty if (filters.maxQty && q > filters.maxQty) q = filters.maxQty q = floorToStep(q, filters.stepSize || 1) if (filters.minQty && q < filters.minQty) q = filters.minQty return q } function includesMaxQtyError(message: string): boolean { return /Quantity\s+greater\s+than\s+max\s+quantity/i.test(message) || message.includes('code":-4005') } async function main() { const base = process.env.ASTER_HTTP_BASE || 'https://fapi.asterdex.com' const symbol = process.env.ASTER_TEST_SYMBOL || 'BTCUSDT' let qtyStr = process.env.ASTER_TEST_QTY || '' const notionalUsd = process.env.ASTER_TEST_NOTIONAL_USD ? Number(process.env.ASTER_TEST_NOTIONAL_USD) : undefined const user = process.env.ASTER_ORDER_USER || '' const signer = process.env.ASTER_API_KEY || '' const pk = process.env.ASTER_API_SECRET || '' if (!user || !signer || !pk) throw new Error('Missing ASTER_ORDER_USER / ASTER_API_KEY / ASTER_API_SECRET') // 在初始化处填写(不从环境读取)REST 鉴权(若提供则自动读取双向持仓设置) const restAuth = { apiKey: '', // 可填入你的 API Key,留空则跳过自动读取 apiSecret: '', // 可填入你的 API Secret } const dualMode = await fetchDualMode(base, restAuth) if (typeof dualMode === 'boolean') console.log('account dualSidePosition =', dualMode) const forceDualMode = true // 如明确知道账户为双向持仓,可置为 true 覆盖 // 拉取交易所规格,按 stepSize/maxQty 对齐数量 let lotFilters: { minQty: number; maxQty: number; stepSize: number; minNotional?: number } | undefined try { const info = await fetchExchangeInfo(base) const symInfo = (info?.symbols || []).find((s: any) => String(s.symbol).toUpperCase() === symbol.toUpperCase()) const filters = parseLotFilters(symInfo) if (filters) { lotFilters = filters let rawQty: number if (notionalUsd) { const price = await fetchPrice(base, symbol) rawQty = notionalUsd / price } else { rawQty = Number(qtyStr || '0') if (!rawQty) rawQty = 1 // fallback } const aligned = alignQtyByFilters(rawQty, filters) qtyStr = String(aligned) console.log('aligned qty by filters', { raw: rawQty, ...filters, aligned, notionalUsd: notionalUsd ?? null }) } } catch (e: any) { console.warn('exchangeInfo fetch/align skipped:', e?.message || e) } // 已在上方定义 restAuth,这里不再重复声明 const adapter = new AsterAdapter({ rpcUrl: '', chainId: 0, routerAddress: '', httpBase: base, defaultUser: user, defaultSigner: signer, apiKey: restAuth.apiKey, apiSecret: restAuth.apiSecret, }) // 启动账户 User Stream,打印事件,便于验证 WS 推送 try { const ensured = await adapter.ensureListenKey() const listenKey = ensured.listenKey const wsBase = process.env.ASTER_WS_BASE || 'wss://fstream.asterdex.com' const c = new AsterWsClient({ wsUrl: wsBase }) c.setUserStream(listenKey, wsBase) c.on('open', () => console.log('[WS] user stream connected')) c.on('ws_error', e => console.log('[WS] error', e)) c.on('balance', p => console.log('[WS] balance', JSON.stringify(p))) c.on('account_positions', p => console.log('[WS] positions', JSON.stringify(p))) c.on('account_info', p => console.log('[WS] account_info', JSON.stringify(p))) c.on('orders', p => console.log('[WS] orders', JSON.stringify(p))) c.on('raw', m => console.log('[WS] raw', JSON.stringify(m))) c.connectUserStream() } catch (e) { console.log('start user stream failed:', (e as any)?.message || e) } // 使用适配器的 openPerp/closePerp,内部会自动选择 positionSide(若提供了 apiKey/apiSecret) let qtyNum = Number(qtyStr) const deadlineSec = Math.floor(Date.now() / 1000) + 60 async function openCloseWithFallback() { console.log('openPerp request', { symbol, qty: qtyNum }) const openRes = await adapter.openPerp({ symbol, side: 'long', quantity: qtyNum, slippage: 0, deadlineSec }) console.log('openPerp result:', openRes) if (!openRes.success && String(openRes.error || '').includes('2019')) { const price = await fetchPrice(base, symbol).catch(() => 0) let tryQty = qtyNum * 0.5 if (lotFilters) { tryQty = alignQtyByFilters(tryQty, lotFilters) const minNotional = lotFilters.minNotional ?? 10 if (price > 0 && tryQty * price < minNotional) tryQty = alignQtyByFilters(minNotional / price, lotFilters) } if (tryQty > 0 && tryQty !== qtyNum) { qtyNum = tryQty console.log('retry open with smaller qty', qtyNum) return await openCloseWithFallback() } } console.log('closePerp request', { symbol, qty: qtyNum }) let closeQty = qtyNum let attempts = 0 while (attempts < 5) { const closeRes = await adapter.closePerp({ symbol, side: 'long', quantity: closeQty, slippage: 0, deadlineSec }) console.log('closePerp result:', closeRes) if (closeRes.success) break const msg = String(closeRes.error || '') if (!msg.includes('2019')) break closeQty = alignQtyByFilters(closeQty * 0.5, lotFilters || { minQty: 0.001, maxQty: 999, stepSize: 0.001 }) if (closeQty <= 0) break attempts++ console.log('retry close with smaller qty', closeQty) } } await openCloseWithFallback() // 可选:验证 LIMIT 下单 -> 查单 -> 撤单(使用 REST get/cancel 路径),默认关闭 if (process.env.ASTER_ENABLE_LIMIT === '1') { try { const info = await fetchExchangeInfo(base) const symInfo = (info?.symbols || []).find((s: any) => String(s.symbol).toUpperCase() === symbol.toUpperCase()) const filters = parseLotFilters(symInfo) as any const d = await fetchPrice(base, symbol).catch(() => 0) const farBuy = d > 0 ? String(Math.max(1, Math.floor(d * 0.98))) : '10000' const farSell = d > 0 ? String(Math.floor(d * 1.02)) : '20000' const limitQty = filters?.minQty ? String(filters.minQty) : qtyStr || '0.001' // 1) LIMIT BUY 下单 const buyPosSide = dualMode === true || forceDualMode ? 'LONG' : await adapter.choosePositionSide('BUY') const buySig = await adapter.generateOrderSignature( { symbol, positionSide: buyPosSide, type: 'LIMIT', side: 'BUY', quantity: limitQty, price: farBuy, timeInForce: 'GTC', }, { user, signer, privateKey: pk }, ) const buyUrl = `${base.replace(/\/$/, '')}/fapi/v3/order` console.log('POST LIMIT BUY', buyUrl, buySig.formFields) const buyRes = await httpPostForm(buyUrl, buySig.formFields) console.log('limit buy resp =', buyRes) const orderId = buyRes?.orderId ?? buyRes?.order_id // 2) GET 查单 if (orderId) { const queryRes = await adapter.getOrderRest({ symbol, side: 'BUY', type: 'LIMIT', orderId }) console.log('getOrder REST =', queryRes) } // 3) 撤单 if (orderId) { const cancelRes = await adapter.cancelOrderRest({ symbol, side: 'BUY', type: 'LIMIT', orderId }) console.log('cancelOrder REST =', cancelRes) } // 4) LIMIT SELL 下单并撤单 const sellPosSide = dualMode === true || forceDualMode ? 'SHORT' : await adapter.choosePositionSide('SELL') const sellSig = await adapter.generateOrderSignature( { symbol, positionSide: sellPosSide, type: 'LIMIT', side: 'SELL', quantity: limitQty, price: farSell, timeInForce: 'GTC', }, { user, signer, privateKey: pk }, ) console.log('POST LIMIT SELL', buyUrl, sellSig.formFields) const sellRes = await httpPostForm(buyUrl, sellSig.formFields) console.log('limit sell resp =', sellRes) const sellOrderId = sellRes?.orderId ?? sellRes?.order_id if (sellOrderId) { const cancelSellRes = await adapter.cancelOrderRest({ symbol, side: 'SELL', type: 'LIMIT', orderId: sellOrderId, }) console.log('cancel sell REST =', cancelSellRes) } } catch (e: any) { console.error('limit/get/cancel flow failed', e?.message || e) } } } main().catch(e => { console.error(e) process.exitCode = 1 })