WEEK 3

Socket Streaming & Protocols

From the wire up: the seven layers a message crosses, the two transports that carry it, the socket API that opens them, and then the protocols trading actually runs on: FIX, ITCH, OUCH, and the WebSocket the arena speaks.

Live session · real broker quotes on Yahoo prices Deliverable: protocol contract review + message-flow diagram Read shared/messages.py in full
Part 1

1The seven OSI layers

You open your laptop and join the café Wi-Fi. The radio finds the access point and agrees on a channel (physical). The laptop and the access point swap MAC addresses and start exchanging frames (data link). DHCP hands you an IP address, and ARP finds the router's MAC so packets can leave the room (network). Then your bot runs: DNS turns a hostname into an address, TCP opens a connection to a port (transport), TLS agrees on keys (presentation), the WebSocket handshake and the arena Handshake open a session, and finally a PlaceOrder goes out (application). Every one of those steps is a layer doing one job and adding its own header in front of the bytes it was given.

Interactive · click any layer

OSI model · 7 layersTCP/IP · 4 layers
ApplicationHTTP, WebSocket, TLS, FIX
TransportTCP, UDP
InternetIP, ICMP
LinkEthernet, Wi-Fi

OSI is the vocabulary; TCP/IP is what actually runs. The Internet stack folds layers 5–7 into "the application" and 1–2 into "the link", which is why you will hear people say "layer 4 load balancer" and "layer 7 load balancer" but almost never "layer 5".

—

—

Header it adds

Protocols at this layer
Devices that work at this layer

Interactive · send one order and watch the headers

Press Send: the order goes down your machine's stack, picking up one header per layer (encapsulation), crosses the wire through a router, and goes up the exchange's stack with each layer reading and removing its own header (decapsulation). Use Step to go one layer at a time; click any layer in either stack to read about it above.

Your bot
router
(reads to L3)
wire
Exchange
What is on the wire right now

0 / 16
Two things to notice. The router never looks above layer 3: it strips the Ethernet header, decrements the TTL, and writes a new Ethernet header for the next hop, so MAC addresses change at every hop while IP addresses stay end to end. And everything above TCP is encrypted once TLS is on, so a firewall in the middle sees ports and addresses but not your order.
Part 2

2UDP vs TCP

Layer 4 offers two deals. UDP puts a port number on your datagram and hands it to IP: nothing more. TCP builds a connection, numbers every byte, acknowledges, retransmits, reorders and paces, and gives the application a reliable byte stream. Trading uses both, for opposite reasons.

UDPTCP
ConnectionNone. sendto() any address at any time.Three-way handshake (SYN, SYN-ACK, ACK) before any data; FIN or RST to close.
OrderingNone. Datagrams may arrive out of order.Guaranteed: the receiver reorders by sequence number.
ReliabilityNone. A lost datagram is gone and nobody is told.Every byte is acknowledged; missing bytes are detected.
RetransmitNever (the application can ask, if its protocol allows).Automatic: after three duplicate ACKs (fast retransmit) or a timeout.
Flow / congestion controlNone. You can flood the network or the receiver.Receive window (flow) plus congestion window (slow start, backoff on loss).
BoundariesMessage boundaries kept: one sendto() is one recvfrom().Byte stream: no boundaries; two sends can arrive as one recv. You frame it.
Head-of-line blockingNo. Each datagram is delivered as it arrives.Yes. One lost segment holds back every byte after it until the retransmit lands.
LatencyOne-way delay, no setup, no waiting on others.One round trip to connect, then stalls on loss; Nagle can delay small writes (set TCP_NODELAY).
MulticastYes: one send reaches every host that joined the group.No. Point to point only; N subscribers means N connections.
Header8 bytes.20–60 bytes.
Trading useMarket data: exchanges multicast the feed (ITCH over MoldUDP64, CME MDP 3.0). Late data is worthless, and sequence numbers detect gaps.Order entry: FIX, OUCH (over SoupBinTCP), the arena's WebSocket. Every order and fill must arrive exactly once, in order.

Interactive · the same eight messages, with packet loss

Time runs downward. Each arrow is one packet from sender to receiver; a red ✕ is a packet the network dropped. Move the slider and watch TCP pay for reliability with delay while UDP pays for speed with gaps. One-way delay is 10 ms; ACKs for data are left off the drawing to keep it readable.

draw #1

TCP —

delivered
—
last to app
—
worst wait
—

UDP —

delivered
—
last to app
—
gaps
—
—
Part 3

3TCP and UDP sockets

A socket is the kernel's handle on one end of a conversation: an address, a port, buffers, and for TCP a state machine. Python's socket module is a thin layer over the same BSD calls every language uses, so once you know the order of the calls you know them everywhere. Step through both sides at once; the kernel state is what netstat or ss would show at that moment.

Interactive · the TCP calls, server and client side by side

Server
Client
On the wire
—
Kernel state of each socket

—

Interactive · the UDP calls: no connection, no states

Receiver
Sender
On the wire
—
Kernel state of each socket

—

What the kernel really reports

Run while tcp_echo_server.py (Part 4) is listening; this is the exact output on macOS with Python 3.14. You can see the listening socket, the two ESTABLISHED ends of one connection, and the client's end lingering in TIME_WAIT after close() (the server had already exited, so its LISTEN line is gone).

# tcp_states.py
import socket, subprocess, time

