Back to Insights
Platform Engineering·2026-04-18·4 min read

The /signals page went from 20 seconds to 500ms — here's the one change that did it.

We had a 14-strategy dashboard that took up to 30 seconds to load on a cold visit. Every click triggered 24 OANDA API calls, three MSR fits, an XGBoost train, and a Johansen test. Then we stopped doing any of that on request.

By Li Tan

Here's a simple truth about financial data: 99% of the time it hasn't moved since you last looked. OANDA's daily bars close once per session. The Fed publishes the policy rate monthly. BIS updates call rates at a monthly cadence. Whether you check the page now or 15 minutes from now, the numbers are the same.

So why was our /signals page computing everything from scratch on every single visit?

The old flow

A cold hit on /signals triggered an SSE stream that walked through four steps:

  • Step 1: Fetch 24 OANDA instruments × 1000 daily bars each. ~6 seconds.
  • Step 2: Fetch 7 Yahoo Finance assets (crypto + VIX). ~3 seconds.
  • Step 3: Run 14 strategies. Pair trading scans all combinations with cointegration tests. Cointegration basket runs Johansen. XGBoost trains a walk-forward model. MSR fits Hamilton-style regime regression. ~10-15 seconds total.
  • Step 4: Detect market regimes per asset category. ~1 second.

Total: 20-30 seconds, every time. Every refresh. Every new visitor. Every F5.

We had a progress modal with a spinner and encouraging messages ("Running XGBoost...", "Detecting market regimes...") to keep users engaged during the wait. It worked, sort of. But it was putting lipstick on a pig. 20 seconds to show a dashboard is 20 seconds too long, no matter how pretty the loading animation.

The change

One script. One systemd timer. One new endpoint. That's the whole thing:

  • scripts/refresh_signals_cache.py — runs the exact _compute_all_signals pipeline from the SSE path, serializes the output to data/live_signals_cache.json.
  • systemd/openalpha-signals-cache.timer — triggers the script every 15 minutes (OnUnitActiveSec=15min), with a 2-minute post-boot warm-up.
  • GET /api/v1/live/signals-cached — reads the JSON, applies the same tier filter as the live endpoint, returns in ~200ms.
  • Frontend: on page mount, try the cached endpoint first. If it returns valid data, display immediately. Only fall back to the SSE stream if the cache is missing or malformed.

Result: 200-500ms on first paint. The 'Refresh' button still triggers the full SSE compute for users who want absolutely-fresh numbers — but 95% of visitors never click it, and the cache is at most 15 minutes old anyway.

What this cost us

Strictly: freshness. A signal generated at 14:02:30 UTC won't show up to users until the next cache refresh at 14:15. For a platform that trades on daily bars closing at 22:00 UTC, this is nothing. For a platform trading intraday, it would be a disaster. We're the former.

Slightly less strictly: server cost. We're now computing the full pipeline every 15 minutes even when nobody visits the page. But the compute is cheap (~10 seconds on a 2GB VPS every 15 min = 10s × 96 runs/day = ~16 min/day of CPU), and in exchange we've eliminated load spikes from bot crawlers and user bursts that used to concurrently hammer the synchronous endpoint.

The boring lesson: if your expensive work doesn't change faster than users check it, precompute and cache it. Don't decorate the wait with a pretty spinner.

For sub-15-minute freshness, there's still the Refresh button and the SSE stream endpoint. For the default experience — the one where you land on the page and want to see the current state of 16 strategies — 500ms is our new normal.

Like this kind of analysis? Upgrade to Learner for weekly articles, real-time signals, and full educational content.

See Learner plan