Never Buy the Same Quarter Twice
A read-through cache for a metered market-data provider — and why the hard part was not storing the data, but knowing when it was safe to trust
The Observation
Market data is billed per request. Grade My Investments makes 13 calls per symbol to build a report — balance sheets, income statements, cash flow, key metrics, prices, profile, analyst coverage — at roughly $0.005 a call. That number is not an estimate; it is encoded in the platform's own pricing table as the internal cost of generating a report.
And then the single most important fact about that data: a closed fiscal period is immutable. Apple's Q3 balance sheet is the same document today, next week, and in five years. Every call that re-fetches it is money spent to receive something we already had. The platform had no persistent cache at all — the only caching anywhere in the provider client was a 30-minute in-memory symbol lookup that died with the process.
flowchart LR
subgraph CONSUMERS[Consumers - unchanged]
C1[Report engine<br/>13 calls per symbol]
C2[Public ticker builder<br/>2,516 symbols]
C3[Grade-regime study]
end
I{{IFmpApiClient<br/>interface}}
D[Caching decorator]
Q{Cache complete?<br/>settled periods held<br/>and none provisional}
DB[(MySQL cache)]
API[FMP API<br/>$0.005 per call]
CONSUMERS --> I --> D --> Q
Q -- yes --> DB
Q -- no --> API
API -- upsert + reconcile settlement --> DB
DB -. cash flow present<br/>means period settled .-> QMeasuring First: The Cost Driver Was Not Where We Assumed
The obvious target was user reports. Measuring the actual call sites said otherwise. Four separate consumers pull fundamentals, and the one that dominates is not user-facing at all:
| Consumer | Calls / symbol | Symbols | Observed spend |
|---|---|---|---|
| Public ticker page builder | 9 | 2,516 | ~$905 across 8 refreshes |
| User report engine | 13 | per job | 27 jobs |
| Grade-by-regime study | 3 | 484 | ~$7 per rebuild |
| Macro & commodities series | 1 | ~40 | daily, marginal |
The SEO page builder re-fetches 2,516 symbols on every run, and it runs far more often than companies file. It is simultaneously the largest cost and the most cacheable workload on the platform — nearly all of that spend was re-buying quarters that had not changed since the previous run. Optimising the user reports first would have been intuitive, visible, and mostly pointless.
The Hard Part Is Completeness, Not Storage
Storing a JSON document against a symbol and a date is trivial. The difficulty is that consumers do not ask for a specific quarter. They ask for the most recent N:
GetBalanceSheetsQuarterlyAsync(symbol, 12) // "the last 12 quarters"
A naive cache asks "do I have twelve rows?" — which proves nothing, because a thirteenth may have been filed yesterday. Answering honestly requires knowing whether a new period can exist yet, without spending a call to find out.
The first answer was wrong
The initial design assumed a fixed settlement lag: trust the cache until roughly four days after an earnings date, then re-fetch. Reasonable-sounding, and wrong — as the provider's own documentation makes clear:
Financials arrive via an 8-K within 24 hours of an earnings report, but that data is "not as reliable or as full" as the 10-Q version, which takes longer to process. Cash flow statements are rarely released with an 8-K — they appear only once the company files its 10-Q, 10-K or 20-F.
So each period publishes twice: thin, then complete. A fixed lag would have cached the thin version, marked the quarter present, and — because the completeness test then reports "we have this quarter" — never fetched the good one. Permanently degraded fundamentals, with nothing in the system to surface the problem. The bug would not have looked like a bug; it would have looked like slightly odd numbers.
The better answer was already in the data
Because cash flow appears only after the real filing, and because every consumer already fetches all three statements together, the presence of a cash flow statement is the provider telling us the complete version has landed. No guessed lag, no per-issuer filing calendar, no assumption to maintain. A period is provisional until its cash flow exists and settled afterwards; provisional periods are served — so a report the week after earnings shows the newest numbers rather than looking a quarter behind — but re-fetched every run until they settle. The signal cost nothing to obtain, because we were already fetching it.
Two Details That Only Show Up Under Concurrency
Settlement cannot depend on arrival order
The report engine fans out its thirteen calls in parallel. Cash flow may be cached before or after the statements it settles. Writing this as "when cash flow arrives, stamp these dates" works only if cash flow arrives last — which is a coin flip. Deriving settlement from what is actually in the table instead makes the operation idempotent and independent of ordering. Both orders are pinned by tests, because this is exactly the class of bug that passes locally and fails intermittently in production.
An upgrade is not a restatement
The cache hashes each stored document to detect issuers revising already-filed statements — a genuine data-quality signal worth surfacing. But a provisional period's content changing is the expected 8-K→10-Q swap, not a revision. Counting them together would have fired a restatement warning for every symbol every quarter, which is not a noisy signal so much as no signal at all. The two paths are distinguished by whether the period had already settled.
One Cache, Four Consumers, Zero Copies of the Rules
Four independent processes need this data: the report engine, the macro and study engine, the public-page builder, and a scheduled pre-warmer. The obvious implementation gives each one caching code. That is four places to change a TTL, four chances to disagree about when a fiscal period is trustworthy, and four subtly different definitions of "cached" that nobody notices until the numbers differ.
Instead the cache is a decorator on the provider interface. Consumers keep calling the interface they always called; the caching implementation of it happens to check a database first. Not one call site changed — the thirteen in the report engine are byte-identical to before the cache existed — and removing the whole feature is a dependency-registration edit.
What is shared, and what deliberately is not
| Concern | Where it lives |
|---|---|
| When a fiscal period is trustworthy | One decorator. A settlement-rule change lands for all four consumers at once, or for none. |
| TTLs and the completeness test | Same decorator. No consumer can quietly decide its own freshness policy. |
| The cache-aside protocol | A single generic helper. Try the cache, record a hit, otherwise fetch, record a miss, write back — and above all, a cache fault falls through to the live provider rather than failing the caller. Written per endpoint, that last rule was three chances to forget a try/catch. Written once, it is a guarantee. |
| Storage and SQL | A separate repository. Deliberately not merged with the decorator: policy and persistence change for different reasons, and merging them means a schema edit touches the settlement logic. |
| Per-dataset specifics | Passed into the shared protocol as small functions. How to tell a statement is settled differs genuinely from how to tell a price series is current — that difference is real knowledge, not duplication, and hiding it would be worse than repeating it. |
The seam that actually bit
The policy was single-sourced from the start. The wiring was not: eight lines of dependency registration copied into each consumer, each making its own object-lifetime decision. Both of this feature's expensive failures came from that seam rather than from the caching logic:
- The pre-warmer paired concurrent workers with a shared database context. Database contexts are not thread-safe, so every write threw — while the metered provider calls went out and were billed as normal. It spent real money and cached nothing, and its log looked healthy throughout.
- Another consumer's wiring was invisible enough that answering "is it actually using the cache?" required inspecting deployed assemblies on the production host.
Both fixes are the same idea: make the dangerous decision impossible to make locally. Registration is now one call that encodes the correct lifetime pairing, so a consumer is a single line that cannot get it wrong. And the operations screen lists the consumers it expects to see, reporting "no activity" against any that are silent — because an unwired consumer and an idle one look identical otherwise, and that ambiguity is what cost the afternoon.
Unit Economics That Improve With Scale
The provider bills per symbol-period. The platform bills per user. Every user after the first who looks at a symbol somebody else already looked at is served for free — so the saving is not linear in users, it compounds with them.
| Users | Distinct symbols | Uncached / mo | Cached / mo | Hit rate | Data cost / user |
|---|---|---|---|---|---|
| 50 | ~340 | $26 | $6 | 77% | $0.52 → $0.12 |
| 100 | ~530 | $52 | $9 | 83% | $0.52 → $0.09 |
| 500 | ~1,600 | $260 | $28 | 89% | $0.52 → $0.06 |
| 1,000 | ~2,700 | $520 | $47 | 91% | $0.52 → $0.05 |
Uncached, market data is a fixed marginal cost per user, forever. Cached, it falls — roughly a tenth by a thousand users, because the marginal user overwhelmingly wants symbols already held. The overlap assumption behind this is deliberately conservative: the platform's own symbol lists already show 929 entries resolving to 275 distinct symbols, an overlap of 3.4× across a handful of users.
These are modelled, not measured — which is why the platform ships an admin screen that reports real hit rates, calls avoided, and dollars saved, so the table above can be replaced with evidence rather than defended as arithmetic.
Designed So the Worst Case Is a Bill
| Risk | How it is handled |
|---|---|
| Cache is wrong, corrupt, or unreachable | Every fault falls through to the live provider. The failure mode is "we pay again", never "reports break". |
| The cache misbehaves in production | It is a decorator. Two lines of dependency registration turn it off; no call site knows it exists. |
| Settlement logic has a bug | A 95-day backstop re-fetches anything untouched for a quarter, so any such bug self-heals rather than serving stale data forever. |
| An issuer restates a filed period | Content hashing detects it, the stored document is replaced, and the count becomes a queryable data-quality signal. |
| Statements are not in USD | Reporting currency is stored verbatim and never converted. Conflating the two once produced a $50 trillion market cap. |
| An empty response is mistaken for a miss | "No analyst coverage" is a real answer and is cached. A transient failure returns null and is never cached. |
| Live prices going stale | Quotes are never cached at all. A quote is a price now; caching it would be wrong rather than merely stale. |
There is also an independent check on whether the cache is telling the truth: the underlying client already counts real billed requests, and the decorator delegates to it untouched. A hit performs no request, so that counter should fall as the cache's own "calls avoided" figure rises. Two numbers from two different places that have to agree.
Related Reading
- Lean Cloud Economics — the rest of the cost discipline this belongs to
- What If We Ran on AWS or Google Cloud? — the same measure-before-optimising habit, applied to infrastructure
- Blob Storage Architecture — the other half of "store it once"
- Third-Party Integrations — what the platform buys and why