def states(label):
    out = subprocess.run(["netstat", "-an", "-p", "tcp"],
                         capture_output=True, text=True).stdout
    print(f"-- {label}")
    for line in out.splitlines():
        if "127.0.0.1.9000 " in line:
            print("  ", " ".join(line.split()[3:]))

states("server only")
cli = socket.create_connection(("127.0.0.1", 9000))
time.sleep(0.2); states("after connect()")
cli.close()
time.sleep(0.2); states("after close()")
$ python3 tcp_states.py
-- server only
   127.0.0.1.9000 *.* LISTEN
-- after connect()
   127.0.0.1.9000 127.0.0.1.58996 ESTABLISHED
   127.0.0.1.58996 127.0.0.1.9000 ESTABLISHED
   127.0.0.1.9000 *.* LISTEN
-- after close()
   127.0.0.1.58996 127.0.0.1.9000 TIME_WAIT

On Linux use ss -tan '( sport = :9000 or dport = :9000 )'; the state names are the same.

Blocking, selectors, asyncio: three ways to wait

Blocking (default). accept() and recv() put the thread to sleep until something happens. One connection per thread; simple, and fine for a script. Add settimeout() so a dead peer cannot hang you forever.

Non-blocking + selectors. Set setblocking(False), register many sockets with a selector (epoll on Linux, kqueue on macOS) and ask "which are ready?". One thread, thousands of sockets: the shape of every fast feed handler.

asyncio. The same selector loop, hidden behind await reader.read() / await ws.recv(). This is Week 2's event loop, and it is what the arena and the websockets library use.

import selectors, socket
sel = selectors.DefaultSelector()                  # kqueue on macOS, epoll on Linux
srv = socket.create_server(("127.0.0.1", 9000)); srv.setblocking(False)
sel.register(srv, selectors.EVENT_READ)
while True:
    for key, _ in sel.select():                     # sleeps until at least one socket is ready
        if key.fileobj is srv:
            conn, _ = srv.accept(); conn.setblocking(False)
            sel.register(conn, selectors.EVENT_READ)
        else:
            data = key.fileobj.recv(4096)            # will not block: the selector said so
            if not data: sel.unregister(key.fileobj); key.fileobj.close()
            else: key.fileobj.sendall(data)
Part 4

4Server and client examples

Two complete programs per transport, standard library only. Every output block below is the exact text these files printed when run with Python 3.14 on macOS; only the client's ephemeral port number will differ on your machine.

TCP · a length-prefixed echo server and client

TCP gives you a byte stream, so the server and client agree on a frame: a 4-byte big-endian length, then that many bytes of JSON. recv_exactly() loops until it has the whole thing, which is the entire trick.

# tcp_echo_server.py: length-prefixed echo server (one client at a time)
import socket
import struct

HOST, PORT = "127.0.0.1", 9000
HEADER = struct.Struct("!I")          # 4-byte big-endian unsigned length


def recv_exactly(conn, n):
    """Loop until exactly n bytes arrive; b'' means the peer closed."""
    buf = b""
    while len(buf) < n:
        chunk = conn.recv(n - len(buf))
        if not chunk:
            return b""
        buf += chunk
    return buf


def recv_msg(conn):
    header = recv_exactly(conn, HEADER.size)
    if not header:
        return None
    (length,) = HEADER.unpack(header)
    return recv_exactly(conn, length)


def send_msg(conn, payload):
    conn.sendall(HEADER.pack(len(payload)) + payload)


with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind((HOST, PORT))
    srv.listen()
    print(f"listening on {HOST}:{PORT}", flush=True)
    conn, addr = srv.accept()
    with conn:
        print(f"accepted {addr[0]}:{addr[1]}", flush=True)
        while (msg := recv_msg(conn)) is not None:
            print(f"recv {len(msg):3d} bytes: {msg.decode()}", flush=True)
            send_msg(conn, msg)
        print("client closed; bye", flush=True)
# tcp_echo_client.py: sends three framed orders, reads the echoes
import json
import socket
import struct

HEADER = struct.Struct("!I")

orders = [
    {"type": "place_order", "symbol": "AAPL", "side": "buy",  "quantity": 5, "price": 185.55},
    {"type": "cancel_order", "order_id": "a91f"},
    {"type": "place_order", "symbol": "NVDA", "side": "sell", "quantity": 2, "price": 121.10},
]


def recv_exactly(sock, n):
    buf = b""
    while len(buf) < n:
        chunk = sock.recv(n - len(buf))
        if not chunk:
            raise ConnectionError("server closed mid-message")
        buf += chunk
    return buf


with socket.create_connection(("127.0.0.1", 9000)) as sock:
    # all three frames in ONE sendall: TCP is free to deliver them in any chunking
    sock.sendall(b"".join(
        HEADER.pack(len(p)) + p
        for p in (json.dumps(o).encode() for o in orders)))
    for _ in orders:
        (length,) = HEADER.unpack(recv_exactly(sock, HEADER.size))
        echo = json.loads(recv_exactly(sock, length))
        print(f"echo {length:3d} bytes -> {echo['type']}")
# terminal 1
$ python3 tcp_echo_server.py
listening on 127.0.0.1:9000
accepted 127.0.0.1:56697
recv  88 bytes: {"type": "place_order", "symbol": "AAPL", "side": "buy", "quantity": 5, "price": 185.55}
recv  44 bytes: {"type": "cancel_order", "order_id": "a91f"}
recv  88 bytes: {"type": "place_order", "symbol": "NVDA", "side": "sell", "quantity": 2, "price": 121.1}
client closed; bye
# terminal 2
$ python3 tcp_echo_client.py
echo  88 bytes -> place_order
echo  44 bytes -> cancel_order
echo  88 bytes -> place_order

