← 返回部落格
·11 min 閱讀·系統架構

即時加密貨幣訊號系統架構設計:WebSocket、Ring Buffer 與策略引擎

分享設計一套即時加密貨幣技術指標訊號掃描系統的架構決策,涵蓋 Binance WebSocket 管理、記憶體內 K 線快取、策略即資料的規則引擎,以及 BullMQ 通知佇列。

WebSocket即時系統Node.js架構設計加密貨幣
GP Wang
GP Wang
GWP4 STUDIO 創辦人 · 軟體 / 資料工程師

前言

加密貨幣市場 24/7 不間斷運作,價格波動劇烈。對交易者來說,即時掌握技術指標的變化(RSI 超賣、MACD 交叉、放量突破等)是關鍵需求。但你不可能 24 小時盯著螢幕看——這就是自動化訊號系統的價值。

這篇文章記錄的是一套自架的加密貨幣訊號掃描系統的架構設計過程。它不是交易機器人(不執行下單),而是一個純粹的監控 + 通知系統:掃描數百個交易對的 K 線數據,當技術指標滿足使用者設定的條件時,透過 Email 發送告警。

重點不在「如何交易」,而在「如何設計一個能可靠處理大量即時數據的系統」。

核心挑戰

在深入架構之前,先理解這個問題的規模:

  • 300+ 交易對 × 5 個時間框架(1m, 5m, 15m, 1h, 4h)= 1,500 條數據流
  • 每秒都有 K 線數據更新
  • 每根 K 線收盤時需要計算多個技術指標
  • 多個使用者各有不同的策略配置
  • 系統必須 7×24 穩定運作

這不是一個「隔幾分鐘 call 一次 REST API」就能解決的問題。

WebSocket 連線管理

為什麼不用 REST API 輪詢?

Binance 的 REST API 有嚴格的速率限制:每分鐘 6,000 weight。如果用輪詢的方式取 K 線數據:

  • 300 對 × 5 時間框架 = 1,500 次請求/分鐘
  • 每次請求 weight = 2
  • 總 weight = 3,000/分鐘

光是 K 線數據就佔了一半的 rate limit,而且只能做到最快「每分鐘更新一次」。對 1m K 線來說,你可能錯過收盤瞬間的訊號。

WebSocket 解決了這兩個問題:推送模式、無 rate limit。

Combined Stream:一條連線 1,024 條流

一個常見的錯誤是每個 symbol 開一條 WebSocket。Binance 限制每 5 分鐘最多建立 300 條新連線,這種做法直接爆掉。

正確做法是使用 Combined Stream endpoint:

wss://stream.binance.com:9443/stream?streams=btcusdt@kline_1m/ethusdt@kline_5m/...

一條連線最多承載 1,024 條流。對我們的場景(300 對 × 5 時間框架 = 1,500 流),只需要 2 條連線就足夠。

只處理「收盤」K 線

Binance 的 kline WebSocket 會在 K 線形成過程中持續推送更新(isFinal: false)。如果你在未收盤的 K 線上計算指標,會產生 lookahead bias——一個「訊號」可能在 K 線收盤前觸發,收盤後卻消失了。

wsClient.on('formattedMessage', (data) => {
  if (isWsFormattedKline(data) && data.kline.isFinal) {
    // 只有已收盤的 K 線才進入處理流程
    this.emit('closedCandle', {
      symbol: data.symbol,
      interval: data.kline.interval,
      open: data.kline.open,
      high: data.kline.high,
      low: data.kline.low,
      close: data.kline.close,
      volume: data.kline.volume,
    });
  }
});

斷線重連與缺口填補

WebSocket 連線不是永遠穩定的。斷線後需要:

  1. 自動重連(SDK 內建)
  2. 重新訂閱所有 stream(SDK 內建)
  3. 填補斷線期間遺漏的 K 線(需要自己做)
