mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 01:08:07 +00:00
更新 .env.example 和 README.md,添加新的交易配置项(如 PROFIT_LOCK_TRIGGER_USD、PRICE_TICK 和 QTY_STEP),并在代码中实现相应的逻辑以支持这些配置,提升交易策略的灵活性和可配置性。
This commit is contained in:
+31
-14
@@ -1,16 +1,33 @@
|
|||||||
# Aster API credentials (必填)
|
# Aster API credentials
|
||||||
ASTER_API_KEY=your_api_key
|
ASTER_API_KEY=
|
||||||
ASTER_API_SECRET=your_api_secret
|
ASTER_API_SECRET=
|
||||||
|
|
||||||
# 通用策略配置
|
# Core trading symbol and sizing
|
||||||
TRADE_SYMBOL=BTCUSDT
|
TRADE_SYMBOL=BTCUSDT # Trading pair symbol
|
||||||
TRADE_AMOUNT=0.001
|
TRADE_AMOUNT=0.001 # Base order quantity (base asset, e.g. BTC)
|
||||||
LOSS_LIMIT=0.03
|
|
||||||
TRAILING_PROFIT=0.2
|
|
||||||
TRAILING_CALLBACK_RATE=0.2
|
|
||||||
|
|
||||||
# 做市策略可选参数
|
# Risk management (USD amounts unless noted)
|
||||||
MAKER_PRICE_CHASE=0.5
|
LOSS_LIMIT=0.03 # Max loss per trade in USDT before forced close
|
||||||
MAKER_BID_OFFSET=0
|
TRAILING_PROFIT=0.2 # Trailing stop activation profit (USDT)
|
||||||
MAKER_ASK_OFFSET=0
|
TRAILING_CALLBACK_RATE=0.2 # Trailing callback percent (e.g. 0.2 => 0.2%)
|
||||||
MAKER_REFRESH_INTERVAL_MS=1500
|
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)
|
||||||
@@ -53,7 +53,9 @@
|
|||||||
- `TRADE_SYMBOL`:策略运行的交易对(默认 `BTCUSDT`),需与 API 权限范围一致。
|
- `TRADE_SYMBOL`:策略运行的交易对(默认 `BTCUSDT`),需与 API 权限范围一致。
|
||||||
- `TRADE_AMOUNT`:单次下单数量(合约张数折算后单位为标的货币,例如 BTC)。
|
- `TRADE_AMOUNT`:单次下单数量(合约张数折算后单位为标的货币,例如 BTC)。
|
||||||
- `LOSS_LIMIT`:单笔允许的最大亏损(USDT),触发即强制平仓。
|
- `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_*` 参数:做市策略追价阈值、报价偏移、刷新频率等,可按流动性需求调节。
|
- `MAKER_*` 参数:做市策略追价阈值、报价偏移、刷新频率等,可按流动性需求调节。
|
||||||
6. **运行机器人**
|
6. **运行机器人**
|
||||||
```bash
|
```bash
|
||||||
@@ -89,8 +91,24 @@ ASTER_API_SECRET=your_secret
|
|||||||
TRADE_SYMBOL=BTCUSDT # optional, defaults to BTCUSDT
|
TRADE_SYMBOL=BTCUSDT # optional, defaults to BTCUSDT
|
||||||
TRADE_AMOUNT=0.001 # position size used by both strategies
|
TRADE_AMOUNT=0.001 # position size used by both strategies
|
||||||
LOSS_LIMIT=0.03 # per-trade USD loss cap
|
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
|
## Running the CLI
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface TradingConfig {
|
|||||||
maxLogEntries: number;
|
maxLogEntries: number;
|
||||||
klineInterval: string;
|
klineInterval: string;
|
||||||
maxCloseSlippagePct: number;
|
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 {
|
function parseNumber(value: string | undefined, fallback: number): number {
|
||||||
@@ -30,6 +32,8 @@ export const tradingConfig: TradingConfig = {
|
|||||||
maxLogEntries: parseNumber(process.env.MAX_LOG_ENTRIES, 200),
|
maxLogEntries: parseNumber(process.env.MAX_LOG_ENTRIES, 200),
|
||||||
klineInterval: process.env.KLINE_INTERVAL ?? "1m",
|
klineInterval: process.env.KLINE_INTERVAL ?? "1m",
|
||||||
maxCloseSlippagePct: parseNumber(process.env.MAX_CLOSE_SLIPPAGE_PCT, 0.05),
|
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 {
|
export interface MakerConfig {
|
||||||
@@ -42,6 +46,7 @@ export interface MakerConfig {
|
|||||||
refreshIntervalMs: number;
|
refreshIntervalMs: number;
|
||||||
maxLogEntries: number;
|
maxLogEntries: number;
|
||||||
maxCloseSlippagePct: number;
|
maxCloseSlippagePct: number;
|
||||||
|
priceTick: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makerConfig: MakerConfig = {
|
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,
|
process.env.MAKER_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT,
|
||||||
0.05
|
0.05
|
||||||
),
|
),
|
||||||
|
priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
AsterOrder,
|
AsterOrder,
|
||||||
AsterTicker,
|
AsterTicker,
|
||||||
} from "../exchanges/types";
|
} from "../exchanges/types";
|
||||||
import { toPrice1Decimal } from "../utils/math";
|
import { roundDownToTick } from "../utils/math";
|
||||||
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
||||||
import { isUnknownOrderError } from "../utils/errors";
|
import { isUnknownOrderError } from "../utils/errors";
|
||||||
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
||||||
@@ -232,8 +232,8 @@ export class MakerEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bidPrice = toPrice1Decimal(topBid - this.config.bidOffset);
|
const bidPrice = roundDownToTick(topBid - this.config.bidOffset, this.config.priceTick);
|
||||||
const askPrice = toPrice1Decimal(topAsk + this.config.askOffset);
|
const askPrice = roundDownToTick(topAsk + this.config.askOffset, this.config.priceTick);
|
||||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||||
const absPosition = Math.abs(position.positionAmt);
|
const absPosition = Math.abs(position.positionAmt);
|
||||||
const desired: DesiredOrder[] = [];
|
const desired: DesiredOrder[] = [];
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
AsterOrder,
|
AsterOrder,
|
||||||
AsterTicker,
|
AsterTicker,
|
||||||
} from "../exchanges/types";
|
} from "../exchanges/types";
|
||||||
import { toPrice1Decimal } from "../utils/math";
|
import { roundDownToTick } from "../utils/math";
|
||||||
import { createTradeLog } from "../state/trade-log";
|
import { createTradeLog } from "../state/trade-log";
|
||||||
import { isUnknownOrderError } from "../utils/errors";
|
import { isUnknownOrderError } from "../utils/errors";
|
||||||
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
||||||
@@ -245,8 +245,8 @@ export class OffsetMakerEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bidPrice = toPrice1Decimal(topBid! - this.config.bidOffset);
|
const bidPrice = roundDownToTick(topBid! - this.config.bidOffset, this.config.priceTick);
|
||||||
const askPrice = toPrice1Decimal(topAsk! + this.config.askOffset);
|
const askPrice = roundDownToTick(topAsk! + this.config.askOffset, this.config.priceTick);
|
||||||
const absPosition = Math.abs(position.positionAmt);
|
const absPosition = Math.abs(position.positionAmt);
|
||||||
const desired: DesiredOrder[] = [];
|
const desired: DesiredOrder[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||||
import type { AsterOrder, CreateOrderParams } from "../exchanges/types";
|
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 { isUnknownOrderError } from "../utils/errors";
|
||||||
import { isOrderPriceAllowedByMark } from "../utils/strategy";
|
import { isOrderPriceAllowedByMark } from "../utils/strategy";
|
||||||
|
|
||||||
@@ -126,17 +126,20 @@ export async function placeOrder(
|
|||||||
amount: number,
|
amount: number,
|
||||||
log: LogHandler,
|
log: LogHandler,
|
||||||
reduceOnly = false,
|
reduceOnly = false,
|
||||||
guard?: OrderGuardOptions
|
guard?: OrderGuardOptions,
|
||||||
|
opts?: { priceTick: number; qtyStep: number }
|
||||||
): Promise<AsterOrder | undefined> {
|
): Promise<AsterOrder | undefined> {
|
||||||
const type = "LIMIT";
|
const type = "LIMIT";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
if (!enforceMarkPriceGuard(side, price, guard, log, "限价单")) return;
|
if (!enforceMarkPriceGuard(side, price, guard, log, "限价单")) return;
|
||||||
|
const priceTick = opts?.priceTick ?? 0.1;
|
||||||
|
const qtyStep = opts?.qtyStep ?? 0.001;
|
||||||
const params: CreateOrderParams = {
|
const params: CreateOrderParams = {
|
||||||
symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
type,
|
type,
|
||||||
quantity: toQty3Decimal(amount),
|
quantity: roundQtyDownToStep(amount, qtyStep),
|
||||||
price: toPrice1Decimal(price),
|
price: roundDownToTick(price, priceTick),
|
||||||
timeInForce: "GTX",
|
timeInForce: "GTX",
|
||||||
};
|
};
|
||||||
if (reduceOnly) params.reduceOnly = "true";
|
if (reduceOnly) params.reduceOnly = "true";
|
||||||
@@ -168,16 +171,18 @@ export async function placeMarketOrder(
|
|||||||
amount: number,
|
amount: number,
|
||||||
log: LogHandler,
|
log: LogHandler,
|
||||||
reduceOnly = false,
|
reduceOnly = false,
|
||||||
guard?: OrderGuardOptions
|
guard?: OrderGuardOptions,
|
||||||
|
opts?: { qtyStep: number }
|
||||||
): Promise<AsterOrder | undefined> {
|
): Promise<AsterOrder | undefined> {
|
||||||
const type = "MARKET";
|
const type = "MARKET";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return;
|
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return;
|
||||||
|
const qtyStep = opts?.qtyStep ?? 0.001;
|
||||||
const params: CreateOrderParams = {
|
const params: CreateOrderParams = {
|
||||||
symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
type,
|
type,
|
||||||
quantity: toQty3Decimal(amount),
|
quantity: roundQtyDownToStep(amount, qtyStep),
|
||||||
};
|
};
|
||||||
if (reduceOnly) params.reduceOnly = "true";
|
if (reduceOnly) params.reduceOnly = "true";
|
||||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||||
@@ -209,7 +214,8 @@ export async function placeStopLossOrder(
|
|||||||
quantity: number,
|
quantity: number,
|
||||||
lastPrice: number | null,
|
lastPrice: number | null,
|
||||||
log: LogHandler,
|
log: LogHandler,
|
||||||
guard?: OrderGuardOptions
|
guard?: OrderGuardOptions,
|
||||||
|
opts?: { priceTick: number; qtyStep: number }
|
||||||
): Promise<AsterOrder | undefined> {
|
): Promise<AsterOrder | undefined> {
|
||||||
const type = "STOP_MARKET";
|
const type = "STOP_MARKET";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
@@ -224,14 +230,16 @@ export async function placeStopLossOrder(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const priceTick = opts?.priceTick ?? 0.1;
|
||||||
|
const qtyStep = opts?.qtyStep ?? 0.001;
|
||||||
const params: CreateOrderParams = {
|
const params: CreateOrderParams = {
|
||||||
symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
type,
|
type,
|
||||||
stopPrice: toPrice1Decimal(stopPrice),
|
stopPrice: roundDownToTick(stopPrice, priceTick),
|
||||||
closePosition: "true",
|
closePosition: "true",
|
||||||
timeInForce: "GTC",
|
timeInForce: "GTC",
|
||||||
quantity: toQty3Decimal(quantity),
|
quantity: roundQtyDownToStep(quantity, qtyStep),
|
||||||
};
|
};
|
||||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
@@ -262,18 +270,21 @@ export async function placeTrailingStopOrder(
|
|||||||
quantity: number,
|
quantity: number,
|
||||||
callbackRate: number,
|
callbackRate: number,
|
||||||
log: LogHandler,
|
log: LogHandler,
|
||||||
guard?: OrderGuardOptions
|
guard?: OrderGuardOptions,
|
||||||
|
opts?: { priceTick: number; qtyStep: number }
|
||||||
): Promise<AsterOrder | undefined> {
|
): Promise<AsterOrder | undefined> {
|
||||||
const type = "TRAILING_STOP_MARKET";
|
const type = "TRAILING_STOP_MARKET";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return;
|
if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return;
|
||||||
|
const priceTick = opts?.priceTick ?? 0.1;
|
||||||
|
const qtyStep = opts?.qtyStep ?? 0.001;
|
||||||
const params: CreateOrderParams = {
|
const params: CreateOrderParams = {
|
||||||
symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
type,
|
type,
|
||||||
quantity: toQty3Decimal(quantity),
|
quantity: roundQtyDownToStep(quantity, qtyStep),
|
||||||
reduceOnly: "true",
|
reduceOnly: "true",
|
||||||
activationPrice: toPrice1Decimal(activationPrice),
|
activationPrice: roundDownToTick(activationPrice, priceTick),
|
||||||
callbackRate,
|
callbackRate,
|
||||||
timeInForce: "GTC",
|
timeInForce: "GTC",
|
||||||
};
|
};
|
||||||
@@ -307,16 +318,18 @@ export async function marketClose(
|
|||||||
side: "BUY" | "SELL",
|
side: "BUY" | "SELL",
|
||||||
quantity: number,
|
quantity: number,
|
||||||
log: LogHandler,
|
log: LogHandler,
|
||||||
guard?: OrderGuardOptions
|
guard?: OrderGuardOptions,
|
||||||
|
opts?: { qtyStep: number }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const type = "MARKET";
|
const type = "MARKET";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return;
|
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return;
|
||||||
|
const qtyStep = opts?.qtyStep ?? 0.001;
|
||||||
const params: CreateOrderParams = {
|
const params: CreateOrderParams = {
|
||||||
symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
type,
|
type,
|
||||||
quantity: toQty3Decimal(quantity),
|
quantity: roundQtyDownToStep(quantity, qtyStep),
|
||||||
reduceOnly: "true",
|
reduceOnly: "true",
|
||||||
};
|
};
|
||||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||||
|
|||||||
+104
-19
@@ -25,7 +25,7 @@ import {
|
|||||||
} from "./order-coordinator";
|
} from "./order-coordinator";
|
||||||
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "./order-coordinator";
|
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "./order-coordinator";
|
||||||
import { isUnknownOrderError } from "../utils/errors";
|
import { isUnknownOrderError } from "../utils/errors";
|
||||||
import { toPrice1Decimal } from "../utils/math";
|
import { roundDownToTick } from "../utils/math";
|
||||||
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
||||||
|
|
||||||
export interface TrendEngineSnapshot {
|
export interface TrendEngineSnapshot {
|
||||||
@@ -335,7 +335,8 @@ export class TrendEngine {
|
|||||||
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
||||||
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
|
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
}
|
},
|
||||||
|
{ qtyStep: this.config.qtyStep }
|
||||||
);
|
);
|
||||||
this.tradeLog.push("open", `${reason}: ${side} @ ${price}`);
|
this.tradeLog.push("open", `${reason}: ${side} @ ${price}`);
|
||||||
this.lastOpenPlan = { side, price };
|
this.lastOpenPlan = { side, price };
|
||||||
@@ -387,32 +388,45 @@ export class TrendEngine {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const profitLockStopPrice = direction === "long"
|
const profitLockStopPrice = direction === "long"
|
||||||
? toPrice1Decimal(
|
? roundDownToTick(
|
||||||
position.entryPrice + this.config.profitLockOffsetUsd / Math.abs(position.positionAmt)
|
position.entryPrice + this.config.profitLockOffsetUsd / Math.abs(position.positionAmt),
|
||||||
|
this.config.priceTick
|
||||||
)
|
)
|
||||||
: toPrice1Decimal(
|
: roundDownToTick(
|
||||||
position.entryPrice - this.config.profitLockOffsetUsd / Math.abs(position.positionAmt)
|
position.entryPrice - this.config.profitLockOffsetUsd / Math.abs(position.positionAmt),
|
||||||
|
this.config.priceTick
|
||||||
);
|
);
|
||||||
|
|
||||||
if (pnl > this.config.profitLockTriggerUsd || position.unrealizedProfit > this.config.profitLockTriggerUsd) {
|
if (pnl > this.config.profitLockTriggerUsd || position.unrealizedProfit > this.config.profitLockTriggerUsd) {
|
||||||
if (!currentStop) {
|
const tick = Math.max(1e-9, this.config.priceTick);
|
||||||
await this.tryPlaceStopLoss(stopSide, profitLockStopPrice, price);
|
const profitLockValid =
|
||||||
} else {
|
(stopSide === "SELL" && profitLockStopPrice <= price - tick) ||
|
||||||
const existingPrice = Number(currentStop.stopPrice);
|
(stopSide === "BUY" && profitLockStopPrice >= price + tick);
|
||||||
if (Math.abs(existingPrice - profitLockStopPrice) > 0.01) {
|
if (profitLockValid) {
|
||||||
await this.tryReplaceStop(stopSide, currentStop, profitLockStopPrice, price);
|
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) {
|
if (!currentStop) {
|
||||||
await this.tryPlaceStopLoss(stopSide, toPrice1Decimal(stopPrice), price);
|
await this.tryPlaceStopLoss(stopSide, roundDownToTick(stopPrice, this.config.priceTick), price);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentTrailing) {
|
if (!currentTrailing) {
|
||||||
await this.tryPlaceTrailingStop(
|
await this.tryPlaceTrailingStop(
|
||||||
stopSide,
|
stopSide,
|
||||||
toPrice1Decimal(activationPrice),
|
roundDownToTick(activationPrice, this.config.priceTick),
|
||||||
Math.abs(position.positionAmt)
|
Math.abs(position.positionAmt)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -479,7 +493,8 @@ export class TrendEngine {
|
|||||||
: this.depthSnapshot?.asks?.[0]?.[0]
|
: this.depthSnapshot?.asks?.[0]?.[0]
|
||||||
) || null,
|
) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
}
|
},
|
||||||
|
{ qtyStep: this.config.qtyStep }
|
||||||
);
|
);
|
||||||
this.tradeLog.push("close", `止损平仓: ${direction === "long" ? "SELL" : "BUY"}`);
|
this.tradeLog.push("close", `止损平仓: ${direction === "long" ? "SELL" : "BUY"}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -518,7 +533,8 @@ export class TrendEngine {
|
|||||||
{
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
}
|
},
|
||||||
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.tradeLog.push("error", `挂止损单失败: ${String(err)}`);
|
this.tradeLog.push("error", `挂止损单失败: ${String(err)}`);
|
||||||
@@ -531,6 +547,15 @@ export class TrendEngine {
|
|||||||
nextStopPrice: number,
|
nextStopPrice: number,
|
||||||
lastPrice: number
|
lastPrice: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// 预校验:SELL 止损价必须低于当前价;BUY 止损价必须高于当前价
|
||||||
|
const invalidForSide =
|
||||||
|
(side === "SELL" && nextStopPrice >= lastPrice) ||
|
||||||
|
(side === "BUY" && nextStopPrice <= lastPrice);
|
||||||
|
if (invalidForSide) {
|
||||||
|
// 目标止损价与当前价冲突时跳过移动,避免反复撤单/重下导致的循环
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existingStopPrice = Number(currentOrder.stopPrice);
|
||||||
try {
|
try {
|
||||||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: currentOrder.orderId });
|
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: currentOrder.orderId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -542,8 +567,67 @@ export class TrendEngine {
|
|||||||
this.tradeLog.push("error", `取消原止损单失败: ${String(err)}`);
|
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(
|
private async tryPlaceTrailingStop(
|
||||||
@@ -567,7 +651,8 @@ export class TrendEngine {
|
|||||||
{
|
{
|
||||||
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
}
|
},
|
||||||
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.tradeLog.push("error", `挂动态止盈失败: ${String(err)}`);
|
this.tradeLog.push("error", `挂动态止盈失败: ${String(err)}`);
|
||||||
|
|||||||
+15
-4
@@ -1,9 +1,20 @@
|
|||||||
export function toPrice1Decimal(price: number): number {
|
export function roundDownToTick(value: number, tick: number): number {
|
||||||
return Math.floor(price * 10) / 10;
|
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 {
|
export function roundQtyDownToStep(value: number, step: number): number {
|
||||||
return Math.floor(qty * 1000) / 1000;
|
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 {
|
export function isNearlyZero(value: number, epsilon = 1e-5): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user