diff --git a/README.md b/README.md index 6dd246f..cec91cc 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ stream.latestTradeDetail$.subscribe((v) => {}) - [x] Get Perpetual Swap Account Asset Information - [x] Perpetual Swap Positions - [ ] Get Account Profit and Loss Fund Flow - - [ ] Export fund flow + - [x] Export fund flow - [ ] User fee rate * Trade Interface - [ ] Trade order test diff --git a/src/bingx-client/services/account.service.spec.ts b/src/bingx-client/services/account.service.spec.ts new file mode 100644 index 0000000..e5c597a --- /dev/null +++ b/src/bingx-client/services/account.service.spec.ts @@ -0,0 +1,75 @@ +import { AccountInterface } from 'bingx-api/bingx/account/account.interface'; +import { EndpointInterface } from 'bingx-api/bingx/endpoints/endpoint.interface'; +import { + BingxExportFundFlowEndpoint, + BingxExportFundFlowResponse, +} from 'bingx-api/bingx/endpoints/bingx-export-fund-flow-endpoint'; +import { RequestExecutorInterface } from 'bingx-api/bingx/request-executor/request-executor.interface'; +import { AccountService } from 'bingx-api/bingx-client/services/account.service'; + +class TestRequestExecutor implements RequestExecutorInterface { + public endpoint?: EndpointInterface; + + execute(endpoint: EndpointInterface): Promise { + this.endpoint = endpoint; + return Promise.resolve({} as T); + } +} + +const account: AccountInterface = { + getApiKey: () => 'api-key', + sign: () => ({ + toString: () => 'signature', + secretKey: () => 'secret-key', + }), +}; + +describe('AccountService', () => { + describe('exportFundFlow', () => { + it('dispatches the export fund flow endpoint', async () => { + const executor = new TestRequestExecutor(); + const service = new AccountService(executor); + + await service.exportFundFlow( + { + symbol: 'BTC-USDT', + incomeType: 'REALIZED_PNL', + startTime: new Date('2026-05-01T00:00:00.000Z'), + endTime: 1777680000000, + limit: 200, + recvWindow: 5000, + }, + account, + ); + + const endpoint = executor.endpoint as BingxExportFundFlowEndpoint; + const parameters = endpoint.parameters().asRecord(); + + expect(endpoint).toBeInstanceOf(BingxExportFundFlowEndpoint); + expect(endpoint.method()).toBe('get'); + expect(endpoint.path()).toBe('/openApi/swap/v2/user/income/export'); + expect(endpoint.responseType()).toBe('arraybuffer'); + expect(parameters).toMatchObject({ + symbol: 'BTC-USDT', + incomeType: 'REALIZED_PNL', + startTime: '1777593600000', + endTime: '1777680000000', + limit: '200', + recvWindow: '5000', + }); + expect(parameters.timestamp).toBeDefined(); + }); + + it('omits optional export filters when they are not provided', async () => { + const endpoint = + new BingxExportFundFlowEndpoint( + {}, + account, + ); + + expect(Object.keys(endpoint.parameters().asRecord())).toEqual([ + 'timestamp', + ]); + }); + }); +}); diff --git a/src/bingx-client/services/account.service.ts b/src/bingx-client/services/account.service.ts index d6d9a03..2ed170d 100644 --- a/src/bingx-client/services/account.service.ts +++ b/src/bingx-client/services/account.service.ts @@ -1,5 +1,9 @@ import { RequestExecutorInterface } from 'bingx-api/bingx/request-executor/request-executor.interface'; import { AccountInterface } from 'bingx-api/bingx/account/account.interface'; +import { + BingxExportFundFlowEndpoint, + BingxExportFundFlowOptions, +} from 'bingx-api/bingx/endpoints/bingx-export-fund-flow-endpoint'; import { BingxGetPerpetualSwapAccountAssetEndpoint } from 'bingx-api/bingx/endpoints/bingx-get-perpetual-swap-account-asset-endpoint'; import { BingxPerpetualSwapPositionsEndpoint } from 'bingx-api/bingx/endpoints/bingx-perpetual-swap-positions-endpoint'; @@ -17,4 +21,13 @@ export class AccountService { new BingxPerpetualSwapPositionsEndpoint(symbol, account), ); } + + public exportFundFlow( + options: BingxExportFundFlowOptions, + account: AccountInterface, + ) { + return this.requestExecutor.execute( + new BingxExportFundFlowEndpoint(options, account), + ); + } } diff --git a/src/bingx/endpoints/bingx-export-fund-flow-endpoint.ts b/src/bingx/endpoints/bingx-export-fund-flow-endpoint.ts new file mode 100644 index 0000000..381d8e4 --- /dev/null +++ b/src/bingx/endpoints/bingx-export-fund-flow-endpoint.ts @@ -0,0 +1,101 @@ +import { AccountInterface } from 'bingx-api/bingx/account/account.interface'; +import { DefaultSignatureParameters } from 'bingx-api/bingx/account/default-signature-parameters'; +import { SignatureParametersInterface } from 'bingx-api/bingx/account/signature-parameters.interface'; +import { Endpoint } from 'bingx-api/bingx/endpoints/endpoint'; +import { EndpointInterface } from 'bingx-api/bingx/endpoints/endpoint.interface'; +import type { ResponseType } from 'axios'; + +export type BingxFundFlowIncomeType = + | 'TRANSFER' + | 'REALIZED_PNL' + | 'FUNDING_FEE' + | 'TRADING_FEE' + | 'INSURANCE_CLEAR' + | 'TRIAL_FUND' + | 'ADL' + | 'SYSTEM_DEDUCTION'; + +export interface BingxExportFundFlowOptions { + symbol?: string; + incomeType?: BingxFundFlowIncomeType | string; + startTime?: Date | number; + endTime?: Date | number; + limit?: number; + recvWindow?: number; +} + +export type BingxExportFundFlowResponse = ArrayBuffer | Buffer; + +export class BingxExportFundFlowEndpoint + extends Endpoint + implements EndpointInterface +{ + constructor( + private readonly options: BingxExportFundFlowOptions, + account: AccountInterface, + ) { + super(account); + } + + method(): 'get' | 'post' | 'put' | 'patch' | 'delete' { + return 'get'; + } + + parameters(): SignatureParametersInterface { + return new DefaultSignatureParameters(this.queryParameters()); + } + + path(): string { + return '/openApi/swap/v2/user/income/export'; + } + + responseType(): ResponseType { + return 'arraybuffer'; + } + + private queryParameters(): Record { + const parameters: Record = {}; + + this.setOptionalParameter(parameters, 'symbol', this.options.symbol); + this.setOptionalParameter( + parameters, + 'incomeType', + this.options.incomeType, + ); + this.setOptionalTimestamp(parameters, 'startTime', this.options.startTime); + this.setOptionalTimestamp(parameters, 'endTime', this.options.endTime); + this.setOptionalParameter(parameters, 'limit', this.options.limit); + this.setOptionalParameter( + parameters, + 'recvWindow', + this.options.recvWindow, + ); + + return parameters; + } + + private setOptionalParameter( + parameters: Record, + name: string, + value?: string | number, + ) { + if (value !== undefined) { + parameters[name] = value.toString(); + } + } + + private setOptionalTimestamp( + parameters: Record, + name: string, + value?: Date | number, + ) { + if (value instanceof Date) { + parameters[name] = value.getTime().toString(10); + return; + } + + this.setOptionalParameter(parameters, name, value); + } + + readonly t!: R; +} diff --git a/src/bingx/endpoints/bingx-request.spec.ts b/src/bingx/endpoints/bingx-request.spec.ts new file mode 100644 index 0000000..5a00f50 --- /dev/null +++ b/src/bingx/endpoints/bingx-request.spec.ts @@ -0,0 +1,20 @@ +import { transformBingxResponse } from 'bingx-api/bingx/endpoints/bingx-request'; + +describe('transformBingxResponse', () => { + it('parses JSON responses with big number support', () => { + expect(transformBingxResponse('{"code":0,"data":{"id":123}}')).toEqual({ + code: '0', + data: { id: '123' }, + }); + }); + + it('passes binary responses through without logging parse errors', () => { + const response = Buffer.from('xlsx data'); + const consoleError = jest.spyOn(console, 'error').mockImplementation(); + + expect(transformBingxResponse(response)).toBe(response); + expect(consoleError).not.toHaveBeenCalled(); + + consoleError.mockRestore(); + }); +}); diff --git a/src/bingx/endpoints/bingx-request.ts b/src/bingx/endpoints/bingx-request.ts index 95db8fb..2a69e03 100644 --- a/src/bingx/endpoints/bingx-request.ts +++ b/src/bingx/endpoints/bingx-request.ts @@ -5,6 +5,19 @@ import { HttpService } from '@nestjs/axios'; import axios from 'axios'; import * as JSONBigNumber from 'json-bignumber'; +export function transformBingxResponse(res: unknown) { + if (typeof res !== 'string') { + return res; + } + + try { + return JSON.parse(JSON.stringify(JSONBigNumber.parse(res))); + } catch (e) { + console.error('BingxRequest.http.transformResponse', e, res); + return res; + } +} + export class BingxRequest implements BingxRequestInterface { private readonly http = new HttpService( axios.create({ @@ -12,14 +25,7 @@ export class BingxRequest implements BingxRequestInterface { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, - transformResponse: (res) => { - try { - return JSON.parse(JSON.stringify(JSONBigNumber.parse(res))); - } catch (e) { - console.error('BingxRequest.http.transformResponse', e, res); - return res; - } - }, + transformResponse: transformBingxResponse, }), ); @@ -34,6 +40,7 @@ export class BingxRequest implements BingxRequestInterface { signature: this.endpoint.signature().toString(), }, headers: this.endpoint.apiKey().asHeader(), + responseType: this.endpoint.responseType?.(), url: this.endpoint.path(), }), ); diff --git a/src/bingx/endpoints/endpoint.interface.ts b/src/bingx/endpoints/endpoint.interface.ts index c6249ed..127b7c3 100644 --- a/src/bingx/endpoints/endpoint.interface.ts +++ b/src/bingx/endpoints/endpoint.interface.ts @@ -1,6 +1,7 @@ import { SignatureInterface } from 'bingx-api/bingx/account/signature.interface'; import { ApiKeyHeader } from 'bingx-api/bingx/headers/api-key-header'; import { SignatureParametersInterface } from 'bingx-api/bingx/account/signature-parameters.interface'; +import type { ResponseType } from 'axios'; export interface EndpointInterface { readonly t: R; @@ -9,4 +10,5 @@ export interface EndpointInterface { parameters(): SignatureParametersInterface; signature(): SignatureInterface; apiKey(): ApiKeyHeader; + responseType?(): ResponseType; } diff --git a/src/bingx/endpoints/index.ts b/src/bingx/endpoints/index.ts index 6866f8b..5f7abda 100644 --- a/src/bingx/endpoints/index.ts +++ b/src/bingx/endpoints/index.ts @@ -1,5 +1,6 @@ export * from './bingx-cancel-all-orders-endpoint'; export * from './bingx-close-all-positions-endpoint'; +export * from './bingx-export-fund-flow-endpoint'; export * from './bingx-generate-listen-key-endpoint'; export * from './bingx-generate-listen-key-response'; export * from './bingx-get-perpetual-swap-account-asset-endpoint'; diff --git a/src/bingx/interfaces/websocket-event.ts b/src/bingx/interfaces/websocket-event.ts index a414299..3be37b3 100644 --- a/src/bingx/interfaces/websocket-event.ts +++ b/src/bingx/interfaces/websocket-event.ts @@ -1,4 +1,3 @@ -import { OrderTypeEnum } from 'bingx-api/bingx/enums/order-type.enum'; import { OrderSideEnum } from 'bingx-api/bingx/enums/order-side.enum'; import { OrderStatusEnum } from 'bingx-api/bingx/enums/order-status.enum'; import { OrderPositionSideEnum } from 'bingx-api/bingx/enums/order-position-side.enum';