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.
shared/messages.py in full
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.
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".
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.
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.
| UDP | TCP | |
|---|---|---|
| Connection | None. sendto() any address at any time. | Three-way handshake (SYN, SYN-ACK, ACK) before any data; FIN or RST to close. |
| Ordering | None. Datagrams may arrive out of order. | Guaranteed: the receiver reorders by sequence number. |
| Reliability | None. A lost datagram is gone and nobody is told. | Every byte is acknowledged; missing bytes are detected. |
| Retransmit | Never (the application can ask, if its protocol allows). | Automatic: after three duplicate ACKs (fast retransmit) or a timeout. |
| Flow / congestion control | None. You can flood the network or the receiver. | Receive window (flow) plus congestion window (slow start, backoff on loss). |
| Boundaries | Message boundaries kept: one sendto() is one recvfrom(). | Byte stream: no boundaries; two sends can arrive as one recv. You frame it. |
| Head-of-line blocking | No. Each datagram is delivered as it arrives. | Yes. One lost segment holds back every byte after it until the retransmit lands. |
| Latency | One-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). |
| Multicast | Yes: one send reaches every host that joined the group. | No. Point to point only; N subscribers means N connections. |
| Header | 8 bytes. | 20–60 bytes. |
| Trading use | Market 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. |
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.
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.
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 (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)
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 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
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.
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
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
python3 tcp_echo_server.py, then terminal 2 python3 tcp_echo_client.py. The server handles one client and exits.python3 udp_receiver.py, then within 2 seconds terminal 2 python3 udp_sender.py. Start the sender first and the datagrams are simply lost: nobody was bound to the port, and nobody tells you.python3 mcast_receiver.py, a third python3 mcast_sender.py.recv_exactly(sock, length) with sock.recv(length) and send a 100 kB payload; or run the server twice and read the OSError: [Errno 48] Address already in use (98 on Linux).tcp_states.py from Part 3.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.
A FIX message is a list of tag=value fields, each ended by the SOH byte
(0x01, drawn here as |). Three parts:
8 BeginString, 9 BodyLength, 35 MsgType, then sender, target, 34 MsgSeqNum, 52 SendingTime.55 Symbol, 54 Side, 38 OrderQty, 44 Price.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.
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.
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.
| tag | name | value |
|---|
# 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.
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.
| type | message | bytes |
|---|---|---|
| S | System Event (start/end of day) | 12 |
| R | Stock Directory | 39 |
| A | Add Order (no MPID) | 36 |
| F | Add Order with MPID | 40 |
| E | Order Executed | 31 |
| X | Order Cancel (partial) | 23 |
| D | Order Delete | 19 |
| U | Order Replace | 35 |
| P | Trade (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.
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.
# 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 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.
| offset | field | len | type |
|---|---|---|---|
| 0 | Type = 'O' | 1 | alpha |
| 1 | Order Token | 14 | alpha |
| 15 | Buy/Sell | 1 | B S T E |
| 16 | Shares | 4 | uint32 |
| 20 | Stock | 8 | alpha |
| 28 | Price | 4 | uint32, 4 dp |
| 32 | Time in Force | 4 | uint32 s |
| 36 | Firm | 4 | alpha |
| 40 | Display, Capacity, ISO | 3 | alpha |
| 43 | Minimum Quantity | 4 | uint32 |
| 47 | Cross Type, Customer Type | 2 | alpha |
OUCH 5.0 replaces the token with a numeric UserRefNum and moves optional fields into appendages; the principle is unchanged.
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.
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.
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.
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.
| code | meaning |
|---|---|
| 1000 | Normal closure |
| 1001 | Going away (server shutting down, tab closed) |
| 1002 | Protocol error |
| 1003 | Unsupported data (binary to a text-only endpoint) |
| 1006 | Abnormal: no Close frame at all (reported locally, never sent) |
| 1008 | Policy violation (for example a bad auth token) |
| 1009 | Message too big |
| 1011 | Internal 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.
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).
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.
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.
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.)
| Concept | Where it lives in AlgoArena | Real-venue analogue |
|---|---|---|
| L7 messages | JSON 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 |
| Framing | WebSocket frames: one await ws.recv() is one message. You never see a partial order. | FIX BodyLength; SoupBinTCP and MoldUDP64 length fields |
| Session open | Handshake(team_id, role, level, token), answered by SessionEvent (SESSION_PREOPEN / SESSION_OPEN). | FIX Logon 35=A; SoupBinTCP Login |
| Heartbeat | WebSocket ping/pong from the websockets library, every 20 s by default. | FIX Heartbeat / TestRequest |
| Order entry | PlaceOrder → OrderAck (with queue_ahead), then TradeExecution and PortfolioUpdate. | FIX NewOrderSingle → ExecutionReport; OUCH O → A, E |
| Market data | BookSnapshot pushed on the same TCP connection, per team. | ITCH / MDP 3.0 over UDP multicast |
| Recovery | No 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 security | ws:// 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.
TCP, then the HTTP upgrade, then WebSocket frames carrying the arena
protocol. Field names are the ones in shared/messages.py.
$ 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.
11 ClOrdID exists for exactly this).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.
msg['quatity']: a typo is a KeyError at runtime, in production, mid-session.order.quantity: the IDE completes it, the type checker verifies it, the validator guarantees it is an int.ValidationError naming the field.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.
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
—
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.# 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.
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.
| exchange v1 | exchange v2 | |
|---|---|---|
| bot v1 | — | — |
| bot v2 | — | — |
Green means the receiving side parses it and keeps trading.
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.
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.
The first one has cost every cohort at least one session. Open them.
recv() equals one messageOver 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.
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.
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.
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.
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.
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.
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?
shared/messages.py in full and list every client and exchange message type.Handshake → BookSnapshot → PlaceOrder → OrderAck/TradeExecution → PortfolioUpdate.docs/uml/.PlaceOrder and parse the OrderAck / TradeExecution in your bot.ws.send.make install.