Skip to main content

Quantago — Real-Time Chart Debug Analysis

Date: 27 April 2026
Status: Live chart updates not working. Historical data loads fine.
Continuation model: Claude Opus 4.7


1. Goal​

Candlestick chart that:

  • Loads historical OHLCV bars on mount
  • Updates the last (current) candle in real time as trades arrive from Finnhub
  • Works across all timeframes: RT (1m bucket), 1m, 5m, 15m, 1H, 1D

2. Architecture Overview​

Finnhub wss://ws.finnhub.io
↓
FinnhubStreamDO (Cloudflare DO)
- Outbound WebSocket to Finnhub
- On each trade: POST to MarketDataDO /broadcast
- On each trade: POST to TickAggregatorDO /tick
↓
MarketDataDO ("global-market-stream")
- Accepts inbound browser WebSockets
- Broadcasts { type: "tick", payload: RealtimeTickEvent } to all clients
↓
Browser (tick WS → /api/realtime/ws?symbol=EURUSD)
- Receives ticks, calls chartRef.current.addTick(ts, price)
- addTick() does client-side OHLCV aggregation in LiveCandleChart.tsx

Historical data path:

Browser → GET /api/candles/historical?symbol=EURUSD&timeframe=1&outputsize=300
→ Worker checks memory cache (90s) → ClickHouse cache → Twelve Data API
→ Returns OHLCBar[] → LiveCandleChart.setData()

Files changed this session:

  • services/frontend/src/components/charts/LiveCandleChart.tsx — chart component
  • services/frontend/src/routes/data-explorer/DataExplorerPage.tsx — page component
  • services/backend/src/routes/candles/candles-router.ts — historical + WS endpoint
  • services/backend/src/routes/realtime/realtime-handlers.ts — tick WS endpoint
  • services/backend/src/realtime/finnhub-stream-do.ts — Finnhub outbound WS
  • services/backend/src/realtime/market-data-do.ts — browser WS fan-out hub
  • services/backend/src/realtime/tick-aggregator-do.ts — candle builder (currently unused in frontend)

3. What Was Tried (Chronological)​

Attempt 1 — React state via candle WS (original)​

  • Approach: Two WebSockets: tick WS (bid/ask) + candle WS (/api/candles/ws → TickAggregatorDO). TickAggregatorDO built OHLCV candles server-side and broadcast them. setLiveCandle() → React state → chart update().
  • Problem: The last candle never updated. The liveCandle state update in the useEffect dep array would throw silently if the incoming candle's timestamp ≤ last historical bar (edge case from Twelve Data including the in-progress bar). No try/catch meant subsequent updates also stopped. Also: TickAggregatorDO needed to be "warm" and the candle WS connection sometimes bootstrapped before FinnhubStreamDO had subscribed.

Attempt 2 — RT mode as a separate line chart​

  • Approach: Added mode="realtime" prop to LiveCandleChart. RT showed a Line series (price over time), fed via addTick(Date.now(), price) using Date.now() as the timestamp. Historical 1m closes were seeded via setHistory().
  • Problem: (a) RT chart was an empty line chart — the seed was close prices, not real timestamps aligned to the live ticks. (b) Using Date.now() instead of actual Finnhub trade timestamps meant the live line was misaligned with history. (c) Line chart is not what the user wants — they want candlesticks.

Attempt 3 — Client-side tick aggregation, candlesticks only (current)​

  • Approach: Removed the line chart entirely. LiveCandleChart is now candlestick-only. addTick(t, price) does client-side OHLCV bucket aggregation using timeframeBucketMs. Historical bars seed currentCandleRef. Single tick WS drives updates for all timeframes. Removed candle WS entirely.
  • Problem: Still not updating in real-time. The Bid/Ask header shows --. No tick messages visible in browser network panel. Root causes unknown but suspected (see §5).

4. Current Code State​

LiveCandleChart.tsx​

  • forwardRef candlestick chart with addTick(t: number, price: number) imperative handle
  • addTick computes bucket start = Math.floor(t / bucketMs) * bucketMs
  • New bucket → opens new candle bar; same bucket → extends H/L/C
  • currentCandleRef seeded from last historical bar on historicalBars change
  • No React state for live updates — all done via seriesRef.current.update()

DataExplorerPage.tsx​

  • Single tick WS (createTickSocket(symbol)) for all timeframes
  • On every { type: "tick", payload } → chartRef.current.addTick(tickMs, midPrice)
  • Historical bars fetched via fetchHistoricalCandles (timeframe = '1' for RT with 60 bars, otherwise normal TF with 300 bars)
  • TF_BUCKET_MS map: { RT: 60_000, '1': 60_000, '5': 300_000, '15': 900_000, '60': 3_600_000, 'D': 86_400_000 }
  • No candle WS used anywhere

