Build a Real-Time Trading Bot in Delphi with sgcWebSockets + Binance

· Features

What We Are Building

By the end of this tutorial you will have a working Delphi VCL application that streams live trade and order book data from Binance, runs a simple breakout strategy, places real orders via the REST API, and enforces risk controls (max position size, daily loss limit, kill switch). The same plumbing works for any exchange supported by sgcWebSockets — Coinbase, Kraken, OKX, Bybit, Bitfinex — with only the credential and symbol mapping changing.

This is the same architecture used by our reference sgcTrader sample. If you want a bigger starting point with full UI, charting, and multi-exchange routing, grab that. The walk-through below shows what is happening under the hood in around 300 lines of code.

Two prerequisites before we start. First, get a Binance API key (Account > API Management). For development, generate a key that has only "Enable Reading" and "Enable Spot Trading", and whitelist your IP. Never put a withdrawal-enabled key in code. Second, do everything on Binance's testnet first (testnet.binance.vision). The endpoints, message formats, and signature algorithm are identical to production, but the funds are fake. We have lost real money to "I'm sure my strategy is correct" exactly the number of times we did not test on the testnet first.

Architecture in One Diagram

Three threads, two components, one risk gatekeeper:

Component Role Thread
TsgcWSAPI_Binance WebSocket stream: trades, depth, klines, user data I/O worker
TsgcHTTP_API_Binance REST: order placement, cancel, account snapshot Trader worker
Strategy queue Decoupling: market events → decisions → orders Strategy worker
Risk gate Block / shrink / allow each order Inline in trader

Step 1: Stream Market Data

Drop a TsgcWSAPI_Binance on the form. It already speaks the Binance combined stream protocol — you just subscribe to the channels you want.

uses
  sgcWSAPI_Binance;

procedure TForm1.FormCreate(Sender: TObject);
begin
  oBinance := TsgcWSAPI_Binance.Create(Self);
  oBinance.WatchDog.Enabled  := True;
  oBinance.WatchDog.Interval := 5;
  oBinance.HeartBeat.Enabled := True;

  oBinance.OnBinanceMessage := DoStream;
  oBinance.OnDisconnect     := DoDisconnect;

  // Subscribe to 1-minute klines and aggregated trades for BTCUSDT
  oBinance.Streams.Add('btcusdt@kline_1m');
  oBinance.Streams.Add('btcusdt@aggTrade');

  oBinance.Active := True;
end;

procedure TForm1.DoStream(Sender: TObject; const aStream, aData: string);
begin
  // Fire-and-forget: push to the strategy queue
  oStrategyQueue.Push(TMarketEvent.Create(aStream, aData));
end;

That is the entire market-data ingestion layer. Reconnect on drops, heartbeat to detect dead links, and a non-blocking push to the strategy queue.

One thing the component does that you would otherwise have to write yourself: the Binance combined stream URL is /stream?streams=name1/name2/name3, and if you want to add or remove streams without dropping the connection you have to send a JSON-RPC subscribe/unsubscribe message over the same socket. TsgcWSAPI_Binance exposes SubscribeStream and UnsubscribeStream methods that handle the JSON-RPC handshake for you. Useful when the user picks a new ticker in the UI — no reconnect, no lost messages.

Also: Binance imposes per-IP and per-stream limits. For full depth on every USDT pair you will hit the message-rate limit quickly. Subscribe only to what you actually need, and prefer aggregated streams (!miniTicker@arr) over per-symbol streams when you need a wide market view.

Step 2: A Minimal Strategy

The strategy runs on its own thread. It maintains a rolling window of the last N closes from the 1-minute kline stream and goes long when price breaks above a 20-period high. Pure illustration — please do not put this in front of real money.

procedure TStrategyThread.Execute;
var
  oEvent : TMarketEvent;
  oJSON  : TsgcJSONObject;
  vClose : Double;
  vHigh  : Double;
