diff --git a/.env.example b/.env.example index 63c5af3..ef09008 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,33 @@ -# Aster API credentials (必填) -ASTER_API_KEY=your_api_key -ASTER_API_SECRET=your_api_secret +# Aster API credentials +ASTER_API_KEY= +ASTER_API_SECRET= -# 通用策略配置 -TRADE_SYMBOL=BTCUSDT -TRADE_AMOUNT=0.001 -LOSS_LIMIT=0.03 -TRAILING_PROFIT=0.2 -TRAILING_CALLBACK_RATE=0.2 +# Core trading symbol and sizing +TRADE_SYMBOL=BTCUSDT # Trading pair symbol +TRADE_AMOUNT=0.001 # Base order quantity (base asset, e.g. BTC) -# 做市策略可选参数 -MAKER_PRICE_CHASE=0.5 -MAKER_BID_OFFSET=0 -MAKER_ASK_OFFSET=0 -MAKER_REFRESH_INTERVAL_MS=1500 +# Risk management (USD amounts unless noted) +LOSS_LIMIT=0.03 # Max loss per trade in USDT before forced close +TRAILING_PROFIT=0.2 # Trailing stop activation profit (USDT) +TRAILING_CALLBACK_RATE=0.2 # Trailing callback percent (e.g. 0.2 => 0.2%) +PROFIT_LOCK_TRIGGER_USD=0.1 # Start moving base stop once unrealized PnL > this (USDT) +PROFIT_LOCK_OFFSET_USD=0.05 # Base stop offset from entry after trigger (USDT) + +# Precision (per-symbol exchange filters) +PRICE_TICK=0.1 # Price tick size (e.g. BTCUSDT uses 0.1) +QTY_STEP=0.001 # Quantity step size (e.g. BTC min step 0.001) + +# Engine cadence and UI +POLL_INTERVAL_MS=500 # Trend engine poll interval (ms) +MAX_LOG_ENTRIES=200 # Max log entries shown in dashboard +KLINE_INTERVAL=1m # Kline interval (e.g., 1m/3m/5m) +MAX_CLOSE_SLIPPAGE_PCT=0.05 # Max allowed deviation vs mark when closing (0.05 => 5%) + +# Maker-only settings +MAKER_LOSS_LIMIT=0.03 # Maker loss cap (USDT). Defaults to LOSS_LIMIT if unset +MAKER_PRICE_CHASE=0.3 # Price chase threshold (USDT) +MAKER_BID_OFFSET=0 # Bid quote offset from top bid (USDT) +MAKER_ASK_OFFSET=0 # Ask quote offset from top ask (USDT) +MAKER_REFRESH_INTERVAL_MS=1500 # Maker refresh cadence (ms) +MAKER_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Maker close slippage guard (fallbacks to MAX_CLOSE_SLIPPAGE_PCT) +MAKER_PRICE_TICK=0.1 # Maker price tick size (defaults to PRICE_TICK) \ No newline at end of file diff --git a/README.md b/README.md index 6fb236c..e919916 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ - `TRADE_SYMBOL`:策略运行的交易对(默认 `BTCUSDT`),需与 API 权限范围一致。 - `TRADE_AMOUNT`:单次下单数量(合约张数折算后单位为标的货币,例如 BTC)。 - `LOSS_LIMIT`:单笔允许的最大亏损(USDT),触发即强制平仓。 - - `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE`:趋势策略的动态止盈触发值与回撤百分比。 + - `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE`:趋势策略的动态止盈触发值(单位 USDT)与回撤百分比(百分数,如 0.2 表示 0.2%)。 + - `PROFIT_LOCK_TRIGGER_USD` / `PROFIT_LOCK_OFFSET_USD`:达到一定浮盈后,将基础止损上调(做多)或下调(做空)到开仓价的偏移量(单位 USDT)。 + - `PRICE_TICK` / `QTY_STEP`:交易对的最小价格变动单位与最小下单数量步长(例如 BTCUSDT 分别为 0.1 与 0.001)。 - `MAKER_*` 参数:做市策略追价阈值、报价偏移、刷新频率等,可按流动性需求调节。 6. **运行机器人** ```bash @@ -89,8 +91,24 @@ ASTER_API_SECRET=your_secret TRADE_SYMBOL=BTCUSDT # optional, defaults to BTCUSDT TRADE_AMOUNT=0.001 # position size used by both strategies LOSS_LIMIT=0.03 # per-trade USD loss cap +TRAILING_PROFIT=0.2 # trailing activation profit in USDT +TRAILING_CALLBACK_RATE=0.2 # trailing callback in percent, e.g. 0.2 => 0.2% +PROFIT_LOCK_TRIGGER_USD=0.1 # profit threshold to start moving base stop (USDT) +PROFIT_LOCK_OFFSET_USD=0.05 # base stop offset from entry after trigger (USDT) +PRICE_TICK=0.1 # price tick size; set per symbol +QTY_STEP=0.001 # quantity step size; set per symbol +``` +Additional maker-specific knobs (`MAKER_*`) live in `src/config.ts` and may be overridden via env vars: +```bash +# Maker-specific (units in USDT unless noted) +MAKER_LOSS_LIMIT=0.03 # override maker risk stop; defaults to LOSS_LIMIT +MAKER_PRICE_CHASE=0.3 # chase threshold +MAKER_BID_OFFSET=0 # bid offset from top bid (USDT) +MAKER_ASK_OFFSET=0 # ask offset from top ask (USDT) +MAKER_REFRESH_INTERVAL_MS=1500 # maker refresh cadence (ms) +MAKER_MAX_CLOSE_SLIPPAGE_PCT=0.05 # allowed deviation vs mark when closing +MAKER_PRICE_TICK=0.1 # maker tick size; defaults to PRICE_TICK ``` -Additional maker-specific knobs (`MAKER_*`) live in `src/config.ts` and may be overridden via env vars. ## Running the CLI ```bash diff --git a/src/config.ts b/src/config.ts index 8be3aeb..a0e1a66 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,8 @@ export interface TradingConfig { maxLogEntries: number; klineInterval: string; maxCloseSlippagePct: number; + priceTick: number; // price tick size, e.g. 0.1 for BTCUSDT + qtyStep: number; // quantity step size, e.g. 0.001 BTC } function parseNumber(value: string | undefined, fallback: number): number { @@ -30,6 +32,8 @@ export const tradingConfig: TradingConfig = { maxLogEntries: parseNumber(process.env.MAX_LOG_ENTRIES, 200), klineInterval: process.env.KLINE_INTERVAL ?? "1m", maxCloseSlippagePct: parseNumber(process.env.MAX_CLOSE_SLIPPAGE_PCT, 0.05), + priceTick: parseNumber(process.env.PRICE_TICK, 0.1), + qtyStep: parseNumber(process.env.QTY_STEP, 0.001), }; export interface MakerConfig { @@ -42,6 +46,7 @@ export interface MakerConfig { refreshIntervalMs: number; maxLogEntries: number; maxCloseSlippagePct: number; + priceTick: number; } export const makerConfig: MakerConfig = { @@ -57,4 +62,5 @@ export const makerConfig: MakerConfig = { process.env.MAKER_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT, 0.05 ), + priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1), }; diff --git a/src/core/maker-engine.ts b/src/core/maker-engine.ts index c1c5e6c..9f9e869 100644 --- a/src/core/maker-engine.ts +++ b/src/core/maker-engine.ts @@ -6,7 +6,7 @@ import type { AsterOrder, AsterTicker, } from "../exchanges/types"; -import { toPrice1Decimal } from "../utils/math"; +import { roundDownToTick } from "../utils/math"; import { createTradeLog, type TradeLogEntry } from "../state/trade-log"; import { isUnknownOrderError } from "../utils/errors"; import { getPosition, type PositionSnapshot } from "../utils/strategy"; @@ -232,8 +232,8 @@ export class MakerEngine { return; } - const bidPrice = toPrice1Decimal(topBid - this.config.bidOffset); - const askPrice = toPrice1Decimal(topAsk + this.config.askOffset); + const bidPrice = roundDownToTick(topBid - this.config.bidOffset, this.config.priceTick); + const askPrice = roundDownToTick(topAsk + this.config.askOffset, this.config.priceTick); const position = getPosition(this.accountSnapshot, this.config.symbol); const absPosition = Math.abs(position.positionAmt); const desired: DesiredOrder[] = []; diff --git a/src/core/offset-maker-engine.ts b/src/core/offset-maker-engine.ts index f0dc6f1..02e0850 100644 --- a/src/core/offset-maker-engine.ts +++ b/src/core/offset-maker-engine.ts @@ -6,7 +6,7 @@ import type { AsterOrder, AsterTicker, } from "../exchanges/types"; -import { toPrice1Decimal } from "../utils/math"; +import { roundDownToTick } from "../utils/math"; import { createTradeLog } from "../state/trade-log"; import { isUnknownOrderError } from "../utils/errors"; import { getPosition, type PositionSnapshot } from "../utils/strategy"; @@ -245,8 +245,8 @@ export class OffsetMakerEngine { return; } - const bidPrice = toPrice1Decimal(topBid! - this.config.bidOffset); - const askPrice = toPrice1Decimal(topAsk! + this.config.askOffset); + const bidPrice = roundDownToTick(topBid! - this.config.bidOffset, this.config.priceTick); + const askPrice = roundDownToTick(topAsk! + this.config.askOffset, this.config.priceTick); const absPosition = Math.abs(position.positionAmt); const desired: DesiredOrder[] = []; diff --git a/src/core/order-coordinator.ts b/src/core/order-coordinator.ts index 4ab13ed..2f9cda8 100644 --- a/src/core/order-coordinator.ts +++ b/src/core/order-coordinator.ts @@ -1,6 +1,6 @@ import type { ExchangeAdapter } from "../exchanges/adapter"; import type { AsterOrder, CreateOrderParams } from "../exchanges/types"; -import { toPrice1Decimal, toQty3Decimal } from "../utils/math"; +import { roundDownToTick, roundQtyDownToStep } from "../utils/math"; import { isUnknownOrderError } from "../utils/errors"; import { isOrderPriceAllowedByMark } from "../utils/strategy"; @@ -126,17 +126,20 @@ export async function placeOrder( amount: number, log: LogHandler, reduceOnly = false, - guard?: OrderGuardOptions + guard?: OrderGuardOptions, + opts?: { priceTick: number; qtyStep: number } ): Promise { const type = "LIMIT"; if (isOperating(locks, type)) return; if (!enforceMarkPriceGuard(side, price, guard, log, "限价单")) return; + const priceTick = opts?.priceTick ?? 0.1; + const qtyStep = opts?.qtyStep ?? 0.001; const params: CreateOrderParams = { symbol, side, type, - quantity: toQty3Decimal(amount), - price: toPrice1Decimal(price), + quantity: roundQtyDownToStep(amount, qtyStep), + price: roundDownToTick(price, priceTick), timeInForce: "GTX", }; if (reduceOnly) params.reduceOnly = "true"; @@ -168,16 +171,18 @@ export async function placeMarketOrder( amount: number, log: LogHandler, reduceOnly = false, - guard?: OrderGuardOptions + guard?: OrderGuardOptions, + opts?: { qtyStep: number } ): Promise { const type = "MARKET"; if (isOperating(locks, type)) return; if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return; + const qtyStep = opts?.qtyStep ?? 0.001; const params: CreateOrderParams = { symbol, side, type, - quantity: toQty3Decimal(amount), + quantity: roundQtyDownToStep(amount, qtyStep), }; if (reduceOnly) params.reduceOnly = "true"; await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); @@ -209,7 +214,8 @@ export async function placeStopLossOrder( quantity: number, lastPrice: number | null, log: LogHandler, - guard?: OrderGuardOptions + guard?: OrderGuardOptions, + opts?: { priceTick: number; qtyStep: number } ): Promise { const type = "STOP_MARKET"; if (isOperating(locks, type)) return; @@ -224,14 +230,16 @@ export async function placeStopLossOrder( return; } } + const priceTick = opts?.priceTick ?? 0.1; + const qtyStep = opts?.qtyStep ?? 0.001; const params: CreateOrderParams = { symbol, side, type, - stopPrice: toPrice1Decimal(stopPrice), + stopPrice: roundDownToTick(stopPrice, priceTick), closePosition: "true", timeInForce: "GTC", - quantity: toQty3Decimal(quantity), + quantity: roundQtyDownToStep(quantity, qtyStep), }; await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); lockOperating(locks, timers, pendings, type, log); @@ -262,18 +270,21 @@ export async function placeTrailingStopOrder( quantity: number, callbackRate: number, log: LogHandler, - guard?: OrderGuardOptions + guard?: OrderGuardOptions, + opts?: { priceTick: number; qtyStep: number } ): Promise { const type = "TRAILING_STOP_MARKET"; if (isOperating(locks, type)) return; if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return; + const priceTick = opts?.priceTick ?? 0.1; + const qtyStep = opts?.qtyStep ?? 0.001; const params: CreateOrderParams = { symbol, side, type, - quantity: toQty3Decimal(quantity), + quantity: roundQtyDownToStep(quantity, qtyStep), reduceOnly: "true", - activationPrice: toPrice1Decimal(activationPrice), + activationPrice: roundDownToTick(activationPrice, priceTick), callbackRate, timeInForce: "GTC", }; @@ -307,16 +318,18 @@ export async function marketClose( side: "BUY" | "SELL", quantity: number, log: LogHandler, - guard?: OrderGuardOptions + guard?: OrderGuardOptions, + opts?: { qtyStep: number } ): Promise { const type = "MARKET"; if (isOperating(locks, type)) return; if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return; + const qtyStep = opts?.qtyStep ?? 0.001; const params: CreateOrderParams = { symbol, side, type, - quantity: toQty3Decimal(quantity), + quantity: roundQtyDownToStep(quantity, qtyStep), reduceOnly: "true", }; await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); diff --git a/src/core/trend-engine.ts b/src/core/trend-engine.ts index 58f05a3..ef43b49 100644 --- a/src/core/trend-engine.ts +++ b/src/core/trend-engine.ts @@ -25,7 +25,7 @@ import { } from "./order-coordinator"; import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "./order-coordinator"; import { isUnknownOrderError } from "../utils/errors"; -import { toPrice1Decimal } from "../utils/math"; +import { roundDownToTick } from "../utils/math"; import { createTradeLog, type TradeLogEntry } from "../state/trade-log"; export interface TrendEngineSnapshot { @@ -335,7 +335,8 @@ export class TrendEngine { markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice, expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null, maxPct: this.config.maxCloseSlippagePct, - } + }, + { qtyStep: this.config.qtyStep } ); this.tradeLog.push("open", `${reason}: ${side} @ ${price}`); this.lastOpenPlan = { side, price }; @@ -387,32 +388,45 @@ export class TrendEngine { ); const profitLockStopPrice = direction === "long" - ? toPrice1Decimal( - position.entryPrice + this.config.profitLockOffsetUsd / Math.abs(position.positionAmt) + ? roundDownToTick( + position.entryPrice + this.config.profitLockOffsetUsd / Math.abs(position.positionAmt), + this.config.priceTick ) - : toPrice1Decimal( - position.entryPrice - this.config.profitLockOffsetUsd / Math.abs(position.positionAmt) + : roundDownToTick( + position.entryPrice - this.config.profitLockOffsetUsd / Math.abs(position.positionAmt), + this.config.priceTick ); if (pnl > this.config.profitLockTriggerUsd || position.unrealizedProfit > this.config.profitLockTriggerUsd) { - if (!currentStop) { - await this.tryPlaceStopLoss(stopSide, profitLockStopPrice, price); - } else { - const existingPrice = Number(currentStop.stopPrice); - if (Math.abs(existingPrice - profitLockStopPrice) > 0.01) { - await this.tryReplaceStop(stopSide, currentStop, profitLockStopPrice, price); + const tick = Math.max(1e-9, this.config.priceTick); + const profitLockValid = + (stopSide === "SELL" && profitLockStopPrice <= price - tick) || + (stopSide === "BUY" && profitLockStopPrice >= price + tick); + if (profitLockValid) { + if (!currentStop) { + await this.tryPlaceStopLoss(stopSide, profitLockStopPrice, price); + } else { + const existingRaw = Number(currentStop.stopPrice); + const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN; + const improves = + !Number.isFinite(existingPrice) || + (stopSide === "SELL" && profitLockStopPrice >= existingPrice + tick) || + (stopSide === "BUY" && profitLockStopPrice <= existingPrice - tick); + if (improves) { + await this.tryReplaceStop(stopSide, currentStop, profitLockStopPrice, price); + } } } } if (!currentStop) { - await this.tryPlaceStopLoss(stopSide, toPrice1Decimal(stopPrice), price); + await this.tryPlaceStopLoss(stopSide, roundDownToTick(stopPrice, this.config.priceTick), price); } if (!currentTrailing) { await this.tryPlaceTrailingStop( stopSide, - toPrice1Decimal(activationPrice), + roundDownToTick(activationPrice, this.config.priceTick), Math.abs(position.positionAmt) ); } @@ -479,7 +493,8 @@ export class TrendEngine { : this.depthSnapshot?.asks?.[0]?.[0] ) || null, maxPct: this.config.maxCloseSlippagePct, - } + }, + { qtyStep: this.config.qtyStep } ); this.tradeLog.push("close", `止损平仓: ${direction === "long" ? "SELL" : "BUY"}`); } catch (err) { @@ -518,7 +533,8 @@ export class TrendEngine { { markPrice: position.markPrice, maxPct: this.config.maxCloseSlippagePct, - } + }, + { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } ); } catch (err) { this.tradeLog.push("error", `挂止损单失败: ${String(err)}`); @@ -531,6 +547,15 @@ export class TrendEngine { nextStopPrice: number, lastPrice: number ): Promise { + // 预校验:SELL 止损价必须低于当前价;BUY 止损价必须高于当前价 + const invalidForSide = + (side === "SELL" && nextStopPrice >= lastPrice) || + (side === "BUY" && nextStopPrice <= lastPrice); + if (invalidForSide) { + // 目标止损价与当前价冲突时跳过移动,避免反复撤单/重下导致的循环 + return; + } + const existingStopPrice = Number(currentOrder.stopPrice); try { await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: currentOrder.orderId }); } catch (err) { @@ -542,8 +567,67 @@ export class TrendEngine { this.tradeLog.push("error", `取消原止损单失败: ${String(err)}`); } } - await this.tryPlaceStopLoss(side, nextStopPrice, lastPrice); - this.tradeLog.push("stop", `移动止损到 ${nextStopPrice}`); + // 仅在成功创建新止损单后记录“移动止损”日志 + try { + const position = getPosition(this.accountSnapshot, this.config.symbol); + const quantity = Math.abs(position.positionAmt) || this.config.tradeAmount; + const order = await placeStopLossOrder( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pending, + side, + nextStopPrice, + quantity, + lastPrice, + (type, detail) => this.tradeLog.push(type, detail), + { + markPrice: position.markPrice, + maxPct: this.config.maxCloseSlippagePct, + }, + { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } + ); + if (order) { + this.tradeLog.push("stop", `移动止损到 ${roundDownToTick(nextStopPrice, this.config.priceTick)}`); + } + } catch (err) { + this.tradeLog.push("error", `移动止损失败: ${String(err)}`); + // 回滚策略:尝试用原价恢复止损,以避免出现短时间内无止损保护 + try { + const position = getPosition(this.accountSnapshot, this.config.symbol); + const quantity = Math.abs(position.positionAmt) || this.config.tradeAmount; + const restoreInvalid = + (side === "SELL" && existingStopPrice >= lastPrice) || + (side === "BUY" && existingStopPrice <= lastPrice); + if (!restoreInvalid) { + const restored = await placeStopLossOrder( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pending, + side, + existingStopPrice, + quantity, + lastPrice, + (t, d) => this.tradeLog.push(t, d), + { + markPrice: position.markPrice, + maxPct: this.config.maxCloseSlippagePct, + }, + { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } + ); + if (restored) { + this.tradeLog.push("order", `恢复原止损 @ ${roundDownToTick(existingStopPrice, this.config.priceTick)}`); + } + } + } catch (recoverErr) { + this.tradeLog.push("error", `恢复原止损失败: ${String(recoverErr)}`); + } + } } private async tryPlaceTrailingStop( @@ -567,7 +651,8 @@ export class TrendEngine { { markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice, maxPct: this.config.maxCloseSlippagePct, - } + }, + { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } ); } catch (err) { this.tradeLog.push("error", `挂动态止盈失败: ${String(err)}`); diff --git a/src/utils/math.ts b/src/utils/math.ts index 0aa0d85..15f53e2 100644 --- a/src/utils/math.ts +++ b/src/utils/math.ts @@ -1,9 +1,20 @@ -export function toPrice1Decimal(price: number): number { - return Math.floor(price * 10) / 10; +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)))); } -export function toQty3Decimal(qty: number): number { - return Math.floor(qty * 1000) / 1000; +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)))); +} + +export function decimalsOf(step: number): number { + const s = step.toString(); + if (!s.includes(".")) return 0; + return s.split(".")[1].length; } export function isNearlyZero(value: number, epsilon = 1e-5): boolean {