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 epochDays
int[] 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

Sat Jun 7weekend

epochDays[ ] — 12 stored trading dates, sorted ascending

Mon Jun 2
[0] lo
Tue Jun 3
[1]
Wed Jun 4
[2]
Thu Jun 5
[3]
Fri Jun 6
[4]
Mon Jun 9
[5]
Tue Jun 10
[6]
Wed Jun 11
[7]
Thu Jun 12
[8]
Fri Jun 13
[9]
Mon Jun 16
[10]
Tue Jun 17
[11] hi
step 0 / 4
lo = 0, hi = 11, result = -1 — press Step to probe the middle.

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.