mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
更新 .env.example 和 README.md,添加新的交易配置项(如 PROFIT_LOCK_TRIGGER_USD、PRICE_TICK 和 QTY_STEP),并在代码中实现相应的逻辑以支持这些配置,提升交易策略的灵活性和可配置性。
This commit is contained in:
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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<AsterOrder | undefined> {
|
||||
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<AsterOrder | undefined> {
|
||||
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<AsterOrder | undefined> {
|
||||
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<AsterOrder | undefined> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
+104
-19
@@ -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<void> {
|
||||
// 预校验: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)}`);
|
||||
|
||||
+15
-4
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user