| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298 |
- 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<string, string>) {
- 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<any> {
- 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<number> {
- 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<string, string | number> = {},
- ) {
- const params: Record<string, string> = {
- 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<boolean | undefined> {
- 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
- })
|