Interactive · why the length prefix is there: TCP has no framing

Three orders are being sent. The kernel hands them to the receiver in arbitrary chunks, exactly as it does in production. Choose how the receiver finds message boundaries, then deliver the chunks one at a time and watch what the parser can and cannot do. The length-prefix scheme is the echo server above.

Framing scheme:
0 / 0 bytes
chunks
0
parsed ok
0
errors
0

Parser output

Nothing received yet.
—

UDP · a sender and a receiver that notices gaps

No listen, no accept, no connect. Each sendto() is one datagram and each recvfrom() returns exactly one. The sender deliberately skips sequence number 3 to stand in for a packet lost on the wire; the receiver can only find out because every datagram carries a sequence number, exactly like a real feed.

# udp_receiver.py: one datagram = one message; detect gaps by sequence number
import socket
import struct

PKT = struct.Struct("!Q")             # 8-byte sequence number, then the payload

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
    sock.bind(("127.0.0.1", 9001))
    sock.settimeout(2.0)
    print("bound to 127.0.0.1:9001", flush=True)
    expected = 1
    try:
        while True:
            data, addr = sock.recvfrom(2048)
            (seq,) = PKT.unpack_from(data)
            if seq != expected:
                print(f"GAP: expected {expected}, got {seq} -> {seq - expected} lost", flush=True)
            print(f"seq {seq} from {addr[0]}: {data[PKT.size:].decode()}", flush=True)
            expected = seq + 1
    except socket.timeout:
        print("no data for 2 s; done", flush=True)
# udp_sender.py: no connect, no handshake; each sendto() is one datagram
import socket
import struct

PKT = struct.Struct("!Q")
quotes = {1: "AAPL 185.50 x 185.55", 2: "AAPL 185.50 x 185.60",
          3: "AAPL 185.45 x 185.55", 4: "AAPL 185.40 x 185.50"}

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
    for seq, text in quotes.items():
        if seq == 3:
            continue                    # simulate a packet lost on the wire
        sock.sendto(PKT.pack(seq) + text.encode(), ("127.0.0.1", 9001))
        print(f"sent seq {seq}")
# terminal 1 (start first)
$ python3 udp_receiver.py
bound to 127.0.0.1:9001
seq 1 from 127.0.0.1: AAPL 185.50 x 185.55
seq 2 from 127.0.0.1: AAPL 185.50 x 185.60
GAP: expected 3, got 4 -> 1 lost
seq 4 from 127.0.0.1: AAPL 185.40 x 185.50
no data for 2 s; done
# terminal 2
$ python3 udp_sender.py
sent seq 1
sent seq 2
sent seq 4

The same thing as a real feed does it: multicast

Change the destination to a multicast group and every receiver that joined the group gets every datagram from one sendto(). Joining is one setsockopt; the kernel sends the IGMP join and the switches start forwarding the group to your port. Two receivers were running here:

# mcast_receiver.py: join a multicast group (the kernel sends an IGMP join for you)
import socket
import struct

GROUP, PORT, IFACE = "239.1.1.1", 9002, "127.0.0.1"   # loopback for a laptop demo

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"):                   # let two receivers share the port
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
sock.bind(("", PORT))
mreq = struct.pack("4s4s", socket.inet_aton(GROUP), socket.inet_aton(IFACE))
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
sock.settimeout(2.0)
print(f"joined {GROUP}:{PORT}", flush=True)
try:
    while True:
        data, addr = sock.recvfrom(2048)
        print(f"{addr[0]}:{addr[1]} -> {data.decode()}", flush=True)
except socket.timeout:
    print("no data for 2 s; done", flush=True)
# mcast_sender.py: one sendto() reaches every receiver that joined the group
import socket

GROUP, PORT, IFACE = "239.1.1.1", 9002, "127.0.0.1"

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(IFACE))
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)   # never leave this host
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1)
for seq in (1, 2, 3):
    sock.sendto(f"seq={seq} AAPL 185.50 x 185.55".encode(), (GROUP, PORT))
print("sent 3 datagrams to", GROUP)
# terminals 1 and 2 print the same thing
$ python3 mcast_receiver.py
joined 239.1.1.1:9002
127.0.0.1:64991 -> seq=1 AAPL 185.50 x 185.55
127.0.0.1:64991 -> seq=2 AAPL 185.50 x 185.55
127.0.0.1:64991 -> seq=3 AAPL 185.50 x 185.55
no data for 2 s; done
# terminal 3
$ python3 mcast_sender.py
sent 3 datagrams to 239.1.1.1

Try it

Part 5

5Financial protocols: FIX, ITCH, OUCH

Venues speak two kinds of protocol. Order entry is a private, reliable, two-way session over TCP: FIX everywhere, and binary protocols such as Nasdaq's OUCH where every microsecond counts. Market data is a public, one-way firehose, usually binary and multicast over UDP: Nasdaq's ITCH, CME's MDP 3.0. Everything in Parts 1–4 shows up here.

FIX · tag=value text, with a session layer built in

A FIX message is a list of tag=value fields, each ended by the SOH byte (0x01, drawn here as |). Three parts:

  • Header: 8 BeginString, 9 BodyLength, 35 MsgType, then sender, target, 34 MsgSeqNum, 52 SendingTime.
  • Body: the fields of that message type, for example 55 Symbol, 54 Side, 38 OrderQty, 44 Price.
  • Trailer: 10 CheckSum, always last.

