Skip to content

feat(simulation): migrate the price level stream to Titan V2 - #1597

Open
tvinagre wants to merge 1 commit into
mainfrom
tl/price-level-stream-v2
Open

tvinagre wants to merge 1 commit into
mainfrom
tl/price-level-stream-v2

Conversation

@tvinagre

@tvinagre tvinagre commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Titan now serves pAMM price levels on a new socket, pamm_price_levels_v2. The V1 socket is on
    its way out.
  • This change reads the V2 payload and deletes the V1 path.
  • What the stream serves does not change: integer price ladders, same states, same component ids.
    What changes is the payload it reads and the arithmetic needed to get from it to a ladder.

Terms used below

  • frame — one WebSocket message: a block number, a build timestamp, and a list of books.
  • book — one venue's quotes for one token pair: a bids list and an asks list.
  • level — one row of a book.
  • ladder — the integer amount_in/amount_out form the stream serves. Quoting interpolates
    between levels.

Counter names below are written without their price_level_stream_ prefix.

What changed in the message, side by side

V1 pamm_price_levels V2 pamm_price_levels_v2
structure pamms[] → pairs[], one entry per direction flat books[], one per venue and pair, each with bids and asks
tokens named by address (tokenIn, tokenOut) symbol (token1, token2)
level values integer amountIn / amountOut, hex, absolute float price and cumVolume, human units
level kind variant: Simulated | Interpolated dropped
block fields blockNumber, timestamp block, ts
added — calculated_from, maker

The same two books, as each version sends them (levels trimmed to two, both captures are in
test_responses/):

// V1: absolute integers, and one entry per direction
{ "blockNumber": 25538727, "timestamp": 1784126589047308938,
  "pamms": [{
    "pamm": "0x5979458912f80b96d30d4220af8e2e4925a33320",
    "pairs": [{
      "tokenIn":  "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599",   // WBTC, by address
      "tokenOut": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",   // USDC, by address
      "orderBook": [
        { "amountIn": "0xc350",  "amountOut": "0x1f27427", "variant": "Simulated"    },
        { "amountIn": "0x11704", "amountOut": "0x2c813a5", "variant": "Interpolated" }]}]}]}
// V2: floats, symbols, both directions in one book, plus a pair version
{ "block": 26148760, "ts": 1791475493806326998, "slot": 15387623,
  "books": [{
    "maker": "TaurusFi",
    "quoteAddress": "0x3ce2672aa806138585920421406bf0decb8130cb",
    "token1": "WETH", "token2": "USDC",                           // symbols, not addresses
    "calculated_from": 283851543383726791766048823,               // the pair version
    "bids": [
      { "price": 2429.6279999999997, "cumVolume": 0.005 },
      { "price": 2429.627980735273,  "cumVolume": 0.008118883695943609 }],
    "asks": [ /* … */ ]}]}

Two V2 fields need no review here:

  • calculated_from names the pair version the venue priced the book at. This change parses and
    validates it. Nothing reads it. The stacked PR joins it to a state snapshot.
  • slot reaches the DEBUG logs and nothing else. No freshness decision rests on it.

What the stream does with a book

flowchart TD
  B["one V2 book"] --> V{"venue known?"}
  V -- no --> U["skip<br/>unregistered_pamm_entries_total"]
  V -- yes --> S{"both symbols bound,<br/>both tokens admitted?"}
  S -- no --> K["skip<br/>skipped_books_total{reason}"]
  S -- yes --> C["convert each side:<br/>floats → integer ladder"]
  C --> R{"side prices a<br/>usable curve?"}
  R -- no --> D["drop that side<br/>rejected_sides_total{reason}"]
  R -- yes --> P["served under fallback:{venue}"]
Loading

Floats to integer ladders

  • price is token2 per token1: the average execution price of the level's whole cumulative
    size.
  • cumVolume is whole token1, cumulated from the top of the book.
  • So bids sell token1, and asks sell token2.
bids (token1 -> token2):
  amount_in  = round(cumVolume · 10^decimals(token1))
  amount_out = round(price · cumVolume · 10^decimals(token2))

asks (token2 -> token1):
  amount_in  = round(price · cumVolume · 10^decimals(token2))
  amount_out = round(cumVolume · 10^decimals(token1))
  • Each level becomes one absolute quote, whose amount_out already contains every smaller level.
  • Verified by capturing both sockets about 1 ms apart and matching the integers V1 sent. That pins
    the interpretation on the levels the capture measured, and no further: it is not proof of parity
    with what the venue computes internally. Titan also rounds price and cumVolume to f64
    before sending, so a ladder carries Titan's precision and nothing tighter.

Rules worth a reviewer's attention

  • A symbol is not an identity. Resolution never reads the symbols in your token metadata — if
    it did, any token calling itself WBTC would capture the real one's quotes. A symbol prices a
    pair only when a trusted binding names an address for it and your with_tokens metadata
    carries that address. Exact match, case included. Defaults bind WETH, USDC, USDT, WBTC and
    cbBTC; with_symbol_bindings replaces a default or adds a symbol.
  • A side that does not price a usable curve is dropped whole. Dropping one bad level instead
    would leave the ladder interpolating across the gap, inventing a plausible price.
    rejected_sides_total{reason} counts the four reasons. Two are new arithmetic on this format:
    zero_amount (a level rounds to zero in or out) and conflicting_knots (two levels round to
    one amount_in but disagree on amount_out). A drop costs one direction; the other side of the
    book keeps serving.
  • Venue addresses are canonicalized. A venue's identity in Tycho is the address a swap goes
    to, but Titan streams some venues under a different quoteAddress, which is a join key rather
    than an executable address. Every such address is normalized through the alias map at each
    boundary, so component ids, venue names and pamm_address always carry the public identity.
    Kipseli is today's only case.
  • No streamed value becomes a metric label. Addresses, component ids and pair versions stay in
    DEBUG logs. Every counter is pre-initialized at zero.

Rollout

  • An environment that pins TITAN_PAMM_PRICE_LEVEL_URL to a V1 regional URL keeps running after
    this upgrade, feeding V1 frames to a V2 parser that rejects them. Update or unset that
    variable at rollout.
  • The V1 fixture pamm_price_levels_1784126589047308938.json is replaced by a byte-faithful V2
    capture.

Docs and tests

  • docs/for-solvers/price-level-stream.md is a new solver page covering the message, the conversion,
    symbol resolution, venue identity, freshness and the metrics.
    request-for-quote-protocols.md keeps a pointer to it instead of its inline section.
  • The module suite goes from 129 to 151 tests. Every test runs against a local socket; none dials
    a live endpoint.

Titan replaced the V1 price level socket with a V2 socket. The stream
now reads the V2 wire, and the V1 path and its fixture are removed. V2
books name their venue by quote address and their assets by symbol, and
they carry float price and cumulative volume levels. A conversion step
rebuilds the integer ladders that the quoting model uses. Symbols
resolve only through the trusted binding table before admission, and
skipped books and dropped sides are counted. A new solver page
documents the wire, the conversion, and the freshness contract. The
module suite grows from 129 to 151 tests.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

1 participant