this.wsClient.on('reconnected', () => {
  // 等待 2-3 秒讓 SDK 完成重新訂閱
  setTimeout(() => {
    this.emit('reconnected');
    // 只取 lastStoredOpenTime 之後的 K 線,不是整段重跑
  }, 3000);
});

缺口填補只取「缺少的那幾根」K 線,而不是重新 bootstrap 全部 100 根。這是為了避免在重連頻繁時燒掉 rate limit。

Ring Buffer:記憶體內 K 線快取

為什麼不從資料庫讀?

每根 K 線收盤時都需要最近 N 根 K 線來計算指標(RSI 需要 14 根、MACD 需要 33 根)。如果每次都查資料庫:

  • 300 對同時收盤 1m K 線 → 300 次 DB query 瞬間湧入
  • 加上 I/O 延遲 10-100ms
  • 在高頻場景下完全不可接受

解法:把熱數據放在記憶體中。

type CandleKey = `${string}:${string}`; // e.g., "BTCUSDT:1h"

class CandleStore {
  private readonly capacity = 100;
  private buffers = new Map<CandleKey, Candle[]>();

  add(symbol: string, interval: string, candle: Candle) {
    const key: CandleKey = `${symbol}:${interval}`;
    const buf = this.buffers.get(key) ?? [];
    buf.push(candle);
    if (buf.length > this.capacity) buf.shift();
    this.buffers.set(key, buf);
  }

  get(symbol: string, interval: string): Candle[] {
    return this.buffers.get(`${symbol}:${interval}`) ?? [];
  }
}

記憶體估算

100 symbols × 5 timeframes × 100 candles × ~100 bytes = ~50MB

完全可以接受。而且這個數據不需要持久化——冷啟動時從 REST API 重新載入即可。

冷啟動 Bootstrap

啟動時需要從 REST API 取得每個 symbol + timeframe 的歷史 K 線:

  • 100 symbols × 5 timeframes = 500 次 API call
  • 每次 weight = 2,總 weight = 1,000(在 6,000/min 限制內)
  • 用 semaphore 控制並發(10 個同時),避免瞬間觸發 rate limit

策略引擎:Strategy as Data

傳統做法的問題

// 不好:每種策略都是一段 hardcode 的邏輯
if (strategyType === 'rsi_oversold') {
  if (rsi < 30) sendAlert();
} else if (strategyType === 'macd_cross') {
  if (macd.histogram > 0 && prevHistogram < 0) sendAlert();
}
// 每新增一種策略就要改 code、重新部署

更好的做法:規則引擎

把策略定義存在資料庫中(JSONB),由一個通用的規則引擎解釋執行:

interface StrategyRule {
  indicator: 'RSI' | 'MACD' | 'VOLUME_SPIKE' | 'BREAKOUT';
  condition: 'ABOVE' | 'BELOW' | 'CROSSOVER' | 'CROSSUNDER';
  threshold?: number;
  timeframe: Timeframe;
}

// 資料庫中的策略記錄
{
  signalType: 'rsi_oversold',
  config: { period: 14, threshold: 30 },
  symbols: ['BTCUSDT', 'ETHUSDT'],
  timeframes: ['1h', '4h'],
  cooldownMinutes: 60
}

新增策略 = 新增一筆資料庫記錄,不需要部署程式碼。

指標計算:Streaming vs Batch

選擇 trading-signals 而非 technicalindicators 的原因:

  • trading-signals:Streaming API,每個指標維護內部狀態,一次餵入一根 K 線
  • technicalindicators:Batch API,每次需要傳入完整的 K 線陣列重新計算

在 WebSocket 推送模式下,Streaming API 更自然——每次 K 線收盤時只需 indicator.update(candle),而不是 calculate(allCandles)。CPU 使用量大幅減少。

告警去重:Cooldown 機制

RSI 在超賣區可能停留很久。如果 RSI 連續 20 根 1m K 線都低於 30,發 20 封 email 顯然不合理。

解法:用複合鍵做 cooldown:

const cooldownKey = `${userId}:${symbol}:${timeframe}:${signalType}`;
const lastFired = cooldownStore.get(cooldownKey);