BodyLength counts the bytes after 9=…| up to and including the | before 10=. CheckSum is the sum of every byte before 10=, modulo 256, written as three digits. Both are framing and corruption checks: FIX runs over TCP, so the receiver reads 8= and 9= and then knows exactly how many bytes to wait for. A length prefix, in text.

Session layer (the "5" in OSI, in practice)

  • 35=A Logon: opens the session; 108 HeartBtInt sets the heartbeat interval; 141=Y resets sequence numbers.
  • 35=0 Heartbeat: sent when you have had nothing to say for HeartBtInt seconds.
  • 35=1 TestRequest: "are you alive?" The peer must answer with a Heartbeat carrying the same 112 TestReqID.
  • 35=2 ResendRequest: "I expected 5 but got 8: resend 7=5 through 16=0 (0 means everything)".
  • 35=4 SequenceReset: with 123=Y (GapFill), "skip ahead to 36 NewSeqNo", used instead of resending stale admin messages.
  • 35=5 Logout. Then the application layer: 35=D NewOrderSingle, 35=8 ExecutionReport, 35=F cancel, 35=G replace.

Every message in each direction carries 34 MsgSeqNum, and the counters survive reconnects. That is how FIX gets exactly-once, in-order delivery across a dropped TCP connection, which TCP alone cannot give you.

Interactive · FIX message builder and decoder

Pick a message, edit any field, and watch 9 BodyLength and 10 CheckSum recompute. Then paste or corrupt a message in the decoder and see which check catches it.

Fields

On the wire (| = SOH 0x01)

headerbodytrailer

How 9 and 10 were computed

—

Decoder · paste a FIX message (| or ^A or real SOH)

tagnamevalue
# fix_build.py: build a FIX 4.4 NewOrderSingle and compute BodyLength and CheckSum
SOH = "\x01"

def fix_message(msg_type, header, body):
    fields = [(35, msg_type)] + header + body
    payload = "".join(f"{tag}={value}{SOH}" for tag, value in fields)
    head = f"8=FIX.4.4{SOH}9={len(payload)}{SOH}"      # BodyLength: bytes after 9=..| up to 10=
    checksum = sum((head + payload).encode()) % 256      # every byte before 10=, mod 256
    return f"{head}{payload}10={checksum:03d}{SOH}"

msg = fix_message("D",
    header=[(49, "T07"), (56, "ARENA"), (34, 12), (52, "20261009-14:30:00.000")],
    body=[(11, "ord-0001"), (55, "AAPL"), (54, 1), (60, "20261009-14:30:00.000"),
          (38, 5), (40, 2), (44, "185.55"), (59, 0)])
print(msg.replace(SOH, "|"))
print(len(msg.encode()), "bytes on the wire")
$ python3 fix_build.py
8=FIX.4.4|9=127|35=D|49=T07|56=ARENA|34=12|52=20261009-14:30:00.000|11=ord-0001|55=AAPL|54=1|60=20261009-14:30:00.000|38=5|40=2|44=185.55|59=0|10=244|
150 bytes on the wire

The builder's default NewOrderSingle is this exact message, so the widget and Python agree: 9=127, 10=244. Real systems use a FIX engine (QuickFIX, or a vendor's) that also persists sequence numbers and handles resends; you almost never hand-roll the session layer.

ITCH · binary market data over UDP multicast

Nasdaq TotalView-ITCH 5.0 publishes every order event on the book: fixed-width binary messages, big-endian integers, prices as integers with 4 implied decimals, timestamps as nanoseconds since midnight. No field names, no delimiters: the first byte is the message type and the type fixes the length and the offset of every field. Decoding is struct.unpack, not parsing.

Messages ride in MoldUDP64 packets: a 20-byte header (10-byte session, 8-byte sequence number of the first message, 2-byte message count), then each message prefixed with its 2-byte length. The packets go out over UDP multicast; a receiver that sees the sequence number jump knows exactly what it missed and asks a re-request server (or a TCP snapshot service) for the gap.

A few ITCH 5.0 message types

typemessagebytes
SSystem Event (start/end of day)12
RStock Directory39
AAdd Order (no MPID)36
FAdd Order with MPID40
EOrder Executed31
XOrder Cancel (partial)23
DOrder Delete19
UOrder Replace35
PTrade (non-cross)44

From A/F/E/X/D/U alone you can rebuild the full order book: that is what a feed handler does.

Interactive · decode a MoldUDP64 packet carrying one Add Order

Edit the order and the bytes re-encode; click a field (or any byte) to see where it lives; or edit the hex directly and watch the decoder read it.

Packet bytes

—
# itch_decode.py: build one MoldUDP64 packet carrying an ITCH 5.0 Add Order, then decode it
import struct

# --- what the exchange would multicast -----------------------------------
ts_ns = 34_200_000_123_456                     # 09:30:00.000123456 since midnight
add = (b"A" + struct.pack(">HH", 7, 0) + ts_ns.to_bytes(6, "big")
       + struct.pack(">Q", 4_105_823) + b"B" + struct.pack(">I", 300)
       + b"AAPL    " + struct.pack(">I", 1_855_500))       # $185.5500, 4 implied decimals
packet = (b"NASDAQ0001" + struct.pack(">QH", 1_000_042, 1)  # session, seq no, count
          + struct.pack(">H", len(add)) + add)