realtime-handlers.ts​

  • handleRealtimeWebsocket: calls ensureTickProviderRunning (fire-and-forget), then proxies WS upgrade to MarketDataDO
  • ensureTickProviderRunning: checks FinnhubStreamDO status, starts/subscribes if needed

candles-router.ts​

  • /ws endpoint still exists (points to TickAggregatorDO) but frontend no longer uses it
  • /historical endpoint used for historical bars with 90s memory cache + ClickHouse fallback

5. Remaining Problem — Why Ticks Don't Arrive​

The browser network panel shows:

  • ✅ ws?symbol=EURUSD → status 101 (WebSocket open, "Pending")
  • ✅ historical?... → 200 (history loads fine)
  • ❌ No tick data visible in WS frames
  • ❌ Bid/Ask display shows --

Probable Cause A — Market Hours (HIGH PROBABILITY)​

Today is Sunday 27 April 2026. Forex markets are closed from Friday ~22:00 UTC until Sunday ~22:00 UTC. EUR/USD (OANDA:EUR_USD) has zero trades on Sundays. Finnhub sends nothing for it. The WebSocket is connected but silent — not a bug, just no data.

Verification: Switch to a crypto pair (BTC/USD = BINANCE:BTCUSDT). Crypto trades 24/7. If ticks arrive for BTC/USD, the architecture works and the forex issue is purely market hours.

Probable Cause B — FinnhubStreamDO Not Starting (MEDIUM)​

ensureTickProviderRunning is fired as void (async () => { ... })() (fire-and-forget). The WS upgrade to MarketDataDO completes immediately. If the async bootstrap fails (network error, Finnhub API key issue, DO cold start latency), no one retries it. The browser is connected to MarketDataDO but FinnhubStreamDO never subscribes.

There is no feedback to the browser about whether the upstream is running.

Probable Cause C — DO Hibernation Kills setTimeout (HIGH)​

