aster_order_test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. import 'dotenv/config'
  2. import { AsterAdapter } from '../src/exchanges/aster/asterAdapter'
  3. import { AsterWsClient } from '../src/exchanges/aster/wsClient'
  4. import { createHmac } from 'crypto'
  5. async function httpPostForm(url: string, form: Record<string, string>) {
  6. const body = new URLSearchParams(form).toString()
  7. const res = await fetch(url, {
  8. method: 'POST',
  9. headers: { 'content-type': 'application/x-www-form-urlencoded' },
  10. body,
  11. } as any)
  12. const text = await res.text()
  13. if (!res.ok) throw new Error(`HTTP ${res.status} ${url} - ${text.slice(0, 300)}`)
  14. try {
  15. return JSON.parse(text)
  16. } catch {
  17. return text
  18. }
  19. }
  20. async function fetchExchangeInfo(base: string): Promise<any> {
  21. const url = `${base.replace(/\/$/, '')}/fapi/v3/exchangeInfo`
  22. const res = await fetch(url, { method: 'GET' } as any)
  23. if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`)
  24. return await res.json()
  25. }
  26. async function fetchPrice(base: string, symbol: string): Promise<number> {
  27. const url = `${base.replace(/\/$/, '')}/fapi/v3/ticker/price?symbol=${encodeURIComponent(symbol)}`
  28. const res = await fetch(url, { method: 'GET' } as any)
  29. if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`)
  30. const data: any = await res.json()
  31. const p = Number(data?.price ?? data?.p ?? data?.lastPrice)
  32. if (!Number.isFinite(p) || p <= 0) throw new Error('invalid_price')
  33. return p
  34. }
  35. async function hmacGet(
  36. base: string,
  37. path: string,
  38. apiKey: string,
  39. apiSecret: string,
  40. extra: Record<string, string | number> = {},
  41. ) {
  42. const params: Record<string, string> = {
  43. recvWindow: String(extra.recvWindow ?? 50000),
  44. timestamp: String(Date.now()),
  45. ...Object.fromEntries(Object.entries(extra).filter(([k]) => k !== 'recvWindow')),
  46. } as any
  47. const qs = new URLSearchParams(params as any).toString()
  48. const sig = createHmac('sha256', apiSecret).update(qs).digest('hex')
  49. const url = `${base.replace(/\/$/, '')}${path}?${qs}&signature=${sig}`
  50. const res = await fetch(url, { method: 'GET', headers: { 'X-MBX-APIKEY': apiKey } as any } as any)
  51. const text = await res.text()
  52. if (!res.ok) throw new Error(`HTTP ${res.status} ${path} - ${text.slice(0, 200)}`)
  53. try {
  54. return JSON.parse(text)
  55. } catch {
  56. return text
  57. }
  58. }
  59. async function fetchDualMode(
  60. base: string,
  61. creds?: { apiKey?: string; apiSecret?: string },
  62. ): Promise<boolean | undefined> {
  63. if (!creds?.apiKey || !creds?.apiSecret) return undefined
  64. try {
  65. const data: any = await hmacGet(base, '/fapi/v3/positionSide/dual', creds.apiKey, creds.apiSecret)
  66. const val = data?.dualSidePosition
  67. if (typeof val === 'boolean') return val
  68. } catch (e) {
  69. console.warn('fetchDualMode failed:', (e as any)?.message || e)
  70. }
  71. return undefined
  72. }
  73. function parseLotFilters(symInfo: any): { minQty: number; maxQty: number; stepSize: number } | null {
  74. if (!symInfo?.filters) return null
  75. const lot = symInfo.filters.find((f: any) => f.filterType === 'LOT_SIZE' || f.filterType === 'MARKET_LOT_SIZE')
  76. if (!lot) return null
  77. const minQty = Number(lot.minQty ?? lot.minQuantity ?? '0')
  78. const maxQty = Number(lot.maxQty ?? lot.maxQuantity ?? '0')
  79. const stepSize = Number(lot.stepSize ?? lot.step ?? '1')
  80. return { minQty, maxQty, stepSize }
  81. }
  82. function floorToStep(val: number, step: number): number {
  83. if (!Number.isFinite(step) || step <= 0) return val
  84. const mul = Math.round(1 / step)
  85. return Math.floor(val * mul) / mul
  86. }
  87. function alignQtyByFilters(rawQty: number, filters: { minQty: number; maxQty: number; stepSize: number }): number {
  88. let q = rawQty
  89. if (filters.maxQty && q > filters.maxQty) q = filters.maxQty
  90. q = floorToStep(q, filters.stepSize || 1)
  91. if (filters.minQty && q < filters.minQty) q = filters.minQty
  92. return q
  93. }
  94. function includesMaxQtyError(message: string): boolean {
  95. return /Quantity\s+greater\s+than\s+max\s+quantity/i.test(message) || message.includes('code":-4005')
  96. }
  97. async function main() {
  98. const base = process.env.ASTER_HTTP_BASE || 'https://fapi.asterdex.com'
  99. const symbol = process.env.ASTER_TEST_SYMBOL || 'BTCUSDT'
  100. let qtyStr = process.env.ASTER_TEST_QTY || ''
  101. const notionalUsd = process.env.ASTER_TEST_NOTIONAL_USD ? Number(process.env.ASTER_TEST_NOTIONAL_USD) : undefined
  102. const user = process.env.ASTER_ORDER_USER || ''
  103. const signer = process.env.ASTER_API_KEY || ''
  104. const pk = process.env.ASTER_API_SECRET || ''
  105. if (!user || !signer || !pk) throw new Error('Missing ASTER_ORDER_USER / ASTER_API_KEY / ASTER_API_SECRET')
  106. // 在初始化处填写(不从环境读取)REST 鉴权(若提供则自动读取双向持仓设置)
  107. const restAuth = {
  108. apiKey: '', // 可填入你的 API Key,留空则跳过自动读取
  109. apiSecret: '', // 可填入你的 API Secret
  110. }
  111. const dualMode = await fetchDualMode(base, restAuth)
  112. if (typeof dualMode === 'boolean') console.log('account dualSidePosition =', dualMode)
  113. const forceDualMode = true // 如明确知道账户为双向持仓,可置为 true 覆盖
  114. // 拉取交易所规格,按 stepSize/maxQty 对齐数量
  115. let lotFilters: { minQty: number; maxQty: number; stepSize: number; minNotional?: number } | undefined
  116. try {
  117. const info = await fetchExchangeInfo(base)
  118. const symInfo = (info?.symbols || []).find((s: any) => String(s.symbol).toUpperCase() === symbol.toUpperCase())
  119. const filters = parseLotFilters(symInfo)
  120. if (filters) {
  121. lotFilters = filters
  122. let rawQty: number
  123. if (notionalUsd) {
  124. const price = await fetchPrice(base, symbol)
  125. rawQty = notionalUsd / price
  126. } else {
  127. rawQty = Number(qtyStr || '0')
  128. if (!rawQty) rawQty = 1 // fallback
  129. }
  130. const aligned = alignQtyByFilters(rawQty, filters)
  131. qtyStr = String(aligned)
  132. console.log('aligned qty by filters', { raw: rawQty, ...filters, aligned, notionalUsd: notionalUsd ?? null })
  133. }
  134. } catch (e: any) {
  135. console.warn('exchangeInfo fetch/align skipped:', e?.message || e)
  136. }
  137. // 已在上方定义 restAuth,这里不再重复声明
  138. const adapter = new AsterAdapter({
  139. rpcUrl: '',
  140. chainId: 0,
  141. routerAddress: '',
  142. httpBase: base,
  143. defaultUser: user,
  144. defaultSigner: signer,
  145. apiKey: restAuth.apiKey,
  146. apiSecret: restAuth.apiSecret,
  147. })
  148. // 启动账户 User Stream,打印事件,便于验证 WS 推送
  149. try {
  150. const ensured = await adapter.ensureListenKey()
  151. const listenKey = ensured.listenKey
  152. const wsBase = process.env.ASTER_WS_BASE || 'wss://fstream.asterdex.com'
  153. const c = new AsterWsClient({ wsUrl: wsBase })
  154. c.setUserStream(listenKey, wsBase)
  155. c.on('open', () => console.log('[WS] user stream connected'))
  156. c.on('ws_error', e => console.log('[WS] error', e))
  157. c.on('balance', p => console.log('[WS] balance', JSON.stringify(p)))
  158. c.on('account_positions', p => console.log('[WS] positions', JSON.stringify(p)))
  159. c.on('account_info', p => console.log('[WS] account_info', JSON.stringify(p)))
  160. c.on('orders', p => console.log('[WS] orders', JSON.stringify(p)))
  161. c.on('raw', m => console.log('[WS] raw', JSON.stringify(m)))
  162. c.connectUserStream()
  163. } catch (e) {
  164. console.log('start user stream failed:', (e as any)?.message || e)
  165. }
  166. // 使用适配器的 openPerp/closePerp,内部会自动选择 positionSide(若提供了 apiKey/apiSecret)
  167. let qtyNum = Number(qtyStr)
  168. const deadlineSec = Math.floor(Date.now() / 1000) + 60
  169. async function openCloseWithFallback() {
  170. console.log('openPerp request', { symbol, qty: qtyNum })
  171. const openRes = await adapter.openPerp({ symbol, side: 'long', quantity: qtyNum, slippage: 0, deadlineSec })
  172. console.log('openPerp result:', openRes)
  173. if (!openRes.success && String(openRes.error || '').includes('2019')) {
  174. const price = await fetchPrice(base, symbol).catch(() => 0)
  175. let tryQty = qtyNum * 0.5
  176. if (lotFilters) {
  177. tryQty = alignQtyByFilters(tryQty, lotFilters)
  178. const minNotional = lotFilters.minNotional ?? 10
  179. if (price > 0 && tryQty * price < minNotional) tryQty = alignQtyByFilters(minNotional / price, lotFilters)
  180. }
  181. if (tryQty > 0 && tryQty !== qtyNum) {
  182. qtyNum = tryQty
  183. console.log('retry open with smaller qty', qtyNum)
  184. return await openCloseWithFallback()
  185. }
  186. }
  187. console.log('closePerp request', { symbol, qty: qtyNum })
  188. let closeQty = qtyNum
  189. let attempts = 0
  190. while (attempts < 5) {
  191. const closeRes = await adapter.closePerp({ symbol, side: 'long', quantity: closeQty, slippage: 0, deadlineSec })
  192. console.log('closePerp result:', closeRes)
  193. if (closeRes.success) break
  194. const msg = String(closeRes.error || '')
  195. if (!msg.includes('2019')) break
  196. closeQty = alignQtyByFilters(closeQty * 0.5, lotFilters || { minQty: 0.001, maxQty: 999, stepSize: 0.001 })
  197. if (closeQty <= 0) break
  198. attempts++
  199. console.log('retry close with smaller qty', closeQty)
  200. }
  201. }
  202. await openCloseWithFallback()
  203. // 可选:验证 LIMIT 下单 -> 查单 -> 撤单(使用 REST get/cancel 路径),默认关闭
  204. if (process.env.ASTER_ENABLE_LIMIT === '1') {
  205. try {
  206. const info = await fetchExchangeInfo(base)
  207. const symInfo = (info?.symbols || []).find((s: any) => String(s.symbol).toUpperCase() === symbol.toUpperCase())
  208. const filters = parseLotFilters(symInfo) as any
  209. const d = await fetchPrice(base, symbol).catch(() => 0)
  210. const farBuy = d > 0 ? String(Math.max(1, Math.floor(d * 0.98))) : '10000'
  211. const farSell = d > 0 ? String(Math.floor(d * 1.02)) : '20000'
  212. const limitQty = filters?.minQty ? String(filters.minQty) : qtyStr || '0.001'
  213. // 1) LIMIT BUY 下单
  214. const buyPosSide = dualMode === true || forceDualMode ? 'LONG' : await adapter.choosePositionSide('BUY')
  215. const buySig = await adapter.generateOrderSignature(
  216. {
  217. symbol,
  218. positionSide: buyPosSide,
  219. type: 'LIMIT',
  220. side: 'BUY',
  221. quantity: limitQty,
  222. price: farBuy,
  223. timeInForce: 'GTC',
  224. },
  225. { user, signer, privateKey: pk },
  226. )
  227. const buyUrl = `${base.replace(/\/$/, '')}/fapi/v3/order`
  228. console.log('POST LIMIT BUY', buyUrl, buySig.formFields)
  229. const buyRes = await httpPostForm(buyUrl, buySig.formFields)
  230. console.log('limit buy resp =', buyRes)
  231. const orderId = buyRes?.orderId ?? buyRes?.order_id
  232. // 2) GET 查单
  233. if (orderId) {
  234. const queryRes = await adapter.getOrderRest({ symbol, side: 'BUY', type: 'LIMIT', orderId })
  235. console.log('getOrder REST =', queryRes)
  236. }
  237. // 3) 撤单
  238. if (orderId) {
  239. const cancelRes = await adapter.cancelOrderRest({ symbol, side: 'BUY', type: 'LIMIT', orderId })
  240. console.log('cancelOrder REST =', cancelRes)
  241. }
  242. // 4) LIMIT SELL 下单并撤单
  243. const sellPosSide = dualMode === true || forceDualMode ? 'SHORT' : await adapter.choosePositionSide('SELL')
  244. const sellSig = await adapter.generateOrderSignature(
  245. {
  246. symbol,
  247. positionSide: sellPosSide,
  248. type: 'LIMIT',
  249. side: 'SELL',
  250. quantity: limitQty,
  251. price: farSell,
  252. timeInForce: 'GTC',
  253. },
  254. { user, signer, privateKey: pk },
  255. )
  256. console.log('POST LIMIT SELL', buyUrl, sellSig.formFields)
  257. const sellRes = await httpPostForm(buyUrl, sellSig.formFields)
  258. console.log('limit sell resp =', sellRes)
  259. const sellOrderId = sellRes?.orderId ?? sellRes?.order_id
  260. if (sellOrderId) {
  261. const cancelSellRes = await adapter.cancelOrderRest({
  262. symbol,
  263. side: 'SELL',
  264. type: 'LIMIT',
  265. orderId: sellOrderId,
  266. })
  267. console.log('cancel sell REST =', cancelSellRes)
  268. }
  269. } catch (e: any) {
  270. console.error('limit/get/cancel flow failed', e?.message || e)
  271. }
  272. }
  273. }
  274. main().catch(e => {
  275. console.error(e)
  276. process.exitCode = 1
  277. })