print(len(packet), "bytes:", packet.hex(" "))

# --- what your feed handler does -----------------------------------------
session, seq, count = packet[:10].decode(), *struct.unpack_from(">QH", packet, 10)
(mlen,) = struct.unpack_from(">H", packet, 20)
msg = packet[22:22 + mlen]
locate, tracking = struct.unpack_from(">HH", msg, 1)
ts = int.from_bytes(msg[5:11], "big")
ref, side, shares, stock, price = struct.unpack_from(">Qc I 8s I", msg, 11)
print(f"session={session} seq={seq} count={count} len={mlen} type={msg[:1].decode()}")
print(f"locate={locate} ts={ts} ref={ref} side={side.decode()} shares={shares} "
      f"stock={stock.decode().strip()} price={price / 10_000:.4f}")
$ python3 itch_decode.py
58 bytes: 4e 41 53 44 41 51 30 30 30 31 00 00 00 00 00 0f 42 6a 00 01 00 24 41 00 07 00 00 1f 1a ce db d2 40 00 00 00 00 00 3e a6 5f 42 00 00 01 2c 41 41 50 4c 20 20 20 20 00 1c 50 0c
session=NASDAQ0001 seq=1000042 count=1 len=36 type=A
locate=7 ts=34200000123456 ref=4105823 side=B shares=300 stock=AAPL price=185.5500

The widget's starting packet is byte-for-byte this one. The session name NASDAQ0001 and the order numbers are made up for the example; the layout is the real one.

OUCH · binary order entry

OUCH is Nasdaq's order-entry protocol: the ITCH idea applied to orders. Fixed-width binary messages, no tags, no checksum (TCP's checksum and the session layer are enough). It rides on SoupBinTCP, a thin session layer over TCP with its own login, heartbeats and sequence numbers for the server's messages, so a reconnecting client can ask to resume at the next message it has not seen. Client messages include O Enter Order, U Replace, X Cancel; the venue answers with A Accepted, E Executed, C Canceled, J Rejected.

Compare with FIX: 54=1|38=300|55=AAPL|44=185.55| is 30 bytes of text the venue has to scan and convert; OUCH puts the same information in 17 bytes at fixed offsets. Same idea, same reliability (TCP), less work per message.

OUCH 4.2 Enter Order (type O, 49 bytes)

offsetfieldlentype
0Type = 'O'1alpha
1Order Token14alpha
15Buy/Sell1B S T E
16Shares4uint32
20Stock8alpha
28Price4uint32, 4 dp
32Time in Force4uint32 s
36Firm4alpha
40Display, Capacity, ISO3alpha
43Minimum Quantity4uint32
47Cross Type, Customer Type2alpha

OUCH 5.0 replaces the token with a numeric UserRefNum and moves optional fields into appendages; the principle is unchanged.

SBE, FAST and CME MDP 3.0, in one paragraph

FAST (FIX Adapted for STreaming) compressed FIX-style market data with templates, stop-bit integers and field operators (copy, delta, increment: send only what changed); it saved bandwidth but cost CPU to decode, and it is now mostly legacy. SBE (Simple Binary Encoding) went the other way: fixed offsets, native little-endian integers, a schema in XML, no compression, so a decoder can read a field straight out of the buffer without copying. CME MDP 3.0 is SBE over UDP multicast: every packet starts with a 4-byte sequence number and an 8-byte sending time, the same data is published twice on A and B feeds (take whichever copy arrives first, fill gaps from the other), and separate snapshot channels let a late or gapped receiver rebuild the book. CME's order entry (iLink 3) is SBE too, over TCP with a FIXP session layer. The pattern is the same everywhere: binary and multicast for data, sequenced and reliable for orders.

Part 6

6WebSocket: what the arena speaks

A WebSocket starts life as an ordinary HTTP/1.1 request on a TCP connection and then stops being HTTP. The client asks to upgrade; the server answers 101 Switching Protocols; from then on the same TCP connection carries frames in both directions, whenever either side likes. It is TCP plus the length-prefix framing from Part 4, plus a session layer (ping/pong, close codes), in a shape that browsers, proxies and firewalls already allow.

The Upgrade handshake, captured

This script sends the handshake by hand to a local websockets 15.0.1 server (the echo server further down, on port 8770 because 8765 was taken on this machine). The response is printed exactly as it arrived.

# ws_raw_upgrade.py: do the HTTP Upgrade by hand and print the server's answer
import base64, hashlib, socket

key = "dGhlIHNhbXBsZSBub25jZQ=="                    # the RFC 6455 example key
req = ("GET / HTTP/1.1\r\n"
       "Host: 127.0.0.1:8770\r\n"
       "Upgrade: websocket\r\n"
       "Connection: Upgrade\r\n"
       f"Sec-WebSocket-Key: {key}\r\n"
       "Sec-WebSocket-Version: 13\r\n\r\n")
with socket.create_connection(("127.0.0.1", 8770)) as s:
    s.sendall(req.encode())
    print(s.recv(4096).decode(), end="")
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
print("expected accept:", base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode())
$ python3 ws_raw_upgrade.py
HTTP/1.1 101 Switching Protocols
Date: Sat, 10 Oct 2026 03:32:56 GMT
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Server: Python/3.14 websockets/15.0.1

expected accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Because the script hung up without sending a Close frame, the server logged websockets.exceptions.ConnectionClosedError: no close frame received or sent: the abnormal closure that the API reports as code 1006. A well-behaved client closes with a frame, as the websockets client below does.