if (lastFired && Date.now() - lastFired < cooldownMs) {
  return; // 冷卻期內,不重複發送
}

cooldownStore.set(cooldownKey, Date.now());
notificationQueue.add({ userId, symbol, signal });

複合鍵的設計讓每個使用者的每種訊號類型有獨立的 cooldown——你的 RSI alert cooldown 不會影響你的 MACD alert。

通知佇列:BullMQ

為什麼不直接 sendEmail() 就好?

  1. 峰值吸收:高波動市場可能瞬間觸發幾十個訊號,佇列可以平滑處理
  2. 故障容錯:SMTP 暫時失敗時,BullMQ 會自動重試(exponential backoff)
  3. 不阻塞主流程:訊號評估不需要等 email 發送完成
Signal Evaluator → BullMQ Queue → Email Worker → SMTP
                                              → alert_log (PostgreSQL)

資料庫設計

四張核心表:

-- 使用者
users (id, email, passwordHash, timestamps)

-- 策略(每個使用者可有多組)
strategies (id, userId, name, signalType, config JSONB,
            symbols JSONB, timeframes JSONB,
            cooldownMinutes, active, timestamps)

-- 訊號歷史(audit trail)
signal_history (id, strategyId, symbol, timeframe,
               signalType, indicatorValues JSONB,
               candleCloseTime, createdAt)

-- 通知紀錄
alert_log (id, userId, signalHistoryId, channel,
           recipient, subject, status, sentAt, errorMessage)

關鍵決策:

  • configsymbolstimeframes 用 JSONB 而非 relation table,簡化查詢
  • signal_historyindicatorValues 記錄觸發時的指標數值(如 {rsi: 28.5}),便於事後分析
  • alert_log.signalHistoryId 可為 null,因為 signal_history 可能被定期清理

架構總結

                    ┌─────────────────────────────┐
                    │  Binance Combined Stream WS  │
                    └──────────────┬──────────────┘
                                   │ closed candles only
                    ┌──────────────▼──────────────┐
                    │      Candle Store (RAM)       │
                    │     Ring Buffer per pair      │
                    └──────────────┬──────────────┘
                                   │ latest N candles
                    ┌──────────────▼──────────────┐
                    │     Indicator Calculator      │
                    │   (streaming, stateful)       │
                    └──────────────┬──────────────┘
                                   │ indicator values
                    ┌──────────────▼──────────────┐
                    │      Strategy Evaluator       │
                    │   (rules from PostgreSQL)     │
                    └──────────────┬──────────────┘
                                   │ triggered signals
                    ┌──────────────▼──────────────┐
                    │   Cooldown Check (Redis)      │
                    └──────────────┬──────────────┘
                                   │ deduplicated
                    ┌──────────────▼──────────────┐
                    │   BullMQ Notification Queue   │
                    └──────────────┬──────────────┘
                                   │
                    ┌──────────────▼──────────────┐
                    │     Email Worker + Log DB     │
                    └─────────────────────────────┘

整個 hot path(從 K 線收盤到訊號觸發)完全在記憶體中完成,不涉及任何資料庫讀取。資料庫只用於:

  • 讀取策略配置(啟動時載入 + 變更時重載)
  • 寫入 signal_history 和 alert_log(audit 用途)

這是效能的關鍵:幾百個交易對同時收盤 1m K 線時,系統不會因為 DB query storm 而卡住。

結語

設計即時系統最重要的原則是:把 I/O 從 hot path 移除。所有頻繁存取的數據放記憶體,所有可以延遲的操作放佇列,所有可以失敗重試的操作做成 idempotent。

這套架構的每一層都可以獨立擴展:WebSocket 連線可以分散到多個 process、Candle Store 可以用 Redis 共享、BullMQ worker 可以水平擴展。但在單機場景下,一台普通的 VPS 就能處理全市場的掃描。

關於作者

GP Wang
GP Wang

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。

了解更多 →