DEV Community

ABROWNFOX001
ABROWNFOX001

Posted on

TWAP Feed Engineering on Polymarket RTDS

Polymarket: https://polymarket.com/@abrownfox001?tab=activity

GitHub: https://github.com/abrownfox0/abrownfox001-twap60-prediction-trigger-system

Telegram: https://t.me/abrownfox001

If the TWAP feed is late, stale, disconnected, or pinned to the wrong slot open, every downstream “edge” is fake.

The contract the feed must satisfy

For BTC 5-minute Up/Down, settlement is a 30-second Chainlink TWAP, not a last trade.

The live engine therefore needs four values in memory at all times:

Field Meaning
slot_id Current btc-updown-5m-{ts}
open_ref Price-to-beat at slot start
twap_now Latest official TWAP
twap_ts Event time of that TWAP update

The signal module is only allowed to run if all four are present and fresh.

That rule is stricter than “WebSocket is connected.”

A connected socket can still be serving a dead value.

Why RTDS is the primary path

src/twapFeed.ts is built around Polymarket RTDS (wss://ws-live-data.polymarket.com).

Reasons:

  • It is the venue-facing stream, not a private Chainlink key path
  • It already carries the crypto TWAP topic used by short-duration markets
  • The same process can subscribe to book/user channels nearby
  • Reconnect policy stays inside one client instead of two oracle stacks

CEX mid (src/cexFeed.ts) is secondary. It can lead. It cannot resolve.

If CEX says up and TWAP says down, the engine believes TWAP.

Subscription shape

The 5-minute engine cares about the official short-window TWAP for btc/usd.

Conceptually:

{
  "topic": "crypto_prices_twap_thirty",
  "type": "update",
  "filters": "{\"symbol\":\"btc/usd\"}"
}
Enter fullscreen mode Exit fullscreen mode

Exact topic names can differ by SDK generation. The invariant does not:

  • subscribe by symbol, not by Polymarket market slug
  • store the latest TWAP value + timestamp
  • never infer TWAP from CLOB mid
  • never infer TWAP from a single Binance print

The market slug is joined later in src/markets.ts.

The oracle is joined first.

Reconnect is not “close and open again”

A 5-minute bot dies in the gaps.

The reconnect policy I actually want is:

  1. Detect dead socket: close, error, missed pong, or no TWAP update inside the freshness window
  2. Backoff: 250ms → 500ms → 1s → 2s → 5s, cap at 5s
  3. On resume, invalidate twap_now until a new stamped update arrives
  4. Do not reuse the pre-disconnect value
  5. If reconnect takes longer than one signal cycle, halt entries

The dangerous bug is optimistic reuse:

socket dropped
last TWAP = 111,842.17 from 8 seconds ago
bot keeps scoring as if that is live
Enter fullscreen mode Exit fullscreen mode

That value is now a snapshot. Snapshots are how pre-TWAP bots got wrecked.

After resume, the feed is unknown until the next valid update. Unknown means no new buys.

Freshness gates

I treat TWAP as a time series with an expiry.

Useful thresholds for a 5m / 30s-TWAP market:

Condition Action
Update age < 2s Healthy
Update age 2–5s Score, but do not increase size
Update age > 5s Block new entries
Update age > 8s Force scratch-or-hold freeze; no adds
No update across reconnect Hard halt

These numbers are policy, not magic. The point is that staleness is a trading signal.

A stale TWAP should look like a circuit breaker, not like a slightly old input.

Pseudo-check:

function twapUsable(sample: TwapSample, now: number) {
  if (!sample) return false;
  if (!Number.isFinite(sample.value)) return false;
  if (!sample.ts) return false;
  return now - sample.ts <= 5_000;
}
Enter fullscreen mode Exit fullscreen mode

If twapUsable is false, src/engine.ts cannot enter.

Open reference is a separate feed problem

People obsess over the live TWAP and then pin the wrong open.

The slot-open reference is the denominator of the whole trade:

[
\text{side} = \mathbf{1}{\text{TWAP}_{\text{settle}} > \text{open_ref}}
]

If open_ref is off by a few dollars, the model can look “confident” while betting the wrong binary.

Rules that belong in the feed layer, not the model layer:

  • Capture open_ref at slot start from the same official family as settlement
  • Freeze it for the life of that slot_id
  • Do not let it drift with later TWAP updates
  • If slot discovery is late, either wait for the official open or skip the slot
  • Never backfill open from a random CEX candle close

Late join is a real production case. The bot does not always wake at t=0.

A late join with a guessed open is worse than missing the slot.

Clock alignment

RTDS event time, local machine time, and slot boundary time are three clocks.

The engine should score on event time.

If the socket delivers a TWAP stamped T and local now is T+3s, the sample is 3s old.

If you use local now as the sample time, reconnect delay gets hidden.

Practical pattern:

receive update
parse value, event_ts
if event_ts > last_event_ts: accept
if event_ts <= last_event_ts: ignore (out-of-order)
age = local_now - event_ts
Enter fullscreen mode Exit fullscreen mode

Out-of-order updates happen after reconnect. Last-write-wins on arrival time will corrupt the series.

What the feed module should expose

src/twapFeed.ts should not expose “a number.”

It should expose a state object:

type TwapState = {
  symbol: "btc/usd";
  value: number | null;
  eventTs: number | null;
  recvTs: number | null;
  stale: boolean;
  connected: boolean;
  source: "rtds" | "gamma_fallback" | "none";
};
Enter fullscreen mode Exit fullscreen mode

Then the engine can log why it refused a slot:

  • stale=true
  • connected=false
  • source=none
  • open_ref missing

Silent skips are how you debug the wrong layer for a week.

Fallback policy

Gamma outcomePrices remains useful after the slot ends, as an authoritative resolved label.

It is a bad live TWAP substitute.

My rule:

  • Live scoring: RTDS TWAP only
  • Post-slot scoring / research labels: Gamma first, RTDS archive second
  • No live fallback to last CEX trade
  • No live fallback to CLOB mid

If RTDS is down, the correct production behavior is halt, not “use Binance for a minute.”

That minute is exactly when books are most distorted.

Failure modes this layer is built to kill

  1. Phantom edge from stale TWAP after a quiet socket
  2. Wrong binary from a guessed slot open
  3. Double-counting old and new TWAP after reconnect
  4. Scoring through a gap while the 30s window has already moved
  5. Training/live mismatch if research used snapshot close and prod uses TWAP

Part 1 said the signal is private.

That only matters if the feed is honest. A private model on a stale oracle is just a confident error generator.

Checklist before Part 3

If you are implementing this, do not touch signal weights until you can answer yes to all of these:

  • Do I know the exact slot open I will be judged against?
  • Is my live value the official TWAP, not a proxy?
  • Can I prove the last update is fresh in milliseconds?
  • Do I discard state on reconnect?
  • Do I halt when the feed is unknown?

Only then is P(up) allowed to exist.

Part 3 will define the signal contract: what P(up) is allowed to mean, when it may fire, and why most “direction models” are not valid inputs to a 5-minute TWAP market.

Not financial advice. Paper or micro-size first.

If you have more questions, please feel free to contact me at any time: https://t.me/abrownfox001

My Polymarket Activity: https://polymarket.com/@abrownfox001?tab=activity

#Polymarket #TWAP #RTDS #TradingBot #BTC #WebSocket #AlgoTrading #PredictionMarkets
Enter fullscreen mode Exit fullscreen mode

Top comments (0)