Interactive · the Sec-WebSocket-Accept rule

The client sends a random 16-byte key, base64-encoded. The server appends the fixed GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, takes the SHA-1, and returns it base64-encoded. It proves the server really speaks WebSocket (a cache or a plain HTTP server cannot echo the right value); it is not security. Change the key and watch the answer change.

Frames, ping/pong and close codes

The frame header (RFC 6455 §5.2)

  • FIN 1 = last fragment of this message. opcode: 0x1 text, 0x2 binary, 0x0 continuation, 0x8 close, 0x9 ping, 0xA pong.
  • Payload len 0–125 is the length; 126 means "the next 2 bytes are the length", 127 "the next 8 bytes are". A length prefix, again.
  • MASK: every client-to-server frame must be masked with a fresh 4-byte key (XOR), so a browser cannot be tricked into writing predictable bytes onto the wire for a confused proxy. Server-to-client frames are never masked.
  • Control frames (close, ping, pong) carry at most 125 bytes and may be sent between fragments of a data message. A ping must be answered with a pong carrying the same payload.

Close codes you will meet

codemeaning
1000Normal closure
1001Going away (server shutting down, tab closed)
1002Protocol error
1003Unsupported data (binary to a text-only endpoint)
1006Abnormal: no Close frame at all (reported locally, never sent)
1008Policy violation (for example a bad auth token)
1009Message too big
1011Internal server error

A Close frame's payload is the 2-byte code plus an optional UTF-8 reason. The other side replies with its own Close, then the TCP connection is shut down.

Interactive · build a frame and see its bytes

Frame bytes

The websockets library: what you actually write

# ws_server.py: a WebSocket echo server, port 8770 (the arena uses 8765)
import asyncio
import websockets


async def echo(ws):
    async for message in ws:
        await ws.send(message)


async def main():
    async with websockets.serve(echo, "127.0.0.1", 8770):
        await asyncio.Future()        # run forever

asyncio.run(main())
# ws_client.py: connect, send one typed-looking order, read the echo, close cleanly
import asyncio
import json
import websockets


async def main():
    async with websockets.connect("ws://127.0.0.1:8770") as ws:
        print("connected; subprotocol:", ws.subprotocol)
        order = {"type": "place_order", "team_id": "T07", "symbol": "AAPL",
                 "side": "buy", "order_type": "limit", "price": 185.55, "quantity": 5}
        await ws.send(json.dumps(order))          # one call = one text frame
        reply = json.loads(await ws.recv())       # one recv = one whole message
        print("echoed:", reply["type"], reply["symbol"], reply["quantity"])
        latency = await ws.ping()                 # ping frame; await the pong
        print(f"ping/pong round trip: {await latency * 1000:.2f} ms")
    print("closed with code", ws.close_code)

asyncio.run(main())
# terminal 1: python3 ws_server.py      terminal 2:
$ python3 ws_client.py
connected; subprotocol: None
echoed: place_order AAPL 5
ping/pong round trip: 0.08 ms
closed with code 1000

Needs pip install websockets (15.x shown). Leaving the async with block sends a Close frame with code 1000 and waits for the server's reply. By default the library also pings every 20 seconds and drops the connection if no pong arrives within 20 seconds: the heartbeat comes for free. The dict is for the demo only; in the arena you send model.model_dump_json() (Part 7).

Request / response

Client asks, server answers, done. HTTP and REST. Simple, but the client has to keep asking to learn what changed, and it misses everything that happened between two polls. Useless for a book that moves every 50 ms.

Streaming

One connection stays open and the server pushes updates as they happen. No re-asking, no per-message HTTP header tax, no gap between polls. This is how you receive market data.

Publish / subscribe

Publishers emit to topics; subscribers get what they asked for. One book update fans out to every trader without the exchange knowing who they are. (UDP multicast is pub/sub done by the network.)

How the arena maps onto everything above

ConceptWhere it lives in AlgoArenaReal-venue analogue
L7 messagesJSON objects, one per WebSocket text frame, every one a pydantic model in shared/messages.py with a type discriminator.FIX 35= MsgType; ITCH/OUCH first byte
FramingWebSocket frames: one await ws.recv() is one message. You never see a partial order.FIX BodyLength; SoupBinTCP and MoldUDP64 length fields
Session openHandshake(team_id, role, level, token), answered by SessionEvent (SESSION_PREOPEN / SESSION_OPEN).FIX Logon 35=A; SoupBinTCP Login
HeartbeatWebSocket ping/pong from the websockets library, every 20 s by default.FIX Heartbeat / TestRequest
Order entryPlaceOrder → OrderAck (with queue_ahead), then TradeExecution and PortfolioUpdate.FIX NewOrderSingle → ExecutionReport; OUCH O → A, E
Market dataBookSnapshot pushed on the same TCP connection, per team.ITCH / MDP 3.0 over UDP multicast
RecoveryNo sequence numbers: on reconnect you re-handshake and the venue's next BookSnapshot and PortfolioUpdate are the truth.FIX ResendRequest; MoldUDP64 re-request; snapshot channels
Transport securityws:// locally (no TLS); a public deployment should use wss:// so TLS sits under the frames.TLS or a private cross-connect

The arena trades latency for clarity on purpose: one reliable TCP connection for everything, readable JSON, no gap recovery to write. Real venues split the two flows because at their scale and speed they must.

Interactive · one arena connection, message by message