FinnhubStreamDO.scheduleReconnect() uses setTimeout. Cloudflare DOs do not persist setTimeout across hibernation. If the DO is evicted (e.g., after first WS bootstrap, while Finnhub is quiet because it's Sunday), the reconnect timer is lost. On the next browser connect, ensureTickProviderRunning will see connected: false (DO was evicted, in-memory state reset) and re-start. But if the DO was evicted mid-session while a browser was connected, ticks just stop silently.

Fix: Replace setTimeout with this.state.storage.setAlarm(Date.now() + 5000) and implement alarm() handler.

Probable Cause D — MarketDataDO Uses Old WebSocket API (MEDIUM)​

MarketDataDO uses socket.accept() (the non-hibernation WebSocket API). According to Cloudflare docs, DOs using this API do not survive hibernation — all in-memory sessions are wiped when the DO is evicted. Browser is still technically "connected" at the TCP level for a short window, then receives a close frame.

The frontend has no auto-reconnect logic:

ws.onclose = () => { if (mounted) setIsTickConnected(false); };
// no retry, no exponential backoff

Probable Cause E — Tick Path Latency Cascade (LOW — but worth noting)​

Every Finnhub trade triggers:

  1. FinnhubStreamDO.broadcastTick() → awaits HTTP POST to MarketDataDO /broadcast
  2. FinnhubStreamDO.broadcastTick() → awaits HTTP POST to TickAggregatorDO /tick

These are sequential await calls per trade. If Finnhub sends multiple trades in a burst, each one blocks while waiting for two HTTP DO round-trips. This could cause backpressure but wouldn't cause complete silence.


6. WebSocket vs. SSE — Architectural Analysis​

Can WebSocket work in this architecture?​

Yes, but requires fixes:

Cloudflare DOs fully support both inbound and outbound WebSockets. The issues are not fundamental — they're implementation bugs:

IssueFix
setTimeout lost on hibernationUse this.state.storage.setAlarm() + alarm() handler
MarketDataDO sessions lost on evictionUse Hibernation API: this.state.acceptWebSocket(server) + webSocketMessage() / webSocketClose() handlers
No frontend reconnectAdd exponential-backoff reconnect in tick WS effect
No startup feedbackAdd a /api/realtime/stream-status?symbol=X endpoint the browser can poll before connecting

Would SSE be better?​

SSE (Server-Sent Events) has the same fundamental constraints in Cloudflare Workers:

  • A DO would hold the open response stream (equivalent to holding a WS session)
  • DO eviction still kills the open stream
  • SSE is unidirectional (browser can't send) — fine for this use case but no advantage
  • SSE has no native browser reconnect for DO-backed responses (browser reconnects to the Worker URL, which would require a new DO stub fetch anyway)

Conclusion: SSE doesn't solve the root problems. WebSocket is the right choice — it just needs the hibernation API fixes.


Priority 1 — Verify market hours first​

Test with BTC/USD (crypto, 24/7). If it works → the architecture is fine, the issue is simply that forex is closed on Sundays.

Priority 2 — Fix MarketDataDO hibernation​

Replace socket.accept() pattern with Cloudflare's Hibernation WebSocket API:

// In MarketDataDO.fetch():
this.state.acceptWebSocket(server); // instead of server.accept()
return new Response(null, { status: 101, webSocket: client });

// Add handlers:
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { /* handle client → server */ }
webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) { /* cleanup */ }
webSocketError(ws: WebSocket, error: unknown) { /* cleanup */ }

// To broadcast, use:
this.state.getWebSockets().forEach((ws) => ws.send(payload));

Priority 3 — Fix FinnhubStreamDO reconnect​

Replace setTimeout with the Alarm API:

private scheduleReconnect() {
const delay = Math.min(this.reconnectDelay * 2, 60_000);
this.reconnectDelay = delay;
void this.state.storage.setAlarm(Date.now() + delay);
}

async alarm() {
if (!this.stopped && !this.connected) {
this.connect();
}
}

Priority 4 — Frontend auto-reconnect​

function createReconnectingSocket(url: string, onMessage: (data: string) => void, onStatus: (connected: boolean) => void) {
let ws: WebSocket;
let dead = false;
let retryMs = 1000;

function connect() {
ws = new WebSocket(url);
ws.onopen = () => { onStatus(true); retryMs = 1000; };
ws.onmessage = (e) => onMessage(e.data as string);
ws.onclose = () => {
onStatus(false);
if (!dead) setTimeout(connect, retryMs);
retryMs = Math.min(retryMs * 2, 30_000);
};
}
connect();
return () => { dead = true; ws.close(); };
}

Priority 5 — Parallel DO tick broadcast​

broadcastTick currently awaits MarketDataDO and TickAggregatorDO sequentially. Make them parallel:

await Promise.allSettled([broadcastToMarketData(), broadcastToTickAggregator()]);

8. Quick Diagnostic Checklist​

Before rewriting anything, run these checks:

  1. Switch to BTC/USD and wait 10-30s — if ticks arrive, the architecture works and the only issue is forex market hours.

  2. Check Cloudflare Worker logs (Workers dashboard → quantago-api → Logs):

    • Look for [FinnhubStreamDO] Connected to Finnhub WebSocket
    • Look for [FinnhubStreamDO] Subscribed to
    • Look for any errors in broadcastTick
  3. Manually probe the Finnhub status endpoint:

    GET https://api.quantago.co/api/realtime/ws?symbol=EURUSD
    (with Upgrade: websocket header)

    Before connecting, call:

    GET https://api.quantago.co/api/health  (or a status endpoint)

    There is currently no /api/realtime/status endpoint — add one that returns FinnhubStreamDO.status.

  4. Check Finnhub plan: Log into finnhub.io dashboard and verify OANDA:EUR_USD is available on the current subscription tier. Free tier should include forex from OANDA, but confirm.


9. File Reference​

FilePurpose
services/backend/src/realtime/finnhub-stream-do.tsOutbound WS to Finnhub + tick dispatch
services/backend/src/realtime/market-data-do.tsBrowser WS fan-out hub (needs hibernation API fix)
services/backend/src/realtime/tick-aggregator-do.tsServer-side OHLCV builder (currently unused in frontend)
services/backend/src/routes/realtime/realtime-handlers.tsensureTickProviderRunning + WS proxy
services/backend/src/routes/candles/candles-router.tsHistorical + TickAggregatorDO WS endpoint
services/frontend/src/components/charts/LiveCandleChart.tsxCandlestick chart with imperative addTick
services/frontend/src/routes/data-explorer/DataExplorerPage.tsxPage: WS effects, history loading
services/frontend/src/lib/realtime/client.tscreateTickSocket, fetchHistoricalCandles helpers
services/backend/wrangler.jsoncDO bindings: MARKET_DATA_DO, FINNHUB_STREAM_DO, TICK_AGGREGATOR_DO

10. Credentials (for backend testing)​

  • FINNHUB_API_KEY=d7nkhkhr01qppri577ogd7nkhkhr01qppri577p0
  • Deployed backend: https://api.quantago.co
  • Deployed frontend: https://quantago-app.pages.dev (latest: fcd1b545.quantago-app.pages.dev)