Back to Tutorials
Real-time Forex and Stocks CFDs with Python WebSocket (V2)

Real-time Forex and Stocks CFDs with Python WebSocket (V2)

In this tutorial, you will build a high-performance Python WebSocket client using asyncio and the modern websockets library to stream real-time market data from the WebSocket V2 feed.

We will break the implementation down into six steps:

  • 1. Connection & Authentication: Establishing the secure handshake.
  • 2. Message Routing: Understanding server response types.
  • 3. Subscriptions: Requesting live feeds for specific tickers.
  • 4. Data Parsing: Unpacking raw ticks and market depth.
  • 5. Stream Management: Dynamically unsubscribing or swapping symbols.
  • 6. Error Handling: Hardening your client against disconnects and malformed data.

1. Connect and send the login frame

Start by opening a secure WebSocket connection using the wss:// URL. Once connected, the client sends a login request with your streaming key. The server then authenticates your API key.

After the server returns login_ok, the client is authorised to request live prices, and you can then send the symbols you want to subscribe to.

async with websockets.connect("wss://stream.tradermade.com/feedAdv") as ws:

    login = {
        "action": "login",
        "key":    API_KEY,   # from environment variable
        "fmt":   "JSON",
    }
    await ws.send(json.dumps(login))

Note: API_KEY must be your streaming WebSocket key. If you don't have an API key, you can request a 1-week trial by signing up at TraderMade and copying it from your dashboard.

By default, ladder data is not returned. If you need market depth in the tick response, add "send_ladder": true to the login request. When enabled and supported on your account, live QUOTE messages can include a ladder object with bid and ask levels.

login = {
    "action": "login",
    "key":    API_KEY,
    "fmt":    "JSON",
    "send_ladder": True,  # optional
}

2. Server message types

Server responses are returned as JSON. Most messages include a type field, such as login_ok, sub_ack, or logout. Price ticks are different because they use the t field instead, with values such as QUOTE or LAST_QUOTE.

type Meaning
login_ok Login accepted. Includes details such as symbol_limit, cfds, and trader_ladder. Subscribe after this response.
login_reject Login failed because of an API key or account issue. Read the reason field and close the connection.
sub_ack Subscription confirmed. Lists accepted, denied, denied_reasons, and invalid symbols.
unsub_ack Unsubscribe confirmed. Lists removed and invalid symbols.
logout Server is closing the session. Read the reason field and close your client cleanly.
QUOTE / LAST_QUOTE Price tick, identified by the t field instead of type.

For the full message format and available actions, see the WebSocket V2 documentation.

3. Subscribe to symbols

Send the subscribe action inside the login_ok handler. Each symbol is the instrument code followed by :QUOTE.

async def handle_message(ws, msg):
    msg_type = msg.get("type", "")
    tick     = msg.get("t", "")

    if msg_type == "login_ok":
        sub = {
            "action":    "subscribe",
            "symbols":   ["EURUSD:QUOTE", "GBPUSD:QUOTE"],
        }
        await ws.send(json.dumps(sub))

send_last: True sends one cached tick for each symbol immediately after subscribing. This tick has t set to LAST_QUOTE, so the client receives the latest available price before live QUOTE updates begin.

if msg_type == "login_ok":
    sub = {
        "action":    "subscribe",
        "symbols":   ["EURUSD:QUOTE", "GBPUSD:QUOTE"],
        "send_last": True,  # Send cached tick immediately
    }
    await ws.send(json.dumps(sub))

Symbol naming

Instrument Format Example
FX pair XXXYYY:QUOTE EURUSD:QUOTE
Crypto XXXUSD:QUOTE BTCUSD:QUOTE
CFD / stock TICKERUSD:QUOTE AAPLUSD:QUOTE
Gold XAUUSD:QUOTE XAUUSD:QUOTE

Handle the confirmation:

    elif msg_type == "sub_ack":
        print("Subscribed:", msg.get("accepted", []))
        if msg.get("denied"):
            print("  denied:", msg["denied"], msg.get("denied_reasons", {}))
        if msg.get("invalid"):
            print("  invalid symbols:", msg["invalid"])

4. Parse a price tick