TCP, then the HTTP upgrade, then WebSocket frames carrying the arena protocol. Field names are the ones in shared/messages.py.

your bot exchange :8765
0 / 12

Press Next to open a connection

$ TEAM_ID=T07 python -m team.trader

Everything below the SDK is the websockets library plus shared/messages.py. You will read both this week.

Phase

TCP HTTP upgrade WebSocket frames arena protocol

Why not HTTP polling?

  • Polling sends a fresh request every N ms: new headers, often a new connection, and you still miss everything between two polls.
  • Long-polling / SSE help, but SSE is one-way, server to client. A trader has to send orders too.
  • WebSocket upgrades once, then both sides push frames whenever they like with 2–14 bytes of header.

Keeping a long-lived connection healthy

  • Back-pressure. Bound your queues, coalesce stale book updates, and let asyncio await the drain (Week 2's widget, in production).
  • Heartbeats. A dead TCP connection can look alive for minutes. Ping/pong finds a silent peer fast and stops NATs closing an idle pipe.
  • Reconnection. Exponential backoff, re-handshake, then wait for a fresh snapshot: never assume continuity.
  • Idempotency. A retried order must not double-execute. Client-side IDs let the server drop a repeat (FIX's 11 ClOrdID exists for exactly this).
Part 7

7Protocol design: the contract at the boundary

Every protocol on this page is a contract: which messages exist, which fields they carry, and what happens when a message breaks the rules. A malformed order is not a nuisance; it is either a rejected trade or, far worse, an accepted one that corrupts the book. The arena has one rule in both directions: never send a raw dict, and never pull fields out of raw JSON. Every message is a pydantic v2 model in shared/messages.py; the class is the contract, and a bad message dies at the boundary instead of three layers deep inside matching.

The cost of a raw dict

  • msg['quatity']: a typo is a KeyError at runtime, in production, mid-session.
  • No autocomplete and no guarantees: you cannot know what fields exist without reading every producer.
  • Silent drift: change a field on one side and the other breaks somewhere far away, with no error at the boundary.

The value of a typed model

  • order.quantity: the IDE completes it, the type checker verifies it, the validator guarantees it is an int.
  • Errors surface at parse time with a precise ValidationError naming the field.
  • The model is the documentation: the class definition is the authoritative spec of the message (FIX has its data dictionary, ITCH its spec PDF; the arena has this file).

Interactive · validate an order at the boundary

Edit the JSON below, or load one of the broken ones, and watch a pydantic-style validator do what the exchange does the instant your frame arrives. The model shown under the editor is the real PlaceOrder.

Outbound message · edit me

from pydantic import BaseModel
from typing import Literal

class PlaceOrder(BaseModel):          # shared/messages.py
    type: Literal["place_order"] = "place_order"
    team_id: str
    symbol: str
    side: Literal["buy", "sell"]        # 'BUY' is rejected
    order_type: Literal["limit", "market", "ioc", "post_only",
                        "stop", "stop_limit", "moc"]
    stop_price: float | None = None        # optional: old clients still parse
    price: float                          # required, even for market orders
    quantity: int                         # '' fails, 5.5 fails, "5" is coerced

Validator

What the exchange does next

—

In the arena this is one function call: parse_message(json.loads(raw)) looks at the type discriminator, picks the right model, and calls model_validate. Unknown type → KeyError; bad shape → ValidationError. Both are caught at the edge and logged; neither kills the connection.

Discriminated unions: many message types, one channel

# shared/messages.py: keyed on the 'type' field
AnyClientMessage   = Union[Handshake, PlaceOrder,
                           CancelOrder, ...]      # client → exchange
AnyExchangeMessage = Union[OrderAck, BookSnapshot,
                           TradeExecution,
                           PortfolioUpdate,
                           SessionEvent, ...]     # exchange → client

def parse_message(raw: dict) -> BaseModel:
    msg_type = raw.get("type")          # the discriminator
    model_cls = _TYPE_MAP.get(msg_type) # unknown -> KeyError
    return model_cls.model_validate(raw)  # bad -> ValidationError

One tag field selects the model, so dispatch is O(1) and unambiguous: the same job as FIX's 35= or ITCH's first byte. The direction is encoded in the union name: AnyClientMessage is what you send, AnyExchangeMessage is what you receive.

Three failure modes, three answers. Malformed (missing field, bad enum): reject with a clear error, never let it reach business logic. Unknown type (a newer peer, a bug): log and skip; do not kill a connection over one frame. Not even JSON: catch the decode error, and consider closing if it repeats.

Interactive · adding a field without breaking last week's bots

A live protocol has to evolve while old clients are still connected. The arena added queue_ahead and level_qty to OrderAck (and maker_rebate, maker_id, taker_id to TradeExecution) with defaults, mid-season. Flip the versions and see why nothing broke, then turn on the breaking change and see what would have.

Who is talking

bot
exchange
change

Compatibility matrix

exchange v1exchange v2
bot v1——
bot v2——

Green means the receiving side parses it and keeps trading.

On the wire


      

What the receiving model sees

—

Text or binary?

JSON or FIX (text). Readable and debuggable: you can watch a session in a terminal and understand it. Larger on the wire and slower to parse. The arena's choice: clarity over bytes for a teaching platform, and 8765 is not a colo cross-connect.

ITCH, OUCH, SBE (binary). Compact and fast, but opaque without the spec, and a wrong offset decodes garbage silently. Rule: stay text until profiling proves you need binary, and then you need the schema discipline above even more, not less.

This week's code: send typed, parse typed

import json
from shared.messages import PlaceOrder, parse_message, OrderAck, TradeExecution

# send: model -> JSON on the wire (never a raw dict)
order = PlaceOrder(team_id=TEAM_ID, symbol="AAPL",
    side="buy", order_type="limit", price=ask, quantity=1)
await ws.send(order.model_dump_json())

# receive: JSON -> validated model, then dispatch
raw = await ws.recv()
msg = parse_message(json.loads(raw))
if isinstance(msg, OrderAck):
    print("resting, queue_ahead =", msg.queue_ahead)
elif isinstance(msg, TradeExecution):
    print("filled @", msg.price)

Verify it by printing every parsed type for a whole session and confirming an OrderAck follows every PlaceOrder. If one is missing, you have found either a rate limit or a validation failure, and both are in the logs.

Part 8

8Six ways this goes wrong

The first one has cost every cohort at least one session. Open them.

Assuming one recv() equals one message

Over a raw TCP socket, json.loads(sock.recv(4096)) works perfectly on your laptop with one small message and fails the moment the market gets busy, because two sends coalesced into one read, or one send arrived in three pieces.

The failure is intermittent and load-dependent, which is the worst kind. It shows up in the live session and not in your test.

Fix: frame explicitly (length prefix or delimiter) and keep a buffer across reads, as in Part 4; or use WebSockets, which frame for you, which is exactly why the arena does.

Treating UDP market data as if it were reliable

A feed handler that just applies every datagram it gets will, sooner or later, miss an Order Delete and carry a phantom order on its book for the rest of the day. Nothing errors.

Fix: check every sequence number. On a gap, stop trusting the affected book, recover the missing range (re-request, the B feed, or a snapshot), and only then resume.

Sending raw dicts and hand-parsing raw JSON

await ws.send(json.dumps({'type': 'place_order', 'quatity': 5})) is accepted by json.dumps, accepted by the socket, and rejected by the exchange; or worse, silently interpreted with a missing field defaulting to something you did not intend.

On the receive side, raw['price'] against a message that does not have a price is a KeyError deep inside your strategy, mid-session, with a position open.

Fix: model.model_dump_json() to send; parse_message(json.loads(raw)) to receive. No exceptions, ever. This is graded.

Letting one bad frame kill the connection

An unknown type, a truncated frame, or a field the newer exchange added and your parser was configured to forbid, and an unhandled exception propagates out of your read loop and ends the session.

Being strict on the send path is discipline. Being strict on the receive path is fragility: you cannot control what a peer sends you.

Fix: try/except (KeyError, ValidationError) around the parse; log and continue. Ignore unknown fields inbound. Only close the connection if the garbage repeats.

A "small" breaking change to a live protocol

Renaming quantity to qty, tightening a type, or making an optional field required. Each is a one-line diff and each instantly breaks every peer that has not redeployed, including three teams whose bots are running from last week's branch.

The subtler version: keeping the field name but changing its meaning: price from dollars to cents, quantity from shares to lots. Nothing errors. Everything is wrong. (ITCH's 4 implied decimals are exactly this trap for anyone who forgets them.)

Fix: add, never change. New fields are optional with defaults; if you truly must replace one, carry both for a version and use an alias.

No heartbeat, no backoff, no resync

A half-open TCP connection looks perfectly alive from your side: no error, no data, forever. Your bot sits happily reading nothing while the session runs without it.

Then the reconnect makes it worse: a tight retry loop hammers a recovering exchange, and resuming without a fresh snapshot means you are trading against a book you remember rather than the one that exists.

Fix: ping/pong with a timeout, exponential backoff on reconnect, re-handshake, then wait for a fresh BookSnapshot before sending a single order.

Part 9

9Check yourself

Six questions, instant feedback, nothing recorded.

Q1Your packet crosses a router on its way to the exchange. Which is the highest layer the router has to read to forward it?

Q2You send() two JSON orders back to back over a raw TCP socket. The peer calls recv(4096) once. What can it get?

Q3An exchange must send the same book updates to 400 subscribers with the lowest and most even latency. What does it use?

Q4In a FIX NewOrderSingle you change 38=5 to 38=50. What else must change before you send it?

Q5Your bot receives a message whose type your parser does not recognise. The right behaviour is:

Q6You want to add queue_ahead to OrderAck mid-season, while other teams' bots are live. How?

Part 10

10Do the work

In class

  • Run the four programs in Part 4, then break the TCP client's framing on purpose and watch it fail.
  • Protocol contract review: read shared/messages.py in full and list every client and exchange message type.
  • Feed client/server design: sketch how your role sends and receives over the WebSocket.
  • Diagram the flow: Handshake → BookSnapshot → PlaceOrder → OrderAck/TradeExecution → PortfolioUpdate.
  • Phase-3 UML kickoff: extend Week 2's diagram with the wire messages and who sends what.

Before next class

  • Commit the message-flow diagram under docs/uml/.
  • Send one typed PlaceOrder and parse the OrderAck / TradeExecution in your bot.
  • Push the W3 feature branch (design plus parsing code).
  • Confirm no raw dict ever reaches ws.send.
Code
Get the code
Labs and homework live in your course’s LMS; the starter repo is public. Use it as a template, clone, make install.
Previous
← Week 2 · Concurrency & Async
The loop that reads the socket you just learned to frame.
Next
Week 4 · Backtesting and Its Data →
The same typed messages, recorded to a tape, stored as Parquet and replayed on the logical clock.