Chain frames
Every publication on a chain: channel is one binary frame in the CCH2 format. All values are little endian. See the Realtime API for how to connect. chain: frames carry quotes and aren't customer delivery; customer sockets receive the derived Arrow tables described under Frames.
Header
| Field | Type | Notes |
|---|---|---|
magic | 4 bytes | CCH2 |
kind | u8 | 1 full frame, 2 delta frame |
tier | u8 | 1 100ms, 2 1s, 3 1m, 4 15s, 5 30s |
root_len | u8 | Length of root |
root | bytes | The option root |
seq | u64 | Counts per channel |
ts_ns | u64 | Frame time in nanoseconds since the Unix epoch |
model_version | u16 | Version of the pricing model |
Expiry
The expiry block follows the header in both frame kinds.
| Field | Type | Notes |
|---|---|---|
expiry_days | u16 | Days since 1970-01-01 |
forward_e4 | i32 | Implied forward, in 1/10,000 USD |
spot_e4 | i32 | Implied spot, in 1/10,000 USD |
gex, dex, vex, cex | f32 each | Expiry totals |
call_oi, put_oi | u32 each | Open interest totals |
Spot comes from the options market through put-call parity, not from an equity feed.
Full frame
A u16 contract count follows the expiry block, and then each contract in this order.
| Bit | Field | Type | Notes |
|---|---|---|---|
strike_e3 | i32 | Strike, in 1/1,000 USD | |
right | u8 | 1 call, 2 put | |
| 0 | bid_e4 | i32 | 1/10,000 USD |
| 1 | ask_e4 | i32 | 1/10,000 USD |
| 2 | bid_sz | u32 | Contracts |
| 3 | ask_sz | u32 | Contracts |
| 4 | oi | u32 | Open interest |
| 5 | iv | f32 | A fraction, so 0.2 is 20%, with no cap |
| 6 | delta_e4 | i16 | 1/10,000 |
| 7 | gamma | f32 | Per $1 |
| 8 | vanna | f32 | Per vol point |
| 9 | charm | f32 | Per calendar day |
| 10 | gex | f32 | Per 1% move |
| 11 | dex | f32 | Dollars of delta notional |
| 12 | vex | f32 | Per vol point |
| 13 | cex | f32 | Per day |
Missing values
A contract field with no value carries a marker. The decoder below returns each of these as null.
| Field | Type | Missing value |
|---|---|---|
bid_e4 | i32 | 0 |
ask_e4 | i32 | 0 |
delta_e4 | i16 | -32768 |
iv | f32 | NaN |
gamma | f32 | NaN |
vanna | f32 | NaN |
charm | f32 | NaN |
gex | f32 | NaN |
dex | f32 | NaN |
vex | f32 | NaN |
cex | f32 | NaN |
bid_sz, ask_sz and oi have no marker: 0 means zero.
Per-contract greeks and exposures are rounded to half precision, so don't expect more than about three significant digits.
Delta frame
Only the 100ms cadence sends delta frames. A u16 change count follows the expiry block, and then each change:
| Field | Type | Notes |
|---|---|---|
idx | u16 | The contract's position in the last full frame |
mask | u16 | Which fields follow, using the bit numbers above |
| Values | varies | The set fields, in bit order |
Apply a delta only if its seq is exactly one more than the previous publication's. If it isn't, you have a gap, so treat the chain as unknown until the next full frame arrives. Full frames come every second at this cadence.
Exposure conventions
Exposures assume dealers are short calls and long puts, with sign of +1 for calls and -1 for puts. All are in dollars of delta notional.
| Field | Formula |
|---|---|
gex | sign × gamma × oi × 100 × spot² × 0.01 |
dex | delta × oi × 100 × spot |
vex | sign × vanna × oi × 100 × spot |
cex | charm × oi × 100 × spot |
Decoder
A TypeScript decoder for both frame kinds. It returns missing values as null.
const FIELDS = ['bid', 'ask', 'bidSize', 'askSize', 'oi', 'iv', 'delta', 'gamma', 'vanna', 'charm', 'gex', 'dex', 'vex', 'cex'];
export function decode(bytes: Uint8Array) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let at = 0;
const step = (size: number) => (at += size) - size;
const u8 = () => view.getUint8(step(1));
const u16 = () => view.getUint16(step(2), true);
const u32 = () => view.getUint32(step(4), true);
const i32 = () => view.getInt32(step(4), true);
const f32 = () => view.getFloat32(step(4), true);
const u64 = () => view.getBigUint64(step(8), true);
const text = (size: number) => new TextDecoder().decode(bytes.subarray(step(size), at));
const float = () => { const value = f32(); return Number.isNaN(value) ? null : value; };
const price = () => { const value = i32(); return value === 0 ? null : value / 1e4; };
const field = (index: number) => {
if (index < 2) return price();
if (index < 5) return u32();
if (index === 6) { const value = view.getInt16(step(2), true); return value === -0x8000 ? null : value / 1e4; }
return float();
};
if (text(4) !== 'CCH2') throw new Error('Unknown frame');
const kind = u8();
const tier = u8();
const root = text(u8());
const seq = u64();
const timeNs = u64();
const modelVersion = u16();
const expiry = {
date: new Date(u16() * 86_400_000).toISOString().slice(0, 10),
forward: i32() / 1e4, spot: i32() / 1e4,
gex: f32(), dex: f32(), vex: f32(), cex: f32(),
callOi: u32(), putOi: u32(),
};
const header = { kind, tier, root, seq, timeNs, modelVersion, expiry };
const count = u16();
if (kind === 1) {
const contracts = Array.from({ length: count }, () => {
const contract: Record<string, unknown> = { strike: i32() / 1e3, right: u8() === 1 ? 'C' : 'P' };
FIELDS.forEach((name, index) => { contract[name] = field(index); });
return contract;
});
return { ...header, contracts };
}
const changes = Array.from({ length: count }, () => {
const index = u16();
const mask = u16();
const values: Record<string, number | null> = {};
FIELDS.forEach((name, bit) => { if (mask & (1 << bit)) values[name] = field(bit); });
return { index, values };
});
return { ...header, changes };
}