mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 17:28:08 +00:00
feat: 添加布林带宽度计算功能及相关配置项,优化趋势引擎以支持布林带宽度过滤
This commit is contained in:
@@ -15,6 +15,9 @@ TRAILING_PROFIT=0.2 # Trailing stop activation profit (USDT)
|
|||||||
TRAILING_CALLBACK_RATE=0.2 # Trailing callback percent (e.g. 0.2 => 0.2%)
|
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_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)
|
PROFIT_LOCK_OFFSET_USD=0.05 # Base stop offset from entry after trigger (USDT)
|
||||||
|
BOLLINGER_LENGTH=20 # SMA window (minutes) used for Bollinger bandwidth
|
||||||
|
BOLLINGER_STD_MULTIPLIER=2 # Standard deviation multiplier for Bollinger bands
|
||||||
|
MIN_BOLLINGER_BANDWIDTH=0.1 # Require bandwidth >= this ratio before new entries
|
||||||
|
|
||||||
# Precision (per-symbol exchange filters)
|
# Precision (per-symbol exchange filters)
|
||||||
PRICE_TICK=0.1 # Price tick size (e.g. BTCUSDT uses 0.1)
|
PRICE_TICK=0.1 # Price tick size (e.g. BTCUSDT uses 0.1)
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ Windows 使用 WSL(推荐):
|
|||||||
- `LOSS_LIMIT`:单笔允许的最大亏损(USDT),触发即强制平仓。
|
- `LOSS_LIMIT`:单笔允许的最大亏损(USDT),触发即强制平仓。
|
||||||
- `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE`:趋势策略的动态止盈触发值(单位 USDT)与回撤百分比(百分数,如 0.2 表示 0.2%)。
|
- `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE`:趋势策略的动态止盈触发值(单位 USDT)与回撤百分比(百分数,如 0.2 表示 0.2%)。
|
||||||
- `PROFIT_LOCK_TRIGGER_USD` / `PROFIT_LOCK_OFFSET_USD`:达到一定浮盈后,将基础止损上调(做多)或下调(做空)到开仓价的偏移量(单位 USDT)。
|
- `PROFIT_LOCK_TRIGGER_USD` / `PROFIT_LOCK_OFFSET_USD`:达到一定浮盈后,将基础止损上调(做多)或下调(做空)到开仓价的偏移量(单位 USDT)。
|
||||||
|
- `BOLLINGER_LENGTH` / `BOLLINGER_STD_MULTIPLIER` / `MIN_BOLLINGER_BANDWIDTH`:布林带宽度过滤参数,默认使用 20 根 1 分钟 K 线及 2 倍标准差,仅当带宽比例 ≥ 0.1 时允许入场。
|
||||||
- `PRICE_TICK` / `QTY_STEP`:交易对的最小价格变动单位与最小下单数量步长(例如 BTCUSDT 分别为 0.1 与 0.001)。
|
- `PRICE_TICK` / `QTY_STEP`:交易对的最小价格变动单位与最小下单数量步长(例如 BTCUSDT 分别为 0.1 与 0.001)。
|
||||||
- `MAKER_*` 参数:做市策略追价阈值、报价偏移、刷新频率等,可按流动性需求调节。
|
- `MAKER_*` 参数:做市策略追价阈值、报价偏移、刷新频率等,可按流动性需求调节。
|
||||||
6. **运行机器人**
|
6. **运行机器人**
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export interface TradingConfig {
|
|||||||
maxCloseSlippagePct: number;
|
maxCloseSlippagePct: number;
|
||||||
priceTick: number; // price tick size, e.g. 0.1 for BTCUSDT
|
priceTick: number; // price tick size, e.g. 0.1 for BTCUSDT
|
||||||
qtyStep: number; // quantity step size, e.g. 0.001 BTC
|
qtyStep: number; // quantity step size, e.g. 0.001 BTC
|
||||||
|
bollingerLength: number;
|
||||||
|
bollingerStdMultiplier: number;
|
||||||
|
minBollingerBandwidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseNumber(value: string | undefined, fallback: number): number {
|
function parseNumber(value: string | undefined, fallback: number): number {
|
||||||
@@ -34,6 +37,9 @@ export const tradingConfig: TradingConfig = {
|
|||||||
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),
|
priceTick: parseNumber(process.env.PRICE_TICK, 0.1),
|
||||||
qtyStep: parseNumber(process.env.QTY_STEP, 0.001),
|
qtyStep: parseNumber(process.env.QTY_STEP, 0.001),
|
||||||
|
bollingerLength: parseNumber(process.env.BOLLINGER_LENGTH, 20),
|
||||||
|
bollingerStdMultiplier: parseNumber(process.env.BOLLINGER_STD_MULTIPLIER, 2),
|
||||||
|
minBollingerBandwidth: parseNumber(process.env.MIN_BOLLINGER_BANDWIDTH, 0.1),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MakerConfig {
|
export interface MakerConfig {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
calcStopLossPrice,
|
calcStopLossPrice,
|
||||||
calcTrailingActivationPrice,
|
calcTrailingActivationPrice,
|
||||||
|
computeBollingerBandwidth,
|
||||||
getPosition,
|
getPosition,
|
||||||
getSMA,
|
getSMA,
|
||||||
type PositionSnapshot,
|
type PositionSnapshot,
|
||||||
@@ -37,6 +38,7 @@ export interface TrendEngineSnapshot {
|
|||||||
symbol: string;
|
symbol: string;
|
||||||
lastPrice: number | null;
|
lastPrice: number | null;
|
||||||
sma30: number | null;
|
sma30: number | null;
|
||||||
|
bollingerBandwidth: number | null;
|
||||||
trend: "做多" | "做空" | "无信号";
|
trend: "做多" | "做空" | "无信号";
|
||||||
position: PositionSnapshot;
|
position: PositionSnapshot;
|
||||||
pnl: number;
|
pnl: number;
|
||||||
@@ -78,6 +80,7 @@ export class TrendEngine {
|
|||||||
private processing = false;
|
private processing = false;
|
||||||
private lastPrice: number | null = null;
|
private lastPrice: number | null = null;
|
||||||
private lastSma30: number | null = null;
|
private lastSma30: number | null = null;
|
||||||
|
private lastBollingerBandwidth: number | null = null;
|
||||||
private totalProfit = 0;
|
private totalProfit = 0;
|
||||||
private totalTrades = 0;
|
private totalTrades = 0;
|
||||||
private lastOpenPlan: OpenOrderPlan = { side: null, price: null };
|
private lastOpenPlan: OpenOrderPlan = { side: null, price: null };
|
||||||
@@ -99,6 +102,7 @@ export class TrendEngine {
|
|||||||
private lastEntryMinute: number | null = null;
|
private lastEntryMinute: number | null = null;
|
||||||
// 止损后冷却:止损发生后的 60s 内忽略 SMA 入场信号
|
// 止损后冷却:止损发生后的 60s 内忽略 SMA 入场信号
|
||||||
private lastStopLossAt: number | null = null;
|
private lastStopLossAt: number | null = null;
|
||||||
|
private lastBollingerBlockLogged = 0;
|
||||||
|
|
||||||
private ordersSnapshotReady = false;
|
private ordersSnapshotReady = false;
|
||||||
private startupLogged = false;
|
private startupLogged = false;
|
||||||
@@ -243,11 +247,12 @@ export class TrendEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isReady(): boolean {
|
private isReady(): boolean {
|
||||||
|
const minKlines = Math.max(30, this.config.bollingerLength);
|
||||||
return Boolean(
|
return Boolean(
|
||||||
this.accountSnapshot &&
|
this.accountSnapshot &&
|
||||||
this.tickerSnapshot &&
|
this.tickerSnapshot &&
|
||||||
this.depthSnapshot &&
|
this.depthSnapshot &&
|
||||||
this.klineSnapshot.length >= 30
|
this.klineSnapshot.length >= minKlines
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,13 +282,19 @@ export class TrendEngine {
|
|||||||
if (sma30 == null) {
|
if (sma30 == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const bollingerBandwidth = computeBollingerBandwidth(
|
||||||
|
this.klineSnapshot,
|
||||||
|
this.config.bollingerLength,
|
||||||
|
this.config.bollingerStdMultiplier
|
||||||
|
);
|
||||||
|
this.lastBollingerBandwidth = bollingerBandwidth;
|
||||||
const ticker = this.tickerSnapshot!;
|
const ticker = this.tickerSnapshot!;
|
||||||
const price = Number(ticker.lastPrice);
|
const price = Number(ticker.lastPrice);
|
||||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||||
|
|
||||||
if (Math.abs(position.positionAmt) < 1e-5) {
|
if (Math.abs(position.positionAmt) < 1e-5) {
|
||||||
if (!this.rateLimit.shouldBlockEntries()) {
|
if (!this.rateLimit.shouldBlockEntries()) {
|
||||||
await this.handleOpenPosition(price, sma30);
|
await this.handleOpenPosition(price, sma30, bollingerBandwidth);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const result = await this.handlePositionManagement(position, price);
|
const result = await this.handlePositionManagement(position, price);
|
||||||
@@ -345,7 +356,11 @@ export class TrendEngine {
|
|||||||
this.startupLogged = true;
|
this.startupLogged = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleOpenPosition(currentPrice: number, currentSma: number): Promise<void> {
|
private async handleOpenPosition(
|
||||||
|
currentPrice: number,
|
||||||
|
currentSma: number,
|
||||||
|
currentBandwidth: number | null
|
||||||
|
): Promise<void> {
|
||||||
this.entryPricePendingLogged = false;
|
this.entryPricePendingLogged = false;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const currentMinute = Math.floor(now / 60_000);
|
const currentMinute = Math.floor(now / 60_000);
|
||||||
@@ -360,6 +375,20 @@ export class TrendEngine {
|
|||||||
this.tradeLog.push("info", "本分钟已入场,忽略新的 SMA 入场信号");
|
this.tradeLog.push("info", "本分钟已入场,忽略新的 SMA 入场信号");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
Number.isFinite(currentBandwidth) &&
|
||||||
|
this.config.minBollingerBandwidth > 0 &&
|
||||||
|
Number(currentBandwidth) < this.config.minBollingerBandwidth
|
||||||
|
) {
|
||||||
|
if (now - this.lastBollingerBlockLogged > 15_000) {
|
||||||
|
this.tradeLog.push(
|
||||||
|
"info",
|
||||||
|
`布林带宽度不足:${Number(currentBandwidth).toFixed(4)} < ${this.config.minBollingerBandwidth},忽略入场信号`
|
||||||
|
);
|
||||||
|
this.lastBollingerBlockLogged = now;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.lastPrice == null) {
|
if (this.lastPrice == null) {
|
||||||
this.lastPrice = currentPrice;
|
this.lastPrice = currentPrice;
|
||||||
return;
|
return;
|
||||||
@@ -852,6 +881,7 @@ export class TrendEngine {
|
|||||||
symbol: this.config.symbol,
|
symbol: this.config.symbol,
|
||||||
lastPrice: price,
|
lastPrice: price,
|
||||||
sma30,
|
sma30,
|
||||||
|
bollingerBandwidth: this.lastBollingerBandwidth,
|
||||||
trend,
|
trend,
|
||||||
position,
|
position,
|
||||||
pnl,
|
pnl,
|
||||||
|
|||||||
@@ -52,6 +52,37 @@ export function calcTrailingActivationPrice(entryPrice: number, qty: number, sid
|
|||||||
return entryPrice - profit / Math.abs(qty);
|
return entryPrice - profit / Math.abs(qty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function computeBollingerBandwidth(
|
||||||
|
values: AsterKline[],
|
||||||
|
length: number,
|
||||||
|
stdMultiplier: number
|
||||||
|
): number | null {
|
||||||
|
const period = Number.isInteger(length) ? Number(length) : 0;
|
||||||
|
const multiplier = Number.isFinite(stdMultiplier) ? stdMultiplier : 0;
|
||||||
|
if (!Array.isArray(values) || period <= 0 || values.length < period || multiplier <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const window = values.slice(-period);
|
||||||
|
const closes = window.map((kline) => Number(kline.close));
|
||||||
|
if (closes.some((close) => !Number.isFinite(close))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mean = closes.reduce((sum, price) => sum + price, 0) / period;
|
||||||
|
if (!Number.isFinite(mean) || mean <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const variance = closes.reduce((sum, price) => {
|
||||||
|
const diff = price - mean;
|
||||||
|
return sum + diff * diff;
|
||||||
|
}, 0) / period;
|
||||||
|
const std = Math.sqrt(Math.max(variance, 0));
|
||||||
|
const width = std * multiplier * 2;
|
||||||
|
if (!Number.isFinite(width)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return width / mean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return true if the intended order price is within the allowed deviation from mark price.
|
* Return true if the intended order price is within the allowed deviation from mark price.
|
||||||
* - For BUY: orderPrice must be <= markPrice * (1 + maxPct)
|
* - For BUY: orderPrice must be <= markPrice * (1 + maxPct)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { getPosition, getSMA } from "../src/utils/strategy";
|
import { computeBollingerBandwidth, getPosition, getSMA } from "../src/utils/strategy";
|
||||||
import type { AsterAccountSnapshot, AsterKline } from "../src/exchanges/types";
|
import type { AsterAccountSnapshot, AsterKline } from "../src/exchanges/types";
|
||||||
|
|
||||||
const mockSnapshot = (positions: Array<{ symbol: string; amt: number; entry: number; pnl: number }> = []): AsterAccountSnapshot => ({
|
const mockSnapshot = (positions: Array<{ symbol: string; amt: number; entry: number; pnl: number }> = []): AsterAccountSnapshot => ({
|
||||||
@@ -55,4 +55,17 @@ describe("strategy utils", () => {
|
|||||||
const data = mockKlines(Array.from({ length: 30 }, (_, i) => i + 1));
|
const data = mockKlines(Array.from({ length: 30 }, (_, i) => i + 1));
|
||||||
expect(getSMA(data, 30)).toBe(15.5);
|
expect(getSMA(data, 30)).toBe(15.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns null Bollinger bandwidth when data insufficient", () => {
|
||||||
|
const klines = mockKlines([100, 101, 102]);
|
||||||
|
expect(computeBollingerBandwidth(klines, 20, 2)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes Bollinger bandwidth ratio", () => {
|
||||||
|
const closes = [...Array(19).fill(100), 110];
|
||||||
|
const klines = mockKlines(closes);
|
||||||
|
const bandwidth = computeBollingerBandwidth(klines, 20, 2);
|
||||||
|
expect(bandwidth).not.toBeNull();
|
||||||
|
expect(bandwidth ?? 0).toBeCloseTo(0.0867443, 5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user