Module 2
The read-optimized store
The service's spec has one line that drives this whole module: “data must be cached at startup, optimized to read.” That sentence is a licence to spend effort once — at boot — so that every query afterward is as cheap as possible. The write path can be slow, allocate freely, sort, and validate; the read path should be an array index and a binary search, nothing more.
Here's how that translates into Java. Each ticker's history becomes a pair of parallel primitive arrays: dates as intepoch days (days since 1970-01-01 — Module 1's year-range check guarantees the cast is safe) in a sorted int[], and closes in a double[] where index i in one array corresponds to index i in the other.
TickerSeries: two arrays and nothing else
From core-api/…/store/TickerSeries.java — the fields, and the one-time build that pays the sorting cost up front:
public final class TickerSeries {
private final String ticker;
private final int[] epochDays; // sorted ascending, unique
private final double[] close; // aligned to epochDaysint[] days = new int[n];
double[] closes = new double[n];
for (int i = 0; i < n; i++) {
PriceRecord r = sorted.get(i);
days[i] = (int) r.date().toEpochDay();
if (i > 0 && days[i] == days[i - 1]) {
throw new IllegalArgumentException(
"Duplicate date for ticker " + ticker + ": " + r.date());
}
closes[i] = r.closePrice();
}Why not a friendly List<PriceRecord> or a TreeMap<LocalDate, Double>? Three reasons, all mechanical:
- No boxing. A List<LocalDate> holds pointers to heap objects; an int[] holds the numbers themselves. Ten years of daily closes is ~2,500 entries — 10 KB of ints instead of thousands of scattered objects with headers.
- Cache-friendly contiguity. A scan over an int[] walks sequential memory, so the CPU prefetcher streams it; chasing object references misses cache on every hop. On modern hardware that's often the difference between “instant” and “why is this slow?”.
- JIT-friendly loops. Tight loops over primitive arrays are exactly what the JIT compiler knows how to unroll and vectorize with SIMD instructions. The beta calculation downstream scans these arrays; give the optimizer something it can work with.
Note also what the build rejects: a duplicate date is thrown out at boot with the ticker and date in the message. Fail loudly at startup, never quietly at query time.
Lookups: floor and ceiling binary search
A sorted array gives you O(log n)date lookup. But the service doesn't need “find this exact date” — a user asks for beta “as of Saturday June 7” and the market was closed. What it needs is the floor: the last stored date on or before the query. From core-api/…/store/IntSearch.java:
/** Index of the last element <= key, or -1 if all elements are greater. */
static int floorIndex(int[] sorted, int key) {
int lo = 0;
int hi = sorted.length - 1;
int result = -1;
while (lo <= hi) {
int mid = (lo + hi) >>> 1;
if (sorted[mid] <= key) {
result = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return result;
}The trick is that result remembers the best candidate seen so far: every time sorted[mid] <= key we record mid and keep searching right for something even later. When the window closes, resultis the floor. So a Saturday query lands one slot past Friday's entry, the comparison fails, and the recorded Friday index wins — weekends and holidays are handled with zero calendar logic. A twin ceilingIndex (first element >= key, mirrored: record and search left) marks the start of a date window. Two small details worth stealing: (lo + hi) >>> 1can't overflow where (lo + hi) / 2 famously can, and returning -1 / lengthas “not found” sentinels lets callers distinguish “before all data” from “after all data.”
🎛 Binary-search stepper
epochDays[ ] — 12 stored trading dates, sorted ascending
This is IntSearch.floorIndex from the Java service, step by step: each probe halves the live window, so finding a date among n = 12 entries takes at most ceil(log2 12) = 4 probes — and among 10 million daily closes, at most 24. The >>> 1 (unsigned shift) is the overflow-safe way to average two indices. Educational tool — not investment advice.
PriceStore: the startup cache
One level up, core-api/…/store/PriceStore.java groups the validated rows by ticker, builds each series once, and freezes the whole thing:
public static PriceStore build(Collection<PriceRecord> records) {
if (records.isEmpty()) {
throw new IllegalArgumentException("No price records supplied");
}
Map<String, List<PriceRecord>> grouped = records.stream()
.collect(Collectors.groupingBy(PriceRecord::ticker));
Map<String, TickerSeries> map = new HashMap<>();
grouped.forEach((ticker, rows) -> map.put(ticker, TickerSeries.build(ticker, rows)));
LOG.info(() -> "Price store built: " + map.size() + " tickers, " + records.size() + " rows");
return new PriceStore(Map.copyOf(map));
}Map.copyOf returns an unmodifiablemap, so the store inherits Module 1's guarantee at the next level: built once at boot, then read by any number of request threads with no locks. Grouping keys are already canonical because PriceRecord upper-cased its ticker — the validation gate paying rent again. And an unknown-ticker lookup throws with the list of known tickers in the message, turning a support question into a self-answering error.
That's the trade in full: startup pays O(n log n) sorting and one pass of array building; every read forever after getsO(log n) date lookups, O(1) index access, and cache-friendly O(n) scans over primitive arrays — with no synchronization anywhere. For a service that loads once and answers thousands of queries, that's the right side of the ledger every time.
Things to try
- • Keep the default query (Sat Jun 7) and press Step through all four probes — watch result latch onto Friday's index while the search keeps looking right.
- • Query an exact trading day (e.g. Jun 10) — the floor is the date itself, and often in fewer steps.
- • Drag to Sun Jun 1, before all stored dates — the search returns -1, the “no data yet” sentinel a caller can turn into a clear error.
- • Notice the step counter never exceeds 4 for 12 entries — that's ceil(log2 n). Doubling the array adds just one step.