Ticks are identified by the t field, not type. Use .get() throughout - Tick shape varies between QUOTE and LAST_QUOTE, so direct key access crashes the script on the first unexpected message.

    elif tick in ("QUOTE", "LAST_QUOTE"):
        s   = msg.get("s",  "")   # symbol
        bid = msg.get("b",  "")   # bid price
        ask = msg.get("a",  "")   # ask price
        bv  = msg.get("bv", "")   # bid volume  — live QUOTEs only
        av  = msg.get("av", "")   # ask volume  — live QUOTEs only
        mid = msg.get("m",  "")   # mid price   — LAST_QUOTE; also live when depth is on
        ts  = msg.get("ts", "")   # timestamp: YYYYMMDD-HH:MM:SS.mmm

        line = f"{s}  bid={bid}  ask={ask}"
        if bv:
            line += f"  bv={bv}  av={av}"
        if mid:
            line += f"  mid={mid}"
        line += f"  ts={ts}"
        print(line)

Field reference

Field Present on Description
s All ticks Symbol name
b All ticks Bid price
a All ticks Ask price
bv / av QUOTE only Bid and ask volume
m LAST_QUOTE; QUOTE when depth on Mid price
t All ticks Tick type: QUOTE or LAST_QUOTE
ts All ticks Server timestamp: YYYYMMDD-HH:MM:SS.mmm

Example output

Logged in. symbol_limit=54  cfds=True  ladder=False
Subscribed: ['EURUSD:QUOTE', 'GBPUSD:QUOTE']
EURUSD  bid=1.16270  ask=1.16272  mid=1.16271  ts=20260522-17:30:12.500   <- LAST_QUOTE
GBPUSD  bid=1.33495  ask=1.33499  mid=1.33497  ts=20260522-17:30:12.512   <- LAST_QUOTE
EURUSD  bid=1.16270  ask=1.16272  bv=100000  av=100000  ts=20260522-17:30:12.842
GBPUSD  bid=1.33492  ask=1.33498  bv=100000  av=100000  ts=20260522-17:30:13.261

The first pair are LAST_QUOTE ticks (note the mid, no volumes). Everything after is live QUOTE data with bid/ask volumes.


5. Unsubscribe from symbols

To stop streaming a symbol, you do not need to reconnect. Send an unsubscribe request on the same open WebSocket connection. The server will stop sending ticks for that symbol and confirm the change with an unsub_ack response.

# drop the symbols that you added while the feed keeps running
await ws.send(json.dumps({
    "action":  "unsubscribe",
    "symbols": ["GBPUSD:QUOTE"],
}))

Swapping symbols mid-session

To replace one symbol with another without reconnecting, send both actions on the same connection:

# swap GBPUSD out for USDJPY
await ws.send(json.dumps({"action": "unsubscribe", "symbols": ["GBPUSD:QUOTE"]}))
await ws.send(json.dumps({"action": "subscribe",   "symbols": ["USDJPY:QUOTE"]}))

Both acks arrive separately — unsub_ack for the removal and sub_ack for the addition. Ticks for GBPUSD stop immediately; ticks for USDJPY start as soon as the subscription is confirmed.


6. Error handling

Three things can go wrong at different layers. Handle all three or the client will stop working without any obvious explanation.

Malformed JSON

Wrap every parse in try/except, log, and continue:

async for raw in ws:
    try:
        msg = json.loads(raw)
    except json.JSONDecodeError as e:
        print("Bad JSON:", e)
        continue          # don't crash — keep reading
    await handle_message(ws, msg)

Server-level errors

    elif msg_type == "error":
        print("Server error:", msg.get("reason", ""))
        # connection stays open; no need to close

    elif msg_type == "login_reject":
        print("Login rejected:", msg.get("reason", ""))
        await ws.close()

    elif msg_type == "logout":
        print("Logged out:", msg.get("reason", ""))
        await ws.close()  # server closed the session — exit cleanly

logout with reason duplicate_login means the same API key opened a second connection elsewhere and the server chose to close this one. It is the most common silent failure beginners miss.

Denied and invalid symbols

A typo in a symbol name returns it in the invalid array with no error message and no tick — logging it is the only way to know. Consolidate the check across both ack types:

    elif msg_type in ("sub_ack", "unsub_ack"):
        key = "accepted" if msg_type == "sub_ack" else "removed"
        print(f"Confirmed: {msg.get(key, [])}")
        if msg.get("denied"):
            print("  denied:", msg["denied"], msg.get("denied_reasons", {}))
        if msg.get("invalid"):
            print("  invalid:", msg["invalid"])

