diff --git a/src/strategy/maker-engine.ts b/src/strategy/maker-engine.ts index bbd99cc..2ffb022 100644 --- a/src/strategy/maker-engine.ts +++ b/src/strategy/maker-engine.ts @@ -495,7 +495,8 @@ export class MakerEngine { markPrice: position.markPrice, expectedPrice: Number(closeSidePrice) || null, maxPct: this.config.maxCloseSlippagePct, - } + }, + { qtyStep: this.qtyStep } ); } catch (error) { if (isUnknownOrderError(error)) { diff --git a/src/strategy/offset-maker-engine.ts b/src/strategy/offset-maker-engine.ts index 6e6c398..91168b7 100644 --- a/src/strategy/offset-maker-engine.ts +++ b/src/strategy/offset-maker-engine.ts @@ -353,7 +353,8 @@ export class OffsetMakerEngine { ? (closeAskPrice != null ? Number(closeAskPrice) : null) : (closeBidPrice != null ? Number(closeBidPrice) : null), maxPct: this.config.maxCloseSlippagePct, - } + }, + { qtyStep: this.qtyStep } ); } catch (error) { if (isUnknownOrderError(error)) { @@ -441,7 +442,8 @@ export class OffsetMakerEngine { markPrice: position.markPrice, expectedPrice: Number(closeSidePrice) || null, maxPct: this.config.maxCloseSlippagePct, - } + }, + { qtyStep: this.qtyStep } ); } catch (error) { if (isUnknownOrderError(error)) { @@ -587,7 +589,8 @@ export class OffsetMakerEngine { markPrice: position.markPrice, expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null, maxPct: this.config.maxCloseSlippagePct, - } + }, + { qtyStep: this.qtyStep } ); } catch (error) { if (isUnknownOrderError(error)) { diff --git a/src/strategy/trend-engine.ts b/src/strategy/trend-engine.ts index 32134f2..26f2574 100644 --- a/src/strategy/trend-engine.ts +++ b/src/strategy/trend-engine.ts @@ -743,8 +743,8 @@ export class TrendEngine { : this.depthSnapshot?.asks?.[0]?.[0] ) || null, maxPct: this.config.maxCloseSlippagePct, - }, - { qtyStep: this.config.qtyStep } + }, + { qtyStep: this.config.qtyStep } ); result.closed = true; this.tradeLog.push("close", `止损平仓: ${direction === "long" ? "SELL" : "BUY"}`); @@ -783,7 +783,11 @@ export class TrendEngine { } try { const position = getPosition(this.accountSnapshot, this.config.symbol); - const quantity = Math.abs(position.positionAmt) || this.config.tradeAmount; + const quantity = Math.abs(position.positionAmt); + const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12; + if (quantity <= minQty) { + return; + } await placeStopLossOrder( this.exchange, this.config.symbol, @@ -839,7 +843,11 @@ export class TrendEngine { // 仅在成功创建新止损单后记录“移动止损”日志 try { const position = getPosition(this.accountSnapshot, this.config.symbol); - const quantity = Math.abs(position.positionAmt) || this.config.tradeAmount; + const quantity = Math.abs(position.positionAmt); + const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12; + if (quantity <= minQty) { + return; + } const order = await placeStopLossOrder( this.exchange, this.config.symbol, @@ -866,7 +874,11 @@ export class TrendEngine { // 回滚策略:尝试用原价恢复止损,以避免出现短时间内无止损保护 try { const position = getPosition(this.accountSnapshot, this.config.symbol); - const quantity = Math.abs(position.positionAmt) || this.config.tradeAmount; + const quantity = Math.abs(position.positionAmt); + const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12; + if (quantity <= minQty) { + return; + } const restoreInvalid = (side === "SELL" && existingStopPrice >= lastPrice) || (side === "BUY" && existingStopPrice <= lastPrice); diff --git a/src/utils/math.ts b/src/utils/math.ts index 45e5db4..94e808a 100644 --- a/src/utils/math.ts +++ b/src/utils/math.ts @@ -1,21 +1,54 @@ +const MAX_SCALE_DECIMALS = 12; +const STEP_TOLERANCE = 1e-9; + +function resolveStepScale(step: number): { scale: number; stepInt: number; decimals: number } { + if (!Number.isFinite(step) || step <= 0) { + return { scale: 1, stepInt: 1, decimals: 0 }; + } + let decimals = Math.min(MAX_SCALE_DECIMALS, Math.max(0, decimalsOf(step))); + let scale = Math.pow(10, decimals); + let scaledStep = step * scale; + while (decimals < MAX_SCALE_DECIMALS && Math.abs(Math.round(scaledStep) - scaledStep) > STEP_TOLERANCE) { + decimals += 1; + scale *= 10; + scaledStep = step * scale; + } + const stepInt = Math.max(1, Math.round(scaledStep)); + return { scale, stepInt, decimals }; +} + export function roundDownToTick(value: number, tick: number): number { if (!Number.isFinite(value) || !Number.isFinite(tick) || tick <= 0) return value; - const scaled = Math.floor(value / tick) * tick; - // Avoid floating residuals - return Number(scaled.toFixed(Math.max(0, decimalsOf(tick)))); + const sign = value < 0 ? -1 : 1; + const absValue = Math.abs(value); + const { scale, stepInt, decimals } = resolveStepScale(tick); + const scaledValue = Math.floor(absValue * scale + STEP_TOLERANCE); + const resultInt = Math.floor(scaledValue / stepInt) * stepInt; + const rounded = resultInt / scale; + return sign * Number(rounded.toFixed(decimals)); } export function roundQtyDownToStep(value: number, step: number): number { if (!Number.isFinite(value) || !Number.isFinite(step) || step <= 0) return value; - const scaled = Math.floor(value / step) * step; - return Number(scaled.toFixed(Math.max(0, decimalsOf(step)))); + const sign = value < 0 ? -1 : 1; + const absValue = Math.abs(value); + const { scale, stepInt, decimals } = resolveStepScale(step); + const scaledValue = Math.floor(absValue * scale + STEP_TOLERANCE); + const resultInt = Math.floor(scaledValue / stepInt) * stepInt; + const rounded = resultInt / scale; + return sign * Number(rounded.toFixed(decimals)); } export function decimalsOf(step: number): number { - const s = step.toString(); - if (!s.includes(".")) return 0; - const fraction = s.split(".")[1]; - return fraction ? fraction.length : 0; + if (!Number.isFinite(step)) return 0; + if (Number.isInteger(step)) return 0; + let decimals = 0; + let scaled = step; + while (decimals < MAX_SCALE_DECIMALS && Math.abs(Math.round(scaled) - scaled) > STEP_TOLERANCE) { + scaled *= 10; + decimals += 1; + } + return decimals; } export function isNearlyZero(value: number, epsilon = 1e-5): boolean { diff --git a/tests/utils/math.test.ts b/tests/utils/math.test.ts new file mode 100644 index 0000000..4763f85 --- /dev/null +++ b/tests/utils/math.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { roundQtyDownToStep, roundDownToTick, decimalsOf } from "../../src/utils/math"; + +describe("utils/math precision", () => { + it("keeps quantity intact when it matches the step exactly", () => { + expect(roundQtyDownToStep(0.01, 0.00001)).toBe(0.01); + expect(roundQtyDownToStep(0.01, 0.001)).toBe(0.01); + }); + + it("floors quantity to the nearest valid step without precision loss", () => { + expect(roundQtyDownToStep(1.23456789, 0.001)).toBe(1.234); + expect(roundQtyDownToStep(0.00009, 0.00005)).toBe(0.00005); + }); + + it("rounds prices down respecting tick size", () => { + expect(roundDownToTick(20345.123456, 0.001)).toBe(20345.123); + expect(roundDownToTick(1.00000009, 0.00001)).toBe(1); + }); + + it("detects decimal places for powers of ten", () => { + expect(decimalsOf(0.00000001)).toBe(8); + expect(decimalsOf(0.25)).toBe(2); + expect(decimalsOf(1)).toBe(0); + }); +});