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 componentservices/frontend/src/routes/data-explorer/DataExplorerPage.tsx— page componentservices/backend/src/routes/candles/candles-router.ts— historical + WS endpointservices/backend/src/routes/realtime/realtime-handlers.ts— tick WS endpointservices/backend/src/realtime/finnhub-stream-do.ts— Finnhub outbound WSservices/backend/src/realtime/market-data-do.ts— browser WS fan-out hubservices/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).TickAggregatorDObuilt OHLCV candles server-side and broadcast them.setLiveCandle()→ React state → chartupdate(). - Problem: The last candle never updated. The
liveCandlestate update in theuseEffectdep 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:TickAggregatorDOneeded to be "warm" and the candle WS connection sometimes bootstrapped beforeFinnhubStreamDOhad subscribed.
Attempt 2 — RT mode as a separate line chart
- Approach: Added
mode="realtime"prop toLiveCandleChart. RT showed a Line series (price over time), fed viaaddTick(Date.now(), price)usingDate.now()as the timestamp. Historical 1m closes were seeded viasetHistory(). - 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.
LiveCandleChartis now candlestick-only.addTick(t, price)does client-side OHLCV bucket aggregation usingtimeframeBucketMs. Historical bars seedcurrentCandleRef. 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
forwardRefcandlestick chart withaddTick(t: number, price: number)imperative handleaddTickcomputes bucket start =Math.floor(t / bucketMs) * bucketMs- New bucket → opens new candle bar; same bucket → extends H/L/C
currentCandleRefseeded from last historical bar onhistoricalBarschange- 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_MSmap:{ 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: callsensureTickProviderRunning(fire-and-forget), then proxies WS upgrade toMarketDataDOensureTickProviderRunning: checksFinnhubStreamDOstatus, starts/subscribes if needed
candles-router.ts
/wsendpoint still exists (points toTickAggregatorDO) but frontend no longer uses it/historicalendpoint 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:
FinnhubStreamDO.broadcastTick()→ awaits HTTP POST toMarketDataDO /broadcastFinnhubStreamDO.broadcastTick()→ awaits HTTP POST toTickAggregatorDO /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:
| Issue | Fix |
|---|---|
setTimeout lost on hibernation | Use this.state.storage.setAlarm() + alarm() handler |
MarketDataDO sessions lost on eviction | Use Hibernation API: this.state.acceptWebSocket(server) + webSocketMessage() / webSocketClose() handlers |
| No frontend reconnect | Add exponential-backoff reconnect in tick WS effect |
| No startup feedback | Add 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.
7. Recommended Fix Plan (for Claude Opus 4.7)
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:
-
Switch to BTC/USD and wait 10-30s — if ticks arrive, the architecture works and the only issue is forex market hours.
-
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
- Look for
-
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/statusendpoint — add one that returnsFinnhubStreamDO.status. -
Check Finnhub plan: Log into finnhub.io dashboard and verify
OANDA:EUR_USDis available on the current subscription tier. Free tier should include forex from OANDA, but confirm.
9. File Reference
| File | Purpose |
|---|---|
services/backend/src/realtime/finnhub-stream-do.ts | Outbound WS to Finnhub + tick dispatch |
services/backend/src/realtime/market-data-do.ts | Browser WS fan-out hub (needs hibernation API fix) |
services/backend/src/realtime/tick-aggregator-do.ts | Server-side OHLCV builder (currently unused in frontend) |
services/backend/src/routes/realtime/realtime-handlers.ts | ensureTickProviderRunning + WS proxy |
services/backend/src/routes/candles/candles-router.ts | Historical + TickAggregatorDO WS endpoint |
services/frontend/src/components/charts/LiveCandleChart.tsx | Candlestick chart with imperative addTick |
services/frontend/src/routes/data-explorer/DataExplorerPage.tsx | Page: WS effects, history loading |
services/frontend/src/lib/realtime/client.ts | createTickSocket, fetchHistoricalCandles helpers |
services/backend/wrangler.jsonc | DO 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)