Complete script

Running the script

Linux / macOS:

export TRADERMADE_API_KEY=your_streaming_key
python stream.py

Windows (PowerShell):

$env:TRADERMADE_API_KEY = "your_streaming_key"
python stream.py

Full Code

import asyncio
import json
import os
import websockets

URL     = "wss://stream.tradermade.com/feedAdv"
SYMBOLS = ["EURUSD:QUOTE", "GBPUSD:QUOTE"]

API_KEY = os.environ.get("TRADERMADE_API_KEY")
if not API_KEY:
    raise SystemExit("Set the TRADERMADE_API_KEY environment variable first.")


def parse_and_print(msg):
    # Get all the keys safely
    a    = msg.get("a", "")
    av   = msg.get("av", "")
    b    = msg.get("b", "")
    bv   = msg.get("bv", "")
    m    = msg.get("m", "")
    s    = msg.get("s", "")
    t    = msg.get("t", "")
    ts   = msg.get("ts", "")

    # Line 1: Strict order -> s, a, av, b, bv, (mid if exists), t, ts
    line = f"{s} a={a} av={av} b={b} bv={bv}"

    if "m" in msg:
        line += f" mid={m}"

    line += f" {t} {ts}"

    # Line 2: Ladder goes here if it exists
    if "ladder" in msg:
        line += f"\n      ladder={msg['ladder']}"

    # Catch-all safety net for any completely unmapped keys
    known_keys = {"a", "av", "b", "bv", "m", "s", "t", "ts", "ladder"}
    extra = {k: v for k, v in msg.items() if k not in known_keys}
    if extra:
        line += f"\n      extra={extra}"

    print(line)


async def handle_message(ws, msg):
    msg_type = msg.get("type", "")
    tick     = msg.get("t", "")

    if msg_type == "login_ok":
        print(
            f"Logged in. symbol_limit={msg.get('symbol_limit', 0)}"
            f"  cfds={msg.get('cfds', False)}"
            f"  ladder={msg.get('trader_ladder', False)}"
        )
        await ws.send(json.dumps({
            "action":    "subscribe",
            "symbols":   SYMBOLS,
            "send_last": True,
        }))

    elif msg_type == "login_reject":
        print("Login rejected:", msg.get("reason", ""))
        await ws.close()

    elif msg_type in ("sub_ack", "unsub_ack"):
        key = "accepted" if msg_type == "sub_ack" else "removed"
        print(f"Confirmed: {msg.get(key, [])}")
        if msg.get("denied"):
            print("  denied:", msg["denied"], msg.get("denied_reasons", {}))
        if msg.get("invalid"):
            print("  invalid:", msg["invalid"])

    elif msg_type == "error":
        print("Server error:", msg.get("reason", ""))

    elif msg_type == "logout":
        print("Logged out:", msg.get("reason", ""))
        await ws.close()

    elif tick in ("QUOTE", "LAST_QUOTE"):
        parse_and_print(msg)


async def stream():
    async with websockets.connect(URL) as ws:
        await ws.send(json.dumps({
            "action": "login", 
            "key": API_KEY, 
            "fmt": "JSON", 
            "send_ladder": True
        }))

        async for raw in ws:
            try:
                msg = json.loads(raw)
            except json.JSONDecodeError as e:
                print("Bad JSON:", e)
                continue
            await handle_message(ws, msg)


if __name__ == "__main__":
    try:
        asyncio.run(stream())
    except KeyboardInterrupt:
        print("\nStopped.")

Wrapping up

You now have a working Python WebSocket client that connects to the streaming feed, logs in with your API key, subscribes to live symbols, reads QUOTE and LAST_QUOTE ticks, and handles common responses such as subscription acknowledgements, invalid symbols, login errors, and logout messages.

From here, you can extend the script further by adding more symbols, storing ticks in a database, enabling ladder data for market depth, or connecting the stream to your own trading dashboard or analytics tool.

For the full message format and available actions, see the WebSocket V2 documentation. If you need any help, contact us via live chat or email us at support@tradermade.com.

Related Tutorials