更新 .env.example 和 README.md,添加新的交易配置项(如 PROFIT_LOCK_TRIGGER_USD、PRICE_TICK 和 QTY_STEP),并在代码中实现相应的逻辑以支持这些配置,提升交易策略的灵活性和可配置性。

This commit is contained in:
discountry
2025-09-24 20:07:21 +08:00
parent e087191fad
commit bbf8ecfead
8 changed files with 209 additions and 59 deletions
+31 -14
View File
@@ -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)
+20 -2
View File
@@ -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
+6
View File
@@ -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),
};
+3 -3
View File
@@ -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[] = [];
+3 -3
View File
@@ -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[] = [];
+27 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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 {