begin
  while not Terminated do
  begin
    if not oStrategyQueue.Pop(oEvent, 100) then Continue;
    try
      oJSON := TsgcJSONObject.Parse(oEvent.Data);
      try
        if oEvent.Stream.EndsWith('@kline_1m') then
        begin
          vClose := oJSON.O['k'].F['c'];
          FCloses.Append(vClose);
          if FCloses.Count >= 21 then
          begin
            vHigh := FCloses.Max(20);                  // prior 20-bar high
            if (FPosition = 0) and (vClose > vHigh) then
              oOrderQueue.Push(TIntent.New(siBuy, 'BTCUSDT', 0.001))
            else if (FPosition > 0) and (vClose < FCloses.MA(20)) then
              oOrderQueue.Push(TIntent.New(siSell, 'BTCUSDT', FPosition));
          end;
        end;
      finally
        oJSON.Free;
      end;
    finally
      oEvent.Free;
    end;
  end;
end;

Step 3: Risk Gate

Never let a strategy talk directly to the exchange. Funnel every intent through a gate that knows your limits.

function TRiskGate.Validate(const aIntent: TIntent;
  out aReason: string): Boolean;
begin
  Result := False;

  if FKillSwitch then
    Exit(False);

  if Abs(FDailyPnL) > FConfig.MaxDailyLoss then
  begin
    aReason := 'Daily loss limit hit';
    Exit;
  end;

  if (aIntent.Side = siBuy)
     and (FPositionUSD + aIntent.Qty * FLastPrice > FConfig.MaxPositionUSD) then
  begin
    aReason := 'Max position size';
    Exit;
  end;

  Result := True;
end;

Step 4: Place the Order via REST

The order worker pulls validated intents, signs the request, and posts to Binance.

procedure TTraderThread.Execute;
var
  oIntent  : TIntent;
  vReason  : string;
  oResponse: TsgcBinanceClass_Response_NewOrder;
begin
  while not Terminated do
  begin
    if not oOrderQueue.Pop(oIntent, 100) then Continue;
    try
      if not FRisk.Validate(oIntent, vReason) then
      begin
        Log(Format('REJECT %s %s qty=%.6f reason=%s',
          [SideName(oIntent.Side), oIntent.Symbol, oIntent.Qty, vReason]));
        Continue;
      end;

      oResponse := oHttp.NewOrder(
        oIntent.Symbol,
        IfThen(oIntent.Side = siBuy, 'BUY', 'SELL'),
        'MARKET',
        oIntent.Qty,
        0  // price ignored for MARKET
      );
      try
        Log(Format('FILL  %s qty=%.6f price=%.2f id=%d',
          [oIntent.Symbol, oResponse.ExecutedQty,
           oResponse.AvgPrice, oResponse.OrderId]));
        FRisk.OnFill(oIntent.Side, oResponse.ExecutedQty, oResponse.AvgPrice);
      finally
        oResponse.Free;
      end;
    finally
      oIntent.Free;
    end;
  end;
end;

Step 4b: REST Authentication and Signing

The TsgcHTTP_API_Binance component signs requests for you using the API secret. Behind the scenes it builds the canonical query string, computes an HMAC-SHA256 with your secret, and appends it as the signature parameter. You provide the key and secret once at startup.

oHttp := TsgcHTTP_API_Binance.Create(Self);
oHttp.BinanceOptions.ApiKey    := FConfig.ApiKey;
oHttp.BinanceOptions.ApiSecret := FConfig.ApiSecret;
oHttp.BinanceOptions.RecvWindow := 5000;   // ms tolerance for signed requests
// Test connectivity and confirm your IP whitelist
ShowMessage('Server time: ' + IntToStr(oHttp.GetServerTime));

If you want to run against the testnet, set BinanceOptions.Testnet := True — the component switches both the REST base URL and the WebSocket host automatically. Build and test against the testnet, flip a single flag, deploy to production. The Binance API documentation is otherwise identical between the two environments.

Step 5: User Data Stream

The same WebSocket component also subscribes to your private user data stream — account updates, order events, position changes. This is how you reconcile fills that happened outside your bot (manual cancel from the web UI, liquidation, etc.).

oBinance.AuthOptions.ApiKey    := FConfig.ApiKey;
oBinance.AuthOptions.ApiSecret := FConfig.ApiSecret;
oBinance.Streams.Add('!userData');

procedure TForm1.DoStream(Sender: TObject; const aStream, aData: string);
var
  oJSON: TsgcJSONObject;
begin
  if aStream = '!userData' then
  begin
    oJSON := TsgcJSONObject.Parse(aData);
    try
      if oJSON.S['e'] = 'executionReport' then
        FRisk.ReconcileExternalFill(oJSON);
    finally
      oJSON.Free;
    end;
  end;
end;

A Note on Backtesting

Nothing above answers "is this strategy actually profitable?" That is what backtesting is for — replaying the same strategy against historical data to estimate its forward performance. The architecture above makes this almost free: the strategy thread does not care whether market events come from a live WebSocket or a CSV reader. Build a synthetic event source that reads klines from disk and feeds them into the same queue, and your strategy code runs unchanged against years of historical data.

Two pitfalls to avoid. Look-ahead bias: do not let the strategy peek at any data point that would not have been available at the timestamp it is processing. And survivorship bias: train and test on the universe of symbols that existed at the time, not the curated list of "successful" symbols that survived to today. Both have killed more strategies in production than every coding bug combined.

Operational Checklist

Concern Where to handle it
Reconnect on Wi-Fi drop WatchDog.Enabled := True
Dead-link detection HeartBeat.Enabled := True
Time sync (Binance rejects skewed signatures) NTP on the OS, plus a daily call to the server time endpoint
Order idempotency Use newClientOrderId on every order
Rate limits Track headers; back off when within 90% of limit
Kill switch Single boolean, flipped from UI or a watchdog process
Audit log Every intent, every fill, every reject, append-only

Beyond Binance

Swap TsgcWSAPI_Binance for TsgcWSAPI_Coinbase, TsgcWSAPI_Kraken, or any of the other 20+ exchange components. The strategy, risk gate, and order worker do not change — only the credential setup and symbol naming. For a production-grade multi-exchange trader with charts, position management, and order routing UI out of the box, look at the sgcTrader sample.

Real multi-exchange systems add one more layer above what you saw here: a symbol normaliser. Binance calls it BTCUSDT, Coinbase calls it BTC-USD, Kraken calls it XBT/USD. Build an internal symbol model with a canonical name and per-exchange aliases, and translate at the API boundary. Five minutes of work upfront, infinite saved bugs downstream.

The other thing to add for multi-exchange operations is a clock-skew check on startup. Binance, Coinbase and the rest reject signed requests with a timestamp more than 1000 ms off from their server. NTP usually keeps you well within that, but a misconfigured VPS can drift seconds in an hour. Query the server time endpoint on startup, log the offset, refuse to trade if it is >500 ms.

Why Delphi for This?

"Why not write it in Python?" is the obvious question. Three answers from production. First, the JIT warm-up and the GIL make CPython a poor fit for low-latency event loops — the same strategy that hits 0.8 ms median latency in Delphi takes 6 ms in CPython without serious effort. Second, the deployment story is simpler: one signed exe vs a virtualenv with a hundred wheels, half of which require a C compiler at install time. Third, the existing back office is in Delphi. Reusing those classes — account ledger, P&L calculator, journal, audit log — in the new bot instead of re-implementing them in another language eliminates an entire category of reconciliation bugs.

For pure research and notebook-style backtests, Python wins easily — the ecosystem of pandas, statsmodels, vectorbt and friends is unmatched. The split that works for most shops: research in Python, production in Delphi. Export the strategy logic as a small state machine, port it once, run it on a battle-tested Delphi runtime. The two halves do not have to share a language to share results.

If you plan to run this 24/7 on a VPS, read Performance Tuning next. To avoid the most common pitfalls, see 10 Common Mistakes. And if you have not installed sgcWebSockets yet, the Getting Started hub gets you live in five minutes.

Disclaimer: the strategy in this post is for educational purposes. Trading cryptocurrency involves substantial risk. Do not deploy untested code with real capital.