asterAdapter.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. import { AbiCoder, Wallet, keccak256, getBytes } from 'ethers'
  2. import { createHmac } from 'crypto'
  3. export interface AsterConfig {
  4. rpcUrl: string
  5. chainId: number
  6. routerAddress: string // Aster 合约/路由地址(根据文档填写)
  7. readerAddress?: string // 读取器合约地址(可选)
  8. httpBase?: string // REST API Base,例如 https://fapi.asterdex.com
  9. defaultUser?: string // 下单 user 地址(owner)
  10. defaultSigner?: string // 下单 signer 地址(与私钥对应)
  11. apiKey?: string // (可选)REST HMAC API Key,用于读取账户设置
  12. apiSecret?: string // (可选)REST HMAC API Secret
  13. }
  14. export interface AsterQuoteParams {
  15. symbol: string // 交易对(与 Aster 平台定义保持一致)
  16. side: 'long' | 'short'
  17. quantity: number // 张数或标的数量(依据 Aster 定义)
  18. slippage: number // 最大滑点比例
  19. }
  20. export interface AsterQuoteResult {
  21. expectedPrice: number
  22. feeUsd?: number
  23. fundingRate?: number
  24. }
  25. export interface AsterOrderParams extends AsterQuoteParams {
  26. deadlineSec: number
  27. positionSide?: 'BOTH' | 'LONG' | 'SHORT'
  28. }
  29. export interface AsterOrderResult {
  30. success: boolean
  31. txHash?: string
  32. error?: string
  33. }
  34. export class AsterAdapter {
  35. private provider: any // 懒加载链上需求
  36. private signer?: Wallet
  37. private cfg: AsterConfig
  38. private dualSideCache?: boolean
  39. private listenKeyState?: { key: string; updatedAt: number }
  40. constructor(cfg: AsterConfig, signer?: Wallet) {
  41. this.cfg = cfg
  42. // 仅在需要链上时使用 provider
  43. // @ts-ignore
  44. this.provider = undefined
  45. this.signer = signer
  46. }
  47. async getFundingRate(symbol: string): Promise<number | null> {
  48. // 使用 REST: GET /fapi/v3/fundingRate?symbol=BTCUSDT&limit=1 取最近一条
  49. const base = this.cfg.httpBase || process.env.ASTER_HTTP_BASE || ''
  50. if (!base) return null
  51. const url = `${base.replace(/\/$/, '')}/fapi/v3/fundingRate?symbol=${encodeURIComponent(symbol)}&limit=1`
  52. try {
  53. const res = await (globalThis.fetch as any)(url, { method: 'GET' } as any)
  54. if (!('ok' in res) || !(res as any).ok) return null
  55. const data = await (res as any).json()
  56. const item = Array.isArray(data) ? data[0] : data
  57. const rate = Number(item?.fundingRate ?? item?.funding_rate ?? item?.r)
  58. return Number.isFinite(rate) ? rate : null
  59. } catch {
  60. return null
  61. }
  62. }
  63. async quote(params: AsterQuoteParams): Promise<AsterQuoteResult> {
  64. // 简化:用标记价格/最新价作为预估成交价,并返回当前资金费率
  65. const base = this.cfg.httpBase || process.env.ASTER_HTTP_BASE || ''
  66. if (!base) return { expectedPrice: 0 }
  67. const url = `${base.replace(/\/$/, '')}/fapi/v3/ticker/price?symbol=${encodeURIComponent(params.symbol)}`
  68. try {
  69. const res = await (globalThis.fetch as any)(url, { method: 'GET' } as any)
  70. const data = await (res as any).json()
  71. const px = Number(data?.price ?? data?.p ?? data?.lastPrice)
  72. const funding = await this.getFundingRate(params.symbol)
  73. return { expectedPrice: Number.isFinite(px) ? px : 0, fundingRate: funding ?? undefined }
  74. } catch {
  75. return { expectedPrice: 0 }
  76. }
  77. }
  78. async openPerp(params: AsterOrderParams): Promise<AsterOrderResult> {
  79. // 使用 REST 市价下单(BUY,LONG 或 BOTH 由调用方选择)
  80. const side = params.side === 'long' ? 'BUY' : 'SELL'
  81. try {
  82. const posSide = params.positionSide ?? (await this.choosePositionSide(side as 'BUY' | 'SELL'))
  83. const attempt = async (positionSide: 'BOTH' | 'LONG' | 'SHORT') => {
  84. const sig = await this.generateOrderSignature({
  85. symbol: params.symbol,
  86. positionSide,
  87. type: 'MARKET',
  88. side: side as any,
  89. quantity: params.quantity,
  90. timeInForce: undefined,
  91. })
  92. const url = `${this.requireHttpBase()}/fapi/v3/order`
  93. const body = new URLSearchParams(sig.formFields).toString()
  94. const res = await (globalThis.fetch as any)(url, {
  95. method: 'POST',
  96. headers: { 'content-type': 'application/x-www-form-urlencoded' } as any,
  97. body,
  98. } as any)
  99. const text = await res.text()
  100. return { ok: (res as any).ok, text }
  101. }
  102. const r1 = await attempt(posSide)
  103. if (r1.ok) return { success: true, txHash: r1.text }
  104. if (r1.text.includes('position side') || r1.text.includes('positionSide')) {
  105. // 切换模式重试:BOTH <-> LONG/SHORT
  106. const fallback: 'BOTH' | 'LONG' | 'SHORT' = posSide === 'BOTH' ? (side === 'BUY' ? 'LONG' : 'SHORT') : 'BOTH'
  107. const r2 = await attempt(fallback)
  108. if (r2.ok) return { success: true, txHash: r2.text }
  109. return { success: false, error: r2.text.slice(0, 200) }
  110. }
  111. return { success: false, error: r1.text.slice(0, 200) }
  112. } catch (e: any) {
  113. return { success: false, error: e?.message || String(e) }
  114. }
  115. }
  116. async closePerp(params: AsterOrderParams): Promise<AsterOrderResult> {
  117. const side = params.side === 'long' ? 'SELL' : 'BUY'
  118. try {
  119. const posSide = params.positionSide ?? (await this.choosePositionSide(side as 'BUY' | 'SELL'))
  120. const attempt = async (positionSide: 'BOTH' | 'LONG' | 'SHORT') => {
  121. const sig = await this.generateOrderSignature({
  122. symbol: params.symbol,
  123. positionSide,
  124. type: 'MARKET',
  125. side: side as any,
  126. quantity: params.quantity,
  127. timeInForce: undefined,
  128. })
  129. const url = `${this.requireHttpBase()}/fapi/v3/order`
  130. const body = new URLSearchParams(sig.formFields).toString()
  131. const res = await (globalThis.fetch as any)(url, {
  132. method: 'POST',
  133. headers: { 'content-type': 'application/x-www-form-urlencoded' } as any,
  134. body,
  135. } as any)
  136. const text = await res.text()
  137. return { ok: (res as any).ok, text }
  138. }
  139. const r1 = await attempt(posSide)
  140. if (r1.ok) return { success: true, txHash: r1.text }
  141. if (r1.text.includes('position side') || r1.text.includes('positionSide')) {
  142. const fallback: 'BOTH' | 'LONG' | 'SHORT' = posSide === 'BOTH' ? (side === 'BUY' ? 'LONG' : 'SHORT') : 'BOTH'
  143. const r2 = await attempt(fallback)
  144. if (r2.ok) return { success: true, txHash: r2.text }
  145. return { success: false, error: r2.text.slice(0, 200) }
  146. }
  147. return { success: false, error: r1.text.slice(0, 200) }
  148. } catch (e: any) {
  149. return { success: false, error: e?.message || String(e) }
  150. }
  151. }
  152. // === REST: 查单 / 撤单(使用与下单相同的签名流程) ===
  153. /** 生成撤单签名 */
  154. async generateCancelSignature(
  155. req: {
  156. symbol: string
  157. side: 'BUY' | 'SELL' | 'buy' | 'sell'
  158. type: 'LIMIT' | 'MARKET' | 'STOP' | 'STOP_MARKET' | 'TAKE_PROFIT' | 'TAKE_PROFIT_MARKET'
  159. orderId?: number
  160. clientOrderId?: string
  161. recvWindow?: number
  162. timestamp?: number
  163. },
  164. creds?: { user?: string; signer?: string; privateKey?: string; nonceMicros?: bigint },
  165. ): Promise<{ formFields: Record<string, string> }> {
  166. const business: Record<string, any> = {
  167. ...req,
  168. recvWindow: req.recvWindow ?? 50000,
  169. timestamp: req.timestamp ?? Date.now(),
  170. }
  171. const bizStrDict = this.normalizeBusinessParams(business)
  172. const jsonStr = this.stableJSONString(bizStrDict)
  173. const user = creds?.user || this.cfg.defaultUser || process.env.ASTER_ORDER_USER || ''
  174. const signer = creds?.signer || this.cfg.defaultSigner || process.env.ASTER_ORDER_SIGNER || ''
  175. const privateKey = creds?.privateKey || process.env.PRIVATE_KEY || ''
  176. if (!user || !signer || !privateKey) throw new Error('缺少用户、签名者或私钥')
  177. const nonce = creds?.nonceMicros ?? BigInt(Math.trunc(Date.now() * 1000))
  178. const signature = await this.signOrderJSONString(jsonStr, user, signer, nonce, privateKey)
  179. const formFields: Record<string, string> = {
  180. ...bizStrDict,
  181. nonce: String(nonce),
  182. user,
  183. signer,
  184. signature,
  185. }
  186. return { formFields }
  187. }
  188. /** GET /fapi/v3/order 查单 */
  189. async getOrderRest(params: {
  190. symbol: string
  191. side: 'BUY' | 'SELL'
  192. type: string
  193. orderId?: number
  194. clientOrderId?: string
  195. }): Promise<any> {
  196. const base = this.requireHttpBase()
  197. const { formFields } = await this.generateCancelSignature(params)
  198. const qs = new URLSearchParams(formFields).toString()
  199. const url = `${base}/fapi/v3/order?${qs}`
  200. const res = await (globalThis.fetch as any)(url, { method: 'GET' } as any)
  201. const txt = await res.text()
  202. if (!('ok' in res) || !(res as any).ok)
  203. throw new Error(`HTTP ${(res as any).status} GET /fapi/v3/order - ${txt.slice(0, 200)}`)
  204. try {
  205. return JSON.parse(txt)
  206. } catch {
  207. return txt
  208. }
  209. }
  210. /** DELETE /fapi/v3/order 撤单 */
  211. async cancelOrderRest(params: {
  212. symbol: string
  213. side: 'BUY' | 'SELL'
  214. type: string
  215. orderId?: number
  216. clientOrderId?: string
  217. }): Promise<any> {
  218. const base = this.requireHttpBase()
  219. const { formFields } = await this.generateCancelSignature(params)
  220. const qs = new URLSearchParams(formFields).toString()
  221. const url = `${base}/fapi/v3/order?${qs}`
  222. const res = await (globalThis.fetch as any)(url, { method: 'DELETE' } as any)
  223. const txt = await res.text()
  224. if (!('ok' in res) || !(res as any).ok)
  225. throw new Error(`HTTP ${(res as any).status} DELETE /fapi/v3/order - ${txt.slice(0, 200)}`)
  226. try {
  227. return JSON.parse(txt)
  228. } catch {
  229. return txt
  230. }
  231. }
  232. // ===== 账户/杠杆相关 =====
  233. async getLeverageBracket(symbol?: string): Promise<any> {
  234. const base = this.requireHttpBase()
  235. const url = `${base}/fapi/v3/leverageBracket${symbol ? `?symbol=${encodeURIComponent(symbol)}` : ''}`
  236. const res = await (globalThis.fetch as any)(url, { method: 'GET' } as any)
  237. if (!('ok' in res) || !(res as any).ok) throw new Error(`HTTP ${(res as any).status} /fapi/v3/leverageBracket`)
  238. return await (res as any).json()
  239. }
  240. async updateLeverage(symbol: string, leverage: number): Promise<any> {
  241. // 需要 HMAC 鉴权
  242. const body = await this.hmacGet('/fapi/v3/leverage', { symbol, leverage })
  243. return body
  244. }
  245. async updateMarginMode(symbol: string, marginType: 'ISOLATED' | 'CROSSED'): Promise<any> {
  246. // 需要 HMAC 鉴权
  247. const body = await this.hmacGet('/fapi/v3/marginType', { symbol, marginType })
  248. return body
  249. }
  250. // === REST 下单封装(按照文档 /fapi/v3/order 签名流程) ===
  251. /**
  252. * 业务参数 -> 纯字符串字典(递归字符串化 list/dict),移除空值
  253. */
  254. private normalizeBusinessParams(input: Record<string, any>): Record<string, string> {
  255. const cleaned: Record<string, any> = {}
  256. for (const [k, v] of Object.entries(input)) {
  257. if (v === undefined || v === null) continue
  258. cleaned[k] = v
  259. }
  260. const toStringValue = (val: any): string => {
  261. if (Array.isArray(val)) {
  262. const arr = val.map(item =>
  263. typeof item === 'object' && item !== null ? JSON.stringify(this.deepStringify(item)) : String(item),
  264. )
  265. return JSON.stringify(arr)
  266. }
  267. if (typeof val === 'object') {
  268. return JSON.stringify(this.deepStringify(val))
  269. }
  270. return String(val)
  271. }
  272. const out: Record<string, string> = {}
  273. for (const [k, v] of Object.entries(cleaned)) {
  274. out[k] = toStringValue(v)
  275. }
  276. return out
  277. }
  278. private deepStringify(obj: Record<string, any>): Record<string, string> {
  279. const out: Record<string, string> = {}
  280. for (const [k, v] of Object.entries(obj)) {
  281. if (v === undefined || v === null) continue
  282. if (Array.isArray(v)) {
  283. out[k] = JSON.stringify(
  284. v.map(item => (typeof item === 'object' && item !== null ? this.deepStringify(item) : String(item))),
  285. )
  286. } else if (typeof v === 'object') {
  287. out[k] = JSON.stringify(this.deepStringify(v as any))
  288. } else {
  289. out[k] = String(v)
  290. }
  291. }
  292. return out
  293. }
  294. /**
  295. * 生成按 ASCII 排序的 JSON 字符串
  296. */
  297. private stableJSONString(obj: Record<string, string>): string {
  298. const entries = Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))
  299. const sorted = Object.fromEntries(entries)
  300. return JSON.stringify(sorted)
  301. }
  302. /**
  303. * 计算签名(ABI 编码 ['string','address','address','uint256'] 后 keccak,再 personal_sign)
  304. */
  305. private async signOrderJSONString(
  306. jsonStr: string,
  307. user: string,
  308. signerAddr: string,
  309. nonce: bigint,
  310. privateKey: string,
  311. ): Promise<string> {
  312. const coder = AbiCoder.defaultAbiCoder()
  313. const encoded = coder.encode(['string', 'address', 'address', 'uint256'], [jsonStr, user, signerAddr, nonce])
  314. const hash = keccak256(encoded)
  315. const wallet = new Wallet(privateKey)
  316. const sig = await wallet.signMessage(getBytes(hash))
  317. return sig
  318. }
  319. /**
  320. * 仅生成签名(不发起 HTTP 请求)。
  321. * 返回 jsonStr(按 ASCII 排序的业务 JSON 字符串)、nonce(微秒)、signature、user、signer 供外部自行拼装表单提交。
  322. */
  323. async generateOrderSignature(
  324. order: {
  325. symbol: string
  326. positionSide: 'BOTH' | 'LONG' | 'SHORT'
  327. type: 'LIMIT' | 'MARKET' | 'STOP' | 'STOP_MARKET' | 'TAKE_PROFIT' | 'TAKE_PROFIT_MARKET'
  328. side: 'BUY' | 'SELL'
  329. timeInForce?: 'GTC' | 'IOC' | 'FOK' | 'GTX' | 'GTD'
  330. quantity: string | number
  331. price?: string | number
  332. recvWindow?: number
  333. timestamp?: number
  334. },
  335. creds?: { user?: string; signer?: string; privateKey?: string; nonceMicros?: bigint },
  336. ): Promise<{
  337. jsonStr: string
  338. nonce: bigint
  339. signature: string
  340. user: string
  341. signer: string
  342. formFields: Record<string, string> // 直接用于 x-www-form-urlencoded 的字段
  343. }> {
  344. const business: Record<string, any> = {
  345. ...order,
  346. recvWindow: order.recvWindow ?? 50000,
  347. timestamp: order.timestamp ?? Date.now(),
  348. }
  349. const bizStrDict = this.normalizeBusinessParams(business)
  350. const jsonStr = this.stableJSONString(bizStrDict)
  351. const user = creds?.user || this.cfg.defaultUser || process.env.ASTER_ORDER_USER || ''
  352. const signer = creds?.signer || this.cfg.defaultSigner || process.env.ASTER_ORDER_SIGNER || ''
  353. const privateKey = creds?.privateKey || process.env.PRIVATE_KEY || ''
  354. if (!user || !signer || !privateKey) throw new Error('缺少用户、签名者或私钥')
  355. const nonce = creds?.nonceMicros ?? BigInt(Math.trunc(Date.now() * 1000))
  356. const signature = await this.signOrderJSONString(jsonStr, user, signer, nonce, privateKey)
  357. const formFields: Record<string, string> = {
  358. ...bizStrDict,
  359. nonce: String(nonce),
  360. user,
  361. signer,
  362. signature,
  363. }
  364. return { jsonStr, nonce, signature, user, signer, formFields }
  365. }
  366. // ===== REST/HMAC 工具与账户模式 =====
  367. private requireHttpBase(): string {
  368. const base = this.cfg.httpBase || process.env.ASTER_HTTP_BASE || ''
  369. if (!base) throw new Error('AsterAdapter: 需要httpBase')
  370. return base.replace(/\/$/, '')
  371. }
  372. private async hmacRequest(
  373. method: 'GET' | 'POST' | 'PUT' | 'DELETE',
  374. path: string,
  375. extra: Record<string, string | number> = {},
  376. ): Promise<any> {
  377. // 对于 listenKey 等端点,使用 Aster 的 ECDSA 签名算法
  378. const business: Record<string, any> = {
  379. ...extra,
  380. recvWindow: extra.recvWindow ?? 50000,
  381. timestamp: extra.timestamp ?? Date.now(),
  382. }
  383. const bizStrDict = this.normalizeBusinessParams(business)
  384. const jsonStr = this.stableJSONString(bizStrDict)
  385. const user = this.cfg.defaultUser || process.env.ASTER_ORDER_USER || ''
  386. const signer = this.cfg.defaultSigner || process.env.ASTER_API_KEY || ''
  387. const privateKey = process.env.ASTER_API_SECRET || ''
  388. if (!user || !signer || !privateKey) throw new Error('HMAC请求需要用户、签名者、私钥')
  389. const nonce = BigInt(Math.trunc(Date.now() * 1000))
  390. const signature = await this.signOrderJSONString(jsonStr, user, signer, nonce, privateKey)
  391. const formFields: Record<string, string> = {
  392. ...bizStrDict,
  393. nonce: String(nonce),
  394. user,
  395. signer,
  396. signature,
  397. }
  398. // Aster API 不需要 X-MBX-APIKEY header,所有认证通过签名参数
  399. const headers: Record<string, string> = {}
  400. let url = `${this.requireHttpBase()}${path}`
  401. const options: any = { method, headers }
  402. if (method === 'GET' || method === 'DELETE') {
  403. const qs = new URLSearchParams(formFields).toString()
  404. url += `?${qs}`
  405. } else {
  406. headers['content-type'] = 'application/x-www-form-urlencoded'
  407. options.body = new URLSearchParams(formFields).toString()
  408. }
  409. const res = await (globalThis.fetch as any)(url, options)
  410. const text = await res.text()
  411. if (!('ok' in res) || !(res as any).ok)
  412. throw new Error(`HTTP ${(res as any).status} ${path} - ${text.slice(0, 200)}`)
  413. try {
  414. return JSON.parse(text)
  415. } catch {
  416. return text
  417. }
  418. }
  419. private async hmacGet(path: string, extra: Record<string, string | number> = {}): Promise<any> {
  420. return await this.hmacRequest('GET', path, extra)
  421. }
  422. // === 公共账户查询(HMAC) ===
  423. async getBalances(): Promise<any[]> {
  424. return await this.hmacGet('/fapi/v3/balance')
  425. }
  426. async getPositionRisk(symbol?: string): Promise<any[]> {
  427. const params: Record<string, string | number> = {}
  428. if (symbol) params.symbol = symbol
  429. return await this.hmacGet('/fapi/v3/positionRisk', params)
  430. }
  431. async getOpenOrders(symbol?: string): Promise<any[]> {
  432. const params: Record<string, string | number> = {}
  433. if (symbol) params.symbol = symbol
  434. return await this.hmacGet('/fapi/v3/openOrders', params)
  435. }
  436. // ===== User Stream (listenKey) =====
  437. /** 创建 listenKey 用于账户类 WS。返回 listenKey 字符串。*/
  438. async createListenKey(): Promise<string> {
  439. // USER_STREAM 类型需要签名,尽管文档说参数为 None
  440. const body = await this.hmacRequest('POST', '/fapi/v3/listenKey', {})
  441. const lk = String(body?.listenKey || '')
  442. if (!lk) throw new Error('empty_listenKey')
  443. this.listenKeyState = { key: lk, updatedAt: Date.now() }
  444. return lk
  445. }
  446. /** 延长 listenKey 有效期(官方为 60 分钟有效期,这里建议每 30 分钟续期)。*/
  447. async keepAliveListenKey(): Promise<void> {
  448. if (!this.listenKeyState) throw new Error('listenKey_not_initialized')
  449. // USER_STREAM 类型需要签名
  450. await this.hmacRequest('PUT', '/fapi/v3/listenKey', {})
  451. this.listenKeyState.updatedAt = Date.now()
  452. }
  453. /** 关闭 listenKey(可选)。*/
  454. async closeListenKey(): Promise<void> {
  455. // USER_STREAM 类型需要签名
  456. await this.hmacRequest('DELETE', '/fapi/v3/listenKey', {})
  457. this.listenKeyState = undefined
  458. }
  459. async ensureListenKey(opts?: {
  460. forceNew?: boolean
  461. refreshThresholdMs?: number
  462. }): Promise<{ listenKey: string; refreshed: boolean; source: 'cached' | 'refreshed' | 'created' }> {
  463. const now = Date.now()
  464. const threshold = opts?.refreshThresholdMs ?? 45 * 60 * 1000
  465. if (opts?.forceNew) {
  466. const key = await this.createListenKey()
  467. return { listenKey: key, refreshed: true, source: 'created' }
  468. }
  469. if (this.listenKeyState) {
  470. const age = now - this.listenKeyState.updatedAt
  471. if (age < threshold) {
  472. return { listenKey: this.listenKeyState.key, refreshed: false, source: 'cached' }
  473. }
  474. try {
  475. await this.keepAliveListenKey()
  476. return { listenKey: this.listenKeyState.key, refreshed: true, source: 'refreshed' }
  477. } catch {
  478. // fallthrough to recreate
  479. }
  480. } else {
  481. const preset = process.env.ASTER_LISTEN_KEY || (this.cfg as any).listenKey
  482. if (preset) {
  483. this.listenKeyState = { key: preset, updatedAt: 0 }
  484. try {
  485. await this.keepAliveListenKey()
  486. return { listenKey: preset, refreshed: true, source: 'refreshed' }
  487. } catch {
  488. this.listenKeyState = undefined
  489. }
  490. }
  491. }
  492. const newKey = await this.createListenKey()
  493. return { listenKey: newKey, refreshed: true, source: 'created' }
  494. }
  495. getListenKeyState() {
  496. return this.listenKeyState ? { ...this.listenKeyState } : undefined
  497. }
  498. /**
  499. * 读取账户是否为双向持仓模式(true=双向,false=单向)。需要 cfg.apiKey/apiSecret。
  500. * 结果带有简单缓存,可通过 force=true 强制刷新。
  501. */
  502. async getDualSidePosition(force = false): Promise<boolean | undefined> {
  503. if (!force && typeof this.dualSideCache === 'boolean') return this.dualSideCache
  504. if (!this.cfg.apiKey || !this.cfg.apiSecret) return undefined
  505. try {
  506. const data = await this.hmacGet('/fapi/v3/positionSide/dual')
  507. const val = data && typeof data.dualSidePosition === 'boolean' ? data.dualSidePosition : undefined
  508. this.dualSideCache = val
  509. return val
  510. } catch {
  511. return undefined
  512. }
  513. }
  514. /**
  515. * 根据账户设置返回下单用 positionSide。
  516. * - 双向:BUY -> LONG,SELL -> SHORT
  517. * - 单向或未知:BOTH
  518. */
  519. async choosePositionSide(side: 'BUY' | 'SELL'): Promise<'BOTH' | 'LONG' | 'SHORT'> {
  520. const dual = await this.getDualSidePosition()
  521. if (dual === true) return side === 'BUY' ? 'LONG' : 'SHORT'
  522. return 'BOTH'
  523. }
  524. }