diff --git a/.env.example b/.env.example index 62491de..66fb334 100644 --- a/.env.example +++ b/.env.example @@ -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%) 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) +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) PRICE_TICK=0.1 # Price tick size (e.g. BTCUSDT uses 0.1) diff --git a/README.md b/README.md index 92c7b3d..7db8fef 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Windows 使用 WSL(推荐): - `LOSS_LIMIT`:单笔允许的最大亏损(USDT),触发即强制平仓。 - `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE`:趋势策略的动态止盈触发值(单位 USDT)与回撤百分比(百分数,如 0.2 表示 0.2%)。 - `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)。 - `MAKER_*` 参数:做市策略追价阈值、报价偏移、刷新频率等,可按流动性需求调节。 6. **运行机器人** diff --git a/src/config.ts b/src/config.ts index a0e1a66..423cf70 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,9 @@ export interface TradingConfig { maxCloseSlippagePct: number; priceTick: number; // price tick size, e.g. 0.1 for BTCUSDT qtyStep: number; // quantity step size, e.g. 0.001 BTC + bollingerLength: number; + bollingerStdMultiplier: number; + minBollingerBandwidth: 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), priceTick: parseNumber(process.env.PRICE_TICK, 0.1), 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 { diff --git a/src/core/trend-engine.ts b/src/core/trend-engine.ts index 5cbaec7..aa14362 100644 --- a/src/core/trend-engine.ts +++ b/src/core/trend-engine.ts @@ -11,6 +11,7 @@ import type { import { calcStopLossPrice, calcTrailingActivationPrice, + computeBollingerBandwidth, getPosition, getSMA, type PositionSnapshot, @@ -37,6 +38,7 @@ export interface TrendEngineSnapshot { symbol: string; lastPrice: number | null; sma30: number | null; + bollingerBandwidth: number | null; trend: "做多" | "做空" | "无信号"; position: PositionSnapshot; pnl: number; @@ -78,6 +80,7 @@ export class TrendEngine { private processing = false; private lastPrice: number | null = null; private lastSma30: number | null = null; + private lastBollingerBandwidth: number | null = null; private totalProfit = 0; private totalTrades = 0; private lastOpenPlan: OpenOrderPlan = { side: null, price: null }; @@ -99,6 +102,7 @@ export class TrendEngine { private lastEntryMinute: number | null = null; // 止损后冷却:止损发生后的 60s 内忽略 SMA 入场信号 private lastStopLossAt: number | null = null; + private lastBollingerBlockLogged = 0; private ordersSnapshotReady = false; private startupLogged = false; @@ -243,11 +247,12 @@ export class TrendEngine { } private isReady(): boolean { + const minKlines = Math.max(30, this.config.bollingerLength); return Boolean( this.accountSnapshot && this.tickerSnapshot && this.depthSnapshot && - this.klineSnapshot.length >= 30 + this.klineSnapshot.length >= minKlines ); } @@ -277,13 +282,19 @@ export class TrendEngine { if (sma30 == null) { return; } + const bollingerBandwidth = computeBollingerBandwidth( + this.klineSnapshot, + this.config.bollingerLength, + this.config.bollingerStdMultiplier + ); + this.lastBollingerBandwidth = bollingerBandwidth; const ticker = this.tickerSnapshot!; const price = Number(ticker.lastPrice); const position = getPosition(this.accountSnapshot, this.config.symbol); if (Math.abs(position.positionAmt) < 1e-5) { if (!this.rateLimit.shouldBlockEntries()) { - await this.handleOpenPosition(price, sma30); + await this.handleOpenPosition(price, sma30, bollingerBandwidth); } } else { const result = await this.handlePositionManagement(position, price); @@ -345,7 +356,11 @@ export class TrendEngine { this.startupLogged = true; } - private async handleOpenPosition(currentPrice: number, currentSma: number): Promise { + private async handleOpenPosition( + currentPrice: number, + currentSma: number, + currentBandwidth: number | null + ): Promise { this.entryPricePendingLogged = false; const now = Date.now(); const currentMinute = Math.floor(now / 60_000); @@ -360,6 +375,20 @@ export class TrendEngine { this.tradeLog.push("info", "本分钟已入场,忽略新的 SMA 入场信号"); 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) { this.lastPrice = currentPrice; return; @@ -852,6 +881,7 @@ export class TrendEngine { symbol: this.config.symbol, lastPrice: price, sma30, + bollingerBandwidth: this.lastBollingerBandwidth, trend, position, pnl, diff --git a/src/utils/strategy.ts b/src/utils/strategy.ts index 8623bc3..77ea045 100644 --- a/src/utils/strategy.ts +++ b/src/utils/strategy.ts @@ -52,6 +52,37 @@ export function calcTrailingActivationPrice(entryPrice: number, qty: number, sid 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. * - For BUY: orderPrice must be <= markPrice * (1 + maxPct) diff --git a/tests/strategy-utils.test.ts b/tests/strategy-utils.test.ts index 7f94914..eda560b 100644 --- a/tests/strategy-utils.test.ts +++ b/tests/strategy-utils.test.ts @@ -1,5 +1,5 @@ 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"; 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)); 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); + }); });