diff --git a/package-lock.json b/package-lock.json index e6aeb81..73fa5ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nof1-tracker", - "version": "1.0.1", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nof1-tracker", - "version": "1.0.1", + "version": "1.0.3", "license": "MIT", "dependencies": { "@types/fs-extra": "^11.0.4", diff --git a/src/__tests__/binance-service-api.test.ts b/src/__tests__/binance-service-api.test.ts index 1dcbfaa..a5cd56f 100644 --- a/src/__tests__/binance-service-api.test.ts +++ b/src/__tests__/binance-service-api.test.ts @@ -30,10 +30,10 @@ describe("BinanceService - API Methods", () => { toString: jest.fn().mockReturnValue('mocked_signature') }); - // Mock axios.create + // Mock axios.create with proper get method that returns data mockAxiosInstance = { request: jest.fn(), - get: jest.fn() + get: jest.fn().mockResolvedValue({ data: { serverTime: Date.now() } }) }; MockedAxios.create = jest.fn().mockReturnValue(mockAxiosInstance); @@ -445,5 +445,139 @@ describe("BinanceService - API Methods", () => { 'Binance API Error: undefined' ); }); + + it("should handle -1021 timestamp error and retry", async () => { + // First call fails with -1021 + const error = { + response: { + data: { code: -1021, msg: 'Timestamp error' }, + status: 400 + } + }; + mockIsAxiosError.mockReturnValue(true); + + // Second call (retry) succeeds + mockAxiosInstance.request + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ data: { success: true } }); + + const result = await binanceService.getAccountInfo(); + expect(result.success).toBe(true); + expect(mockAxiosInstance.request).toHaveBeenCalledTimes(2); + }); + + it("should handle -2019 margin insufficient error", async () => { + const error = { + response: { + data: { code: -2019, msg: 'Margin insufficient' }, + status: 400 + } + }; + mockIsAxiosError.mockReturnValue(true); + mockAxiosInstance.request.mockRejectedValue(error); + + await expect(binanceService.getAccountInfo()).rejects.toThrow( + 'Binance API Error: Margin insufficient' + ); + }); + + it("should handle symbol not found in getSymbolInfo", async () => { + // Mock exchangeInfo not containing the symbol + mockAxiosInstance.get.mockResolvedValueOnce({ + data: { + symbols: [] // Empty symbols array + } + }); + + const result = await binanceService.getSymbolInfo('INVALIDUSDT'); + + // Should return default value when symbol not found + expect(result.symbol).toBe('INVALIDUSDT'); + expect(result.filters).toBeDefined(); + }); + + it("should handle getSymbolInfo error and return default", async () => { + // Mock API error + mockAxiosInstance.get.mockRejectedValueOnce(new Error('Network error')); + + const result = await binanceService.getSymbolInfo('BTCUSDT'); + + // Should return default value on error + expect(result.symbol).toBe('BTCUSDT'); + expect(result.filters).toBeDefined(); + expect(result.filters[0].filterType).toBe('LOT_SIZE'); + }); + + it("should use cached symbol info when available", async () => { + // Mock getExchangeInformation to return symbol data + mockAxiosInstance.get.mockResolvedValueOnce({ + data: { + symbols: [{ symbol: 'BTCUSDT', filters: [] }] + } + }); + + const result1 = await binanceService.getSymbolInfo('BTC'); + expect(result1.symbol).toBe('BTCUSDT'); + + // Second call should use cache (no additional API call) + const result2 = await binanceService.getSymbolInfo('BTC'); + expect(result2).toBe(result1); + + // getExchangeInformation should only be called once (second call uses cache) + expect(mockAxiosInstance.get).toHaveBeenCalled(); + // The exact call count may vary due to syncServerTime, so we just check that both calls return the same cached result + }); + }); + + describe("Error handling for specific error codes", () => { + it("should log margin insufficient error in placeOrder", async () => { + const error = { + response: { + data: { code: -2019, msg: 'Margin insufficient' }, + status: 400 + } + }; + mockIsAxiosError.mockReturnValue(true); + mockAxiosInstance.request.mockRejectedValue(error); + + const order = { + symbol: 'BTCUSDT', + side: 'BUY' as const, + type: 'MARKET' as const, + quantity: '0.001', + leverage: 10 + }; + + // Pre-populate cache to avoid getSymbolInfo call + const symbolInfo = { + symbol: 'BTCUSDT', + filters: [ + { filterType: 'LOT_SIZE', minQty: '0.001', stepSize: '0.001' }, + { filterType: 'PRICE_FILTER', tickSize: '0.01' } + ] + }; + (binanceService as any).symbolInfoCache.set('BTCUSDT', symbolInfo); + + await expect(binanceService.placeOrder(order)).rejects.toThrow( + 'Binance API Error: Margin insufficient' + ); + }); + }); + + describe("Formatting with different price precisions", () => { + it("should handle price precision 3 (MATICUSDT)", () => { + const result = binanceService.formatPrice(0.123456, "MATIC"); + expect(result).toBe("0.123"); + }); + + it("should handle price precision 4 (ADAUSDT)", () => { + const result = binanceService.formatPrice(0.12345678, "ADA"); + expect(result).toBe("0.1235"); + }); + + it("should handle price precision 5 (DOGEUSDT)", () => { + const result = binanceService.formatPrice(0.123456789, "DOGE"); + expect(result).toBe("0.12346"); + }); }); }); \ No newline at end of file diff --git a/src/__tests__/binance-service.test.ts b/src/__tests__/binance-service.test.ts index 2bc5092..6591fb7 100644 --- a/src/__tests__/binance-service.test.ts +++ b/src/__tests__/binance-service.test.ts @@ -5,6 +5,10 @@ import { TakeProfitOrder } from "../services/binance-service"; import { TradingPlan } from "../types/trading"; +import axios from 'axios'; + +// Mock axios +jest.mock('axios'); // Mock crypto for signature generation jest.mock("crypto", () => ({ @@ -16,8 +20,18 @@ jest.mock("crypto", () => ({ describe("BinanceService", () => { let service: BinanceService; + let mockAxiosInstance: any; beforeEach(() => { + // Mock axios.create to return a mock instance + mockAxiosInstance = { + request: jest.fn(), + get: jest.fn().mockResolvedValue({ data: { serverTime: Date.now() } }) + }; + + (axios.create as jest.Mock) = jest.fn(() => mockAxiosInstance); + (axios.isAxiosError as unknown as jest.Mock) = jest.fn(() => false); + service = new BinanceService("test-api-key", "test-api-secret"); }); @@ -29,6 +43,7 @@ describe("BinanceService", () => { describe("constructor", () => { it("should create BinanceService instance with API credentials", () => { + // mockAxiosInstance is already set up in outer beforeEach const testService = new BinanceService("test-api-key", "test-api-secret"); expect(testService).toBeInstanceOf(BinanceService); testService.destroy(); @@ -108,7 +123,7 @@ describe("BinanceService", () => { const binanceOrder = service.convertToBinanceOrder(tradingPlan); - expect(binanceOrder.quantity).toBe("10.000"); // SHIB最小数量是10 + expect(binanceOrder.quantity).toBe("10"); // DOGEUSDT最小数量是10, precision 0 }); it("should handle very small quantities", () => { @@ -719,23 +734,6 @@ describe("BinanceService", () => { // New API method coverage tests describe("API Method Coverage", () => { - let mockAxios: any; - - beforeEach(() => { - // Mock axios for API tests - mockAxios = { - create: jest.fn(() => ({ - request: jest.fn(), - get: jest.fn() - })) - }; - - // Mock axios module - const axios = require('axios'); - axios.create = mockAxios.create; - axios.isAxiosError = jest.fn(() => false); - }); - it("should have getServerTime method", () => { expect(typeof service.getServerTime).toBe('function'); }); @@ -782,16 +780,20 @@ describe("BinanceService", () => { expect(mainnetService).toBeDefined(); expect(testnetService).toBeDefined(); + mainnetService.destroy(); + testnetService.destroy(); }); it("should handle constructor with null credentials", () => { const nullService = new BinanceService(null as any, null as any, false); expect(nullService).toBeDefined(); + nullService.destroy(); }); it("should handle constructor with undefined credentials", () => { const undefService = new BinanceService(undefined as any, undefined as any, false); expect(undefService).toBeDefined(); + undefService.destroy(); }); // Price formatting tests @@ -830,5 +832,477 @@ describe("BinanceService", () => { expect(service.formatPrice(43210.987, "BTC")).toBe("43211.0"); }); }); + + describe("Dynamic Precision and Fallback Mechanism", () => { + describe("formatPrice fallback behavior", () => { + it("should use hardcoded precision for BTCUSDT when cache is empty", () => { + // BTC should use 1 decimal place from fallback + const result = service.formatPrice(45000.123, "BTC"); + expect(result).toBe("45000.1"); + }); + + it("should use hardcoded precision for ETHUSDT when cache is empty", () => { + // ETH should use 2 decimal places from fallback + const result = service.formatPrice(3000.456, "ETH"); + expect(result).toBe("3000.46"); + }); + + it("should use hardcoded precision for BNBUSDT when cache is empty", () => { + // BNB should use 2 decimal places from fallback + const result = service.formatPrice(500.789, "BNB"); + expect(result).toBe("500.79"); + }); + + it("should use default precision for unknown symbols", () => { + // Unknown symbol should default to 2 decimal places + const result = service.formatPrice(12345.6789, "UNKNOWNUSDT"); + expect(result).toBe("12345.68"); + }); + }); + + describe("formatQuantity fallback behavior", () => { + it("should use hardcoded precision for BTCUSDT when cache is empty", () => { + // BTC should use 3 decimal places from fallback + const result = service.formatQuantity(0.123456, "BTC"); + expect(result).toBe("0.123"); + }); + + it("should use hardcoded min quantity for BTCUSDT when cache is empty", () => { + // BTC min quantity is 0.001 + const result = service.formatQuantity(0.0005, "BTC"); + expect(result).toBe("0.001"); + }); + + it("should use hardcoded precision for BNBUSDT when cache is empty", () => { + // BNB should use 2 decimal places from fallback + const result = service.formatQuantity(0.123456, "BNB"); + expect(result).toBe("0.12"); + }); + + it("should use hardcoded min quantity for BNBUSDT when cache is empty", () => { + // BNB min quantity is 0.01 + const result = service.formatQuantity(0.005, "BNB"); + expect(result).toBe("0.01"); + }); + + it("should handle DOGEUSDT with zero precision", () => { + // DOGE should use 0 decimal places (whole numbers only) and stepSize of 10 + const result = service.formatQuantity(123.456, "DOGE"); + expect(result).toBe("120"); // Round down to nearest 10 + }); + + it("should use DOGEUSDT min quantity of 10", () => { + // DOGE min quantity is 10 + const result = service.formatQuantity(5, "DOGE"); + expect(result).toBe("10"); + }); + + it("should use default precision for unknown symbols", () => { + // Unknown symbol should default to 3 decimal places + const result = service.formatQuantity(0.123456, "UNKNOWNUSDT"); + expect(result).toBe("0.123"); + }); + }); + + describe("Fallback behavior for edge cases", () => { + it("should handle very small prices correctly", () => { + // Test with SHIB (default fallback to 2 decimal places) + const result = service.formatPrice(0.00001234, "SHIB"); + expect(result).toBe("0.00"); + }); + + it("should handle very large prices correctly", () => { + // Test with BTC (1 decimal place) + const result = service.formatPrice(99999.999, "BTC"); + expect(result).toBe("100000.0"); + }); + + it("should round quantity down to valid step size", () => { + // BTC with stepSize 0.001, should round down + const result = service.formatQuantity(0.1239, "BTC"); + expect(result).toBe("0.123"); + }); + + it("should ensure quantity meets minimum requirement", () => { + // BNB quantity below 0.01 should return 0.01 + const result = service.formatQuantity(0.001, "BNB"); + expect(result).toBe("0.01"); + }); + }); + + describe("Consistency between cached and fallback values", () => { + it("should produce same results for known symbols with or without cache", () => { + // Test that fallback produces consistent results + const price1 = service.formatPrice(43210.987, "BTC"); + const price2 = service.formatPrice(43210.987, "BTC"); + expect(price1).toBe(price2); + expect(price1).toBe("43211.0"); + }); + + it("should handle zero quantity with correct min quantity", () => { + // DOGEUSDT with quantity 0 should return min quantity + const result = service.formatQuantity(0, "DOGE"); + expect(result).toBe("10"); + }); + }); + }); + + describe("formatQuantityAsync - Dynamic Precision from API", () => { + beforeEach(() => { + // Mock getSymbolInfo to return symbol information + jest.spyOn(service, 'getSymbolInfo').mockImplementation(async (symbol: string) => { + const mockSymbolInfo: any = { + symbol, + filters: [ + { + filterType: 'LOT_SIZE', + minQty: '0.001', + stepSize: '0.001' + }, + { + filterType: 'PRICE_FILTER', + tickSize: '0.01' + } + ] + }; + return mockSymbolInfo; + }); + }); + + it("should format quantity using API precision for BTCUSDT", async () => { + const result = await service.formatQuantityAsync(0.123456, "BTC"); + expect(result).toBe("0.123"); + }); + + it("should use minimum quantity when quantity is too small", async () => { + const result = await service.formatQuantityAsync(0.0005, "BTC"); + expect(result).toBe("0.001"); + }); + + it("should round down to nearest step size", async () => { + const result = await service.formatQuantityAsync(0.1239, "BTC"); + expect(result).toBe("0.123"); + }); + + it("should handle different step sizes correctly", async () => { + // Mock BNB with different step size + (service.getSymbolInfo as jest.Mock).mockImplementation(async (symbol: string) => { + if (symbol === 'BNBUSDT') { + return { + symbol, + filters: [ + { filterType: 'LOT_SIZE', minQty: '0.01', stepSize: '0.01' }, + { filterType: 'PRICE_FILTER', tickSize: '0.01' } + ] + }; + } + return { + symbol, + filters: [ + { filterType: 'LOT_SIZE', minQty: '0.001', stepSize: '0.001' }, + { filterType: 'PRICE_FILTER', tickSize: '0.01' } + ] + }; + }); + + const result = await service.formatQuantityAsync(0.123, "BNB"); + expect(result).toBe("0.12"); + }); + + it("should fall back to defaults when getSymbolInfo fails", async () => { + // Mock getSymbolInfo to fail + (service.getSymbolInfo as jest.Mock).mockRejectedValueOnce(new Error('API Error')); + + // Should use default values when API fails + const result = await service.formatQuantityAsync(0.123, "BTC"); + expect(result).toBe("0.123"); // Uses default fallback from getPrecisions + }); + + it("should handle string quantity input", async () => { + const result = await service.formatQuantityAsync("0.123456", "BTC"); + expect(result).toBe("0.123"); + }); + + it("should preserve precision from API specifications", async () => { + // Mock SHIB with specific precision + (service.getSymbolInfo as jest.Mock).mockImplementation(async (symbol: string) => { + if (symbol === 'SHIBUSDT') { + return { + symbol, + filters: [ + { filterType: 'LOT_SIZE', minQty: '1000', stepSize: '1000' }, + { filterType: 'PRICE_FILTER', tickSize: '0.00001' } + ] + }; + } + return { + symbol, + filters: [ + { filterType: 'LOT_SIZE', minQty: '0.001', stepSize: '0.001' }, + { filterType: 'PRICE_FILTER', tickSize: '0.01' } + ] + }; + }); + + const result = await service.formatQuantityAsync(5000, "SHIB"); + expect(result).toBe("5000"); // Math.floor(5000/1000)*1000 = 5000 + + // Test actual round-down behavior + const result2 = await service.formatQuantityAsync(5500, "SHIB"); + expect(result2).toBe("5000"); // Math.floor(5500/1000)*1000 = 5000 + }); + }); + + describe("Cache-based precision with API data", () => { + beforeEach(() => { + // Add symbol info to cache + const symbolInfo: any = { + symbol: 'BTCUSDT', + filters: [ + { + filterType: 'LOT_SIZE', + minQty: '0.001', + stepSize: '0.001' + }, + { + filterType: 'PRICE_FILTER', + tickSize: '0.1' + } + ] + }; + (service as any).symbolInfoCache.set('BTCUSDT', symbolInfo); + }); + + it("should use cached symbol info when available", () => { + const result = service.formatQuantity(0.123456, "BTC"); + expect(result).toBe("0.123"); + }); + + it("should use cached price info when available", () => { + const result = service.formatPrice(43210.987, "BTC"); + // tickSize = 0.1, so 43210.987 rounds to 43211.0 + expect(result).toBe("43211.0"); + }); + + it("should handle cached data with different step sizes", () => { + // Update cache with different step size + const symbolInfo: any = { + symbol: 'BNBUSDT', + filters: [ + { + filterType: 'LOT_SIZE', + minQty: '0.01', + stepSize: '0.01' + }, + { + filterType: 'PRICE_FILTER', + tickSize: '0.01' + } + ] + }; + (service as any).symbolInfoCache.set('BNBUSDT', symbolInfo); + + const result = service.formatQuantity(0.123, "BNB"); + expect(result).toBe("0.12"); + }); + + it("should handle zero quantity with cached min quantity", () => { + const result = service.formatQuantity(0, "BTC"); + expect(result).toBe("0.001"); + }); + + it("should handle very large quantities with cached precision", () => { + const result = service.formatQuantity(1000.9876, "BTC"); + expect(result).toBe("1000.987"); + }); + + it("should handle prices with cached tick size", () => { + // BTC has tickSize 0.1 in cache + const result = service.formatPrice(43210.987, "BTC"); + // tickSize = 0.1, so 43210.987 rounds to 43211.0 + expect(result).toBe("43211.0"); + }); + + it("should fallback when cache is empty", () => { + (service as any).symbolInfoCache.clear(); + + const result = service.formatQuantity(0.123456, "UNKNOWN"); + expect(result).toBe("0.123"); // Uses fallback + }); + }); + }); + + describe("Private Helper Methods", () => { + describe("calculatePrecision", () => { + it("should calculate precision correctly for decimal values", () => { + const precision = (service as any).calculatePrecision(0.001); + expect(precision).toBe(3); + }); + + it("should handle very small step sizes (scientific notation)", () => { + const precision = (service as any).calculatePrecision(1e-6); + expect(precision).toBe(6); + }); + + it("should return 0 for stepSize >= 1", () => { + expect((service as any).calculatePrecision(1)).toBe(0); + expect((service as any).calculatePrecision(10)).toBe(0); + expect((service as any).calculatePrecision(100.5)).toBe(0); + }); + + it("should handle various decimal precisions", () => { + expect((service as any).calculatePrecision(0.1)).toBe(1); + expect((service as any).calculatePrecision(0.01)).toBe(2); + expect((service as any).calculatePrecision(0.0001)).toBe(4); + expect((service as any).calculatePrecision(0.00001)).toBe(5); + }); + }); + + describe("getLotSizeFilter", () => { + it("should extract LOT_SIZE filter from symbol info", () => { + const symbolInfo = { + symbol: "BTCUSDT", + filters: [ + { filterType: "LOT_SIZE", minQty: "0.001", stepSize: "0.001" }, + { filterType: "PRICE_FILTER", tickSize: "0.1" } + ] + }; + + const filter = (service as any).getLotSizeFilter(symbolInfo); + expect(filter).toBeDefined(); + expect(filter.filterType).toBe("LOT_SIZE"); + expect(filter.minQty).toBe("0.001"); + }); + + it("should return undefined when LOT_SIZE filter not found", () => { + const symbolInfo = { + symbol: "BTCUSDT", + filters: [{ filterType: "PRICE_FILTER", tickSize: "0.1" }] + }; + + const filter = (service as any).getLotSizeFilter(symbolInfo); + expect(filter).toBeUndefined(); + }); + + it("should handle null/undefined symbolInfo safely", () => { + expect((service as any).getLotSizeFilter(null)).toBeUndefined(); + expect((service as any).getLotSizeFilter(undefined)).toBeUndefined(); + expect((service as any).getLotSizeFilter({})).toBeUndefined(); + }); + }); + + describe("getPriceFilter", () => { + it("should extract PRICE_FILTER from symbol info", () => { + const symbolInfo = { + symbol: "BTCUSDT", + filters: [ + { filterType: "LOT_SIZE", minQty: "0.001", stepSize: "0.001" }, + { filterType: "PRICE_FILTER", tickSize: "0.1" } + ] + }; + + const filter = (service as any).getPriceFilter(symbolInfo); + expect(filter).toBeDefined(); + expect(filter.filterType).toBe("PRICE_FILTER"); + expect(filter.tickSize).toBe("0.1"); + }); + + it("should return undefined when PRICE_FILTER not found", () => { + const symbolInfo = { + symbol: "BTCUSDT", + filters: [{ filterType: "LOT_SIZE", minQty: "0.001", stepSize: "0.001" }] + }; + + const filter = (service as any).getPriceFilter(symbolInfo); + expect(filter).toBeUndefined(); + }); + + it("should handle null/undefined symbolInfo safely", () => { + expect((service as any).getPriceFilter(null)).toBeUndefined(); + expect((service as any).getPriceFilter(undefined)).toBeUndefined(); + expect((service as any).getPriceFilter({})).toBeUndefined(); + }); + }); + + describe("roundToStepSize", () => { + it("should round to nearest step size correctly", () => { + const result = (service as any).roundToStepSize(10.123, 0.01); + expect(result).toBeCloseTo(10.12, 10); + }); + + it("should round down correctly", () => { + const result = (service as any).roundToStepSize(10.125, 0.01); + expect(result).toBeCloseTo(10.13, 10); + }); + + it("should handle large step sizes", () => { + const result = (service as any).roundToStepSize(12345, 1000); + expect(result).toBe(12000); + }); + + it("should handle small step sizes", () => { + const result = (service as any).roundToStepSize(0.1239, 0.001); + expect(result).toBe(0.124); + }); + + it("should handle exact multiples", () => { + const result = (service as any).roundToStepSize(100, 10); + expect(result).toBe(100); + }); + }); + + describe("floorToStepSize", () => { + it("should floor to nearest step size correctly", () => { + const result = (service as any).floorToStepSize(10.123, 0.01); + expect(result).toBeCloseTo(10.12, 10); + }); + + it("should always floor down", () => { + const result = (service as any).floorToStepSize(10.129, 0.01); + expect(result).toBeCloseTo(10.12, 10); + }); + + it("should handle large step sizes", () => { + const result = (service as any).floorToStepSize(12345, 1000); + expect(result).toBe(12000); + }); + + it("should handle small step sizes", () => { + const result = (service as any).floorToStepSize(0.1239, 0.001); + expect(result).toBe(0.123); + }); + + it("should handle exact multiples", () => { + const result = (service as any).floorToStepSize(100, 10); + expect(result).toBe(100); + }); + + it("should work correctly with BTC stepSize", () => { + const result = (service as any).floorToStepSize(0.1239, 0.001); + expect(result).toBe(0.123); + }); + }); + + describe("Integration: Helper methods with public methods", () => { + it("should use calculatePrecision in formatQuantity", () => { + const result = service.formatQuantity(0.12345, "BTC"); + expect(result).toBe("0.123"); + }); + + it("should use floorToStepSize in formatQuantity", () => { + const result = service.formatQuantity(0.1239, "BTC"); + expect(result).toBe("0.123"); + }); + + it("should use calculatePrecision in formatPrice", () => { + const result = service.formatPrice(43210.987, "BTC"); + expect(result).toBe("43211.0"); + }); + + it("should use roundToStepSize in formatPrice", () => { + const result = service.formatPrice(43210.987, "BTC"); + expect(result).toBe("43211.0"); + }); + }); }); }); diff --git a/src/services/binance-service.ts b/src/services/binance-service.ts index cc68e08..1aafa51 100644 --- a/src/services/binance-service.ts +++ b/src/services/binance-service.ts @@ -2,6 +2,7 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; import CryptoJS from 'crypto-js'; import http from 'http'; import https from 'https'; +import { logDebug, logInfo } from '../utils/logger'; export interface BinanceOrder { symbol: string; @@ -155,97 +156,249 @@ export class BinanceService { return `${symbol}USDT`; } + /** + * Calculate precision from step size + * Uses logarithm to handle scientific notation and very small values + * @example stepSize = 0.001 -> precision = 3 + * @example stepSize = 1e-6 -> precision = 6 + * @example stepSize = 1 -> precision = 0 + */ + private calculatePrecision(stepSize: number): number { + if (stepSize >= 1) return 0; + return Math.abs(Math.floor(Math.log10(stepSize))); + } + + /** + * Get LOT_SIZE filter from symbol info + */ + private getLotSizeFilter(symbolInfo: any): any { + return symbolInfo?.filters?.find((f: any) => f.filterType === 'LOT_SIZE'); + } + + /** + * Get PRICE_FILTER from symbol info + */ + private getPriceFilter(symbolInfo: any): any { + return symbolInfo?.filters?.find((f: any) => f.filterType === 'PRICE_FILTER'); + } + + /** + * Round a value to the nearest step size + * @param value The value to round + * @param stepSize The step size to round to + * @returns The rounded value + */ + private roundToStepSize(value: number, stepSize: number): number { + return Math.round(value / stepSize) * stepSize; + } + + /** + * Floor a value to the nearest step size + * @param value The value to floor + * @param stepSize The step size to floor to + * @returns The floored value + */ + private floorToStepSize(value: number, stepSize: number): number { + return Math.floor(value / stepSize) * stepSize; + } + + /** + * Get quantity and price precision from Binance API + */ + private async getPrecisions(symbol: string): Promise<{ quantityPrecision: number; pricePrecision: number; minQty: number; stepSize: number }> { + try { + const symbolInfo = await this.getSymbolInfo(symbol); + + // Get LOT_SIZE filter for quantity precision + const lotSizeFilter = this.getLotSizeFilter(symbolInfo); + // Get PRICE_FILTER for price precision + const priceFilter = this.getPriceFilter(symbolInfo); + + if (!lotSizeFilter || !priceFilter) { + throw new Error('Required filters (LOT_SIZE or PRICE_FILTER) not found in symbol info'); + } + + const minQty = parseFloat(lotSizeFilter.minQty || '0.001'); + const stepSize = parseFloat(lotSizeFilter.stepSize || '0.001'); + + // Calculate precision from step size + const quantityPrecision = this.calculatePrecision(stepSize); + + // Get price precision from tickSize + const tickSize = parseFloat(priceFilter.tickSize || '0.01'); + const pricePrecision = this.calculatePrecision(tickSize); + + return { quantityPrecision, pricePrecision, minQty, stepSize }; + } catch (error) { + console.warn(`⚠️ Failed to get precision from API for ${symbol}, using defaults: ${error instanceof Error ? error.message : 'Unknown error'}`); + + // Return default values + return { + quantityPrecision: 3, + pricePrecision: 2, + minQty: 0.001, + stepSize: 0.001 + }; + } + } + /** * Format quantity precision based on symbol */ + public async formatQuantityAsync(quantity: number | string, symbol: string): Promise { + const baseSymbol = this.convertSymbol(symbol); + const { quantityPrecision, minQty, stepSize } = await this.getPrecisions(baseSymbol); + + // Convert to number if it's a string + const quantityNum = typeof quantity === 'string' ? parseFloat(quantity) : quantity; + + // If quantity is too small, return minimum quantity + if (quantityNum < minQty && quantityNum > 0) { + console.warn(`⚠️ Quantity ${quantityNum} is below minimum ${minQty} for ${baseSymbol}, using minimum`); + return minQty.toString(); + } + + // Round down to nearest valid step size + const roundedQuantity = this.floorToStepSize(quantityNum, stepSize); + + // Ensure we don't go below minimum + const finalQuantity = Math.max(roundedQuantity, minQty); + + // Format to correct precision - keep trailing zeros for precision requirements + const formattedQuantity = finalQuantity.toFixed(quantityPrecision); + return formattedQuantity; + } + + /** + * Format quantity precision based on symbol (synchronous fallback) + * This method tries to use cached symbol info or falls back to hardcoded values + */ public formatQuantity(quantity: number | string, symbol: string): string { const baseSymbol = this.convertSymbol(symbol); - // Updated precision map based on actual Binance futures API specifications - const precisionMap: Record = { - 'BTCUSDT': 3, // BTC futures: 3 decimal places (min 0.001, step 0.001) - 'ETHUSDT': 3, // ETH futures: 3 decimal places (min 0.001, step 0.001) - 'BNBUSDT': 2, // BNB futures: 2 decimal places (min 0.01, step 0.01) - 'XRPUSDT': 1, // XRP futures: 1 decimal place (min 0.1, step 0.1) - 'ADAUSDT': 0, // ADA futures: 0 decimal places (min 1, step 1) - 'DOGEUSDT': 0, // DOGE futures: 0 decimal places (min 1, step 1) - 'SOLUSDT': 2, // SOL futures: 2 decimal places (min 0.01, step 0.01) - 'AVAXUSDT': 2, // AVAX futures: 2 decimal places (min 0.01, step 0.01) - 'MATICUSDT': 1, // MATIC futures: 1 decimal place (min 0.1, step 0.1) - 'DOTUSDT': 2, // DOT futures: 2 decimal places (min 0.01, step 0.01) - 'LINKUSDT': 2, // LINK futures: 2 decimal places (min 0.01, step 0.01) - 'UNIUSDT': 2, // UNI futures: 2 decimal places (min 0.01, step 0.01) - }; + // Check cache first + let quantityPrecision = 3; + let minQty = 0.001; + let stepSize = 0.001; - const precision = precisionMap[baseSymbol] || 3; // Default to 3 decimal places + if (this.symbolInfoCache.has(baseSymbol)) { + const symbolInfo = this.symbolInfoCache.get(baseSymbol); + const lotSizeFilter = this.getLotSizeFilter(symbolInfo); - // Convert to number if it's a string - const quantityNum = typeof quantity === 'string' ? parseFloat(quantity) : quantity; + if (lotSizeFilter) { + minQty = parseFloat(lotSizeFilter.minQty || '0.001'); + stepSize = parseFloat(lotSizeFilter.stepSize || '0.001'); + quantityPrecision = this.calculatePrecision(stepSize); + } + } else { + // Fallback to hardcoded precision and min quantity maps for backward compatibility + const precisionMap: Record = { + 'BTCUSDT': 3, // BTC futures: 3 decimal places (min 0.001, step 0.001) + 'ETHUSDT': 3, // ETH futures: 3 decimal places (min 0.001, step 0.001) + 'BNBUSDT': 2, // BNB futures: 2 decimal places (min 0.01, step 0.01) + 'XRPUSDT': 1, // XRP futures: 1 decimal place (min 0.1, step 0.1) + 'ADAUSDT': 0, // ADA futures: 0 decimal places (min 1, step 1) + 'DOGEUSDT': 0, // DOGE futures: 0 decimal places (min 10, step 10) + 'SOLUSDT': 2, // SOL futures: 2 decimal places (min 0.01, step 0.01) + 'AVAXUSDT': 2, // AVAX futures: 2 decimal places (min 0.01, step 0.01) + 'MATICUSDT': 1, // MATIC futures: 1 decimal place (min 0.1, step 0.1) + 'DOTUSDT': 2, // DOT futures: 2 decimal places (min 0.01, step 0.01) + 'LINKUSDT': 2, // LINK futures: 2 decimal places (min 0.01, step 0.01) + 'UNIUSDT': 2, // UNI futures: 2 decimal places (min 0.01, step 0.01) + }; - // Define minimum quantities based on actual Binance futures API specifications - const minQtyMap: Record = { - 'BTCUSDT': 0.001, // BTC futures min: 0.001 - 'ETHUSDT': 0.001, // ETH futures min: 0.001 - 'BNBUSDT': 0.01, // BNB futures min: 0.01 - 'XRPUSDT': 0.1, // XRP futures min: 0.1 - 'ADAUSDT': 1, // ADA futures min: 1 - 'DOGEUSDT': 10, // DOGE futures min: 10 - 'SOLUSDT': 0.01, // SOL futures min: 0.01 - 'AVAXUSDT': 0.01, // AVAX futures min: 0.01 - 'MATICUSDT': 0.1, // MATIC futures min: 0.1 - 'DOTUSDT': 0.01, // DOT futures min: 0.01 - 'LINKUSDT': 0.01, // LINK futures min: 0.01 - 'UNIUSDT': 0.01, // UNI futures min: 0.01 - }; + const minQtyMap: Record = { + 'BTCUSDT': 0.001, // BTC futures min: 0.001 + 'ETHUSDT': 0.001, // ETH futures min: 0.001 + 'BNBUSDT': 0.01, // BNB futures min: 0.01 + 'XRPUSDT': 0.1, // XRP futures min: 0.1 + 'ADAUSDT': 1, // ADA futures min: 1 + 'DOGEUSDT': 10, // DOGE futures min: 10 + 'SOLUSDT': 0.01, // SOL futures min: 0.01 + 'AVAXUSDT': 0.01, // AVAX futures min: 0.01 + 'MATICUSDT': 0.1, // MATIC futures min: 0.1 + 'DOTUSDT': 0.01, // DOT futures min: 0.01 + 'LINKUSDT': 0.01, // LINK futures min: 0.01 + 'UNIUSDT': 0.01, // UNI futures min: 0.01 + }; + + quantityPrecision = precisionMap[baseSymbol] ?? 3; + minQty = minQtyMap[baseSymbol] ?? 0.001; + stepSize = minQty; + } - const minQty = minQtyMap[baseSymbol] || 0.001; + // Convert to number if it's a string + const quantityNum = typeof quantity === 'string' ? parseFloat(quantity) : quantity; // If quantity is too small, return minimum quantity if (quantityNum < minQty && quantityNum > 0) { - console.warn(`Quantity ${quantityNum} is below minimum ${minQty} for ${baseSymbol}, using minimum`); - return minQty.toString(); + console.warn(`⚠️ Quantity ${quantityNum} is below minimum ${minQty} for ${baseSymbol}, using minimum`); + return minQty.toFixed(quantityPrecision); } - // Round to nearest valid step size - const stepSize = minQty; // Use minQty as step size for simplicity - const roundedQuantity = Math.floor(quantityNum / stepSize) * stepSize; + // Round down to nearest valid step size + const roundedQuantity = this.floorToStepSize(quantityNum, stepSize); // Ensure we don't go below minimum const finalQuantity = Math.max(roundedQuantity, minQty); // Format to correct precision - keep trailing zeros for precision requirements - const formattedQuantity = finalQuantity.toFixed(precision); + const formattedQuantity = finalQuantity.toFixed(quantityPrecision); return formattedQuantity; } /** - * Format price precision based on symbol + * Format price precision based on symbol (synchronous version with cache support) */ public formatPrice(price: number | string, symbol: string): string { const baseSymbol = this.convertSymbol(symbol); - // Price precision map for stop prices and regular prices - const pricePrecisionMap: Record = { - 'BTCUSDT': 1, // BTC: 1 decimal place for prices - 'ETHUSDT': 2, // ETH: 2 decimal places for prices - 'BNBUSDT': 2, // BNB: 2 decimal places for prices - 'ADAUSDT': 4, // ADA: 4 decimal places for prices - 'DOGEUSDT': 5, // DOGE: 5 decimal places for prices - 'SOLUSDT': 2, // SOL: 2 decimal places for prices - 'AVAXUSDT': 2, // AVAX: 2 decimal places for prices - 'MATICUSDT': 3, // MATIC: 3 decimal places for prices - 'DOTUSDT': 2, // DOT: 2 decimal places for prices - 'LINKUSDT': 2, // LINK: 2 decimal places for prices - 'UNIUSDT': 2, // UNI: 2 decimal places for prices - }; + // Check cache first + let pricePrecision = 2; + let tickSize = 0.01; - const precision = pricePrecisionMap[baseSymbol] || 2; // Default to 2 decimal places for prices + if (this.symbolInfoCache.has(baseSymbol)) { + const symbolInfo = this.symbolInfoCache.get(baseSymbol); + const priceFilter = this.getPriceFilter(symbolInfo); + + if (priceFilter) { + tickSize = parseFloat(priceFilter.tickSize || '0.01'); + pricePrecision = this.calculatePrecision(tickSize); + } + } else { + // Fallback to hardcoded precision map for backward compatibility + const pricePrecisionMap: Record = { + 'BTCUSDT': 1, // BTC: 1 decimal place for prices + 'ETHUSDT': 2, // ETH: 2 decimal places for prices + 'BNBUSDT': 2, // BNB: 2 decimal places for prices + 'ADAUSDT': 4, // ADA: 4 decimal places for prices + 'DOGEUSDT': 5, // DOGE: 5 decimal places for prices + 'SOLUSDT': 2, // SOL: 2 decimal places for prices + 'AVAXUSDT': 2, // AVAX: 2 decimal places for prices + 'MATICUSDT': 3, // MATIC: 3 decimal places for prices + 'DOTUSDT': 2, // DOT: 2 decimal places for prices + 'LINKUSDT': 2, // LINK: 2 decimal places for prices + 'UNIUSDT': 2, // UNI: 2 decimal places for prices + }; + + pricePrecision = pricePrecisionMap[baseSymbol] || 2; + + // Set appropriate tickSize based on precision + if (pricePrecision === 1) tickSize = 0.1; + else if (pricePrecision === 2) tickSize = 0.01; + else if (pricePrecision === 3) tickSize = 0.001; + else if (pricePrecision === 4) tickSize = 0.0001; + else if (pricePrecision === 5) tickSize = 0.00001; + } // Convert to number if it's a string const priceNum = typeof price === 'string' ? parseFloat(price) : price; + // Round to nearest tick size + const roundedPrice = this.roundToStepSize(priceNum, tickSize); + // Format to correct precision - keep trailing zeros for precision requirements - const formattedPrice = priceNum.toFixed(precision); + const formattedPrice = roundedPrice.toFixed(pricePrecision); return formattedPrice; } @@ -269,7 +422,7 @@ export class BinanceService { const response = await this.client.get('/fapi/v1/time'); const serverTime = response.data.serverTime; this.serverTimeOffset = serverTime - localTime; - console.log(`⏰ Server time synced. Offset: ${this.serverTimeOffset}ms`); + logInfo(`⏰ Server time synced. Offset: ${this.serverTimeOffset}ms`); } catch (error) { console.warn('⚠️ Failed to sync server time:', error instanceof Error ? error.message : 'Unknown error'); } @@ -336,7 +489,7 @@ export class BinanceService { // Log error details for debugging console.error(`API Error [${errorCode || 'UNKNOWN'}]: ${errorMessage}`); - + // 处理时间同步错误 (-1021) if (errorCode === -1021) { console.warn('⏰ Timestamp error detected, syncing server time and retrying...'); @@ -358,7 +511,7 @@ export class BinanceService { const retryResponse = await this.client.request(retryConfig); return retryResponse.data; } - + if (errorCode === -2019) { console.error('💰 Margin insufficient - check available balance and existing positions'); } @@ -485,15 +638,30 @@ export class BinanceService { * 下单 */ async placeOrder(order: BinanceOrder): Promise { + const baseSymbol = this.convertSymbol(order.symbol); + + // 确保符号信息已加载到缓存中(避免精度错误) + if (!this.symbolInfoCache.has(baseSymbol)) { + logDebug(`📥 Loading symbol info for ${baseSymbol}...`); + await this.getSymbolInfo(baseSymbol); + } + const params: Record = { - symbol: this.convertSymbol(order.symbol), + symbol: baseSymbol, side: order.side, type: order.type, }; // 如果使用 closePosition,则不需要 quantity if (order.closePosition !== "true") { - params.quantity = this.formatQuantity(order.quantity, order.symbol); + const formattedQuantity = this.formatQuantity(order.quantity, order.symbol); + params.quantity = formattedQuantity; + + // 调试信息:显示格式化的数量 + const symbolInfo = this.symbolInfoCache.get(baseSymbol); + const lotSizeFilter = this.getLotSizeFilter(symbolInfo); + logDebug(`📊 Quantity formatting: ${order.quantity} -> ${formattedQuantity}`); + logDebug(` Step size: ${lotSizeFilter?.stepSize || 'N/A'}, Min Qty: ${lotSizeFilter?.minQty || 'N/A'}`); } if (order.price) params.price = this.formatPrice(order.price, order.symbol);