Home › Financial infrastructure › Ledgers & money
algorithm low

Corporate-action adjustment

Problem A system keeping years of price and volume history must show it on one comparable scale after an event such as a stock split or dividend changes the unit, without rewriting stored history each time.

Keep raw bars plus a tiny table of cumulative backward factors, built by one reverse scan over events and applied at read time, versioned by as-of date.

factor table O(E log N) per series; lookup O(log E); lazy read O(bars returned)
splitsdividendsadjustment-factorohlcvpoint-in-timebackward-adjustmentrebasing
Overview

A discrete event changes a series' unit: a 2-for-1 split halves a share price, an index moves to a new base year, a sensor is recalibrated, a loyalty program multiplies every balance. Old values stop being comparable. Do not rewrite history: keep raw values and an event table, compute per date the product of all later event factors (one reverse scan), and multiply at read time.

Key facts

Use when

  • Backtests and charts of equity prices, where returns across a split must not show a -50 % crash.
  • Economic series rebased to a new base year, telemetry after a unit or calibration change, game or loyalty-point currencies rescaled.
  • History is large and events are rare: rewriting it per event would touch every row.

Avoid when

Deep dive

How it works

In: events (ex-date, announce date, kind, ratio or amount), raw bars, as-of date · Out: adjusted bars for any date range · State: raw bars (never changed) + one factor table per series and as-of version · Cost: build O(E log N), lookup O(log E), read O(bars returned)

  1. Keep only events known on the as-of date (known <= asof); sort by ex-date.
  2. Scan from the newest event to the oldest with running price factor p = 1 and volume factor v = 1.
  3. Split with r new shares per old: p /= r, v *= r. Cash dividend D: p *= 1 - D/P, P = raw close of the last bar before the ex-date; v unchanged.
  4. Store the running (p, v) as the factor for every bar strictly before that ex-date.
  5. For a bar dated d, binary-search the first event with ex-date > d; its entry is the factor; no such event means 1.
  6. Multiply open, high, low and close by p, volume by v. Build coarser bars (weekly, VWAP) from adjusted base bars: one factor per base bar, not per window.
  7. A new event or a changed announce date makes a new table version; raw data is never touched.

Forward adjustment is the mirror: the oldest value stays, later ones are scaled; latest prices then no longer match the market. Spin-offs and rights issues fit the same form, p *= (P - value of what was distributed) / P, with the value from the issuer's terms or the first trades.

Code

#[derive(Clone, Default)]
pub struct Cols { pub date: Vec<u32>, pub o: Vec<f64>, pub h: Vec<f64>, pub l: Vec<f64>, pub c: Vec<f64>, pub v: Vec<f64> }
#[derive(Clone, Copy)]
pub enum Kind { Split(f64), Cash(f64) } // Split(2.0) = 2 new shares per old
#[derive(Clone, Copy)]
pub struct Event { pub ex: u32, pub known: u32, pub kind: Kind }
/// Factors for bars strictly before ex[i]: price x px[i], volume x vol[i].
pub struct Factors { pub ex: Vec<u32>, pub px: Vec<f64>, pub vol: Vec<f64> }

impl Factors {
    pub fn build(events: &[Event], raw: &Cols, asof: u32) -> Factors {
        let mut ev: Vec<Event> = events.iter().copied().filter(|e| e.known <= asof).collect();
        ev.sort_by_key(|e| e.ex);
        let n = ev.len();
        let (mut px, mut vol, mut p, mut v) = (vec![1.0; n], vec![1.0; n], 1.0, 1.0);
        for i in (0..n).rev() { // reverse scan
            match ev[i].kind {
                Kind::Split(r) => { p /= r; v *= r; }
                Kind::Cash(d) => {
                    let j = raw.date.partition_point(|&x| x < ev[i].ex);
                    if j > 0 { p *= 1.0 - d / raw.c[j - 1]; } // raw close before ex-date
                }
            }
            px[i] = p; vol[i] = v;
        }
        Factors { ex: ev.iter().map(|e| e.ex).collect(), px, vol }
    }
    pub fn at(&self, d: u32) -> (f64, f64) {
        let i = self.ex.partition_point(|&x| x <= d);
        if i == self.ex.len() { (1.0, 1.0) } else { (self.px[i], self.vol[i]) }
    }
    /// Lazy read of bars [a, b): copy, then multiply each constant-factor segment.
    pub fn read(&self, raw: &Cols, a: usize, b: usize) -> Cols {
        let mut out = Cols { date: raw.date[a..b].to_vec(), o: raw.o[a..b].to_vec(), h: raw.h[a..b].to_vec(),
                             l: raw.l[a..b].to_vec(), c: raw.c[a..b].to_vec(), v: raw.v[a..b].to_vec() };
        let mut s = 0;
        while s < out.date.len() {
            let i = self.ex.partition_point(|&x| x <= out.date[s]);
            let (p, v) = self.at(out.date[s]);
            let e = if i == self.ex.len() { out.date.len() } else { out.date.partition_point(|&d| d < self.ex[i]) };
            if p != 1.0 || v != 1.0 {
                for col in [&mut out.o, &mut out.h, &mut out.l, &mut out.c] { col[s..e].iter_mut().for_each(|x| *x *= p); }
                out.v[s..e].iter_mut().for_each(|x| *x *= v);
            }
            s = e;
        }
        out
    }
}

fn main() {
    let c = vec![100.0, 102.0, 100.0, 51.0, 52.0]; // 2:1 split, ex 20260105
    let raw = Cols { date: vec![20260102, 20260103, 20260104, 20260105, 20260106], o: c.clone(),
                     h: c.clone(), l: c.clone(), c: c.clone(), v: vec![1000.0, 1000.0, 1000.0, 2000.0, 2000.0] };
    let split = Event { ex: 20260105, known: 20260104, kind: Kind::Split(2.0) };
    let adj = Factors::build(&[split], &raw, 20260110).read(&raw, 0, 5);
    assert_eq!(adj.c[2], 50.0); // continuous: 50 -> 51 is +2 %, not -49 %
    assert!((adj.c[3] / adj.c[2] - 1.02).abs() < 1e-12);
    for i in 0..5 { assert!((adj.c[i] * adj.v[i] - c[i] * raw.v[i]).abs() < 1e-9); } // dollar volume
    let div = Event { ex: 20260106, known: 20260101, kind: Kind::Cash(1.0) };
    let f = Factors::build(&[div], &raw, 20260110);
    assert!((f.at(20260105).0 - (1.0 - 1.0 / 51.0)).abs() < 1e-15 && f.at(20260106).0 == 1.0);
    assert_eq!(Factors::build(&[], &raw, 20260110).read(&raw, 0, 5).c, c); // identity
    assert_eq!(Factors::build(&[split], &raw, 20260103).read(&raw, 0, 5).c, c); // not yet announced
}

Compiled and run; asserts pass.

Complexity and constants

Build: one sort plus E steps and one binary search per dividend. Lookup: log2(E) compares in a table of E x 20 bytes (ex-date, p, v), a few cache lines for typical E. Read: bandwidth-bound, 6 columns per bar and a multiply on 5. Products drift in the last f64 bits: compare with a tolerance.

Efficiency

  • Cost: measured by me on an Apple M4 Pro, one thread, 10M in-memory bars, 40 events: table build about 0.3 ms; lookup about 4 ns; lazy full read about 7 ns per bar (12 ns on a cold first run) against about 5 ns for a plain copy; rewriting the five value columns in place after a new event about 5 ns per bar (about 48 ms), memory-bandwidth bound. On disk a rewrite adds write amplification.
  • Levers:
    • factor table instead of rewritten history — 0.3 ms rebuild vs about 48 ms touching 10M bars (measured) — rare events, large history.
    • segment multiply — one binary search per constant-factor segment, then a slice multiply the compiler vectorizes (measured, about 2 ns per bar above a copy) — range reads.
    • per-series factor cache keyed by (series, as-of, last event id) — no rebuild across thousands of queries — repeated reads.
    • columnar layout — contiguous columns multiply at memory speed (columnar-memory-format, simd-programming) — analytics scans.
    • read only the needed window — cost scales with bars returned, not history — chart and backtest windows.
  • Naive → standard → state of the art:
    • naive · rewrite the whole history on every event, O(N) writes per event.
    • standard · raw bars + factor table, adjust while reading, O(bars returned + E).
    • state of the art · versioned tables per as-of date, cached per query, segment multiply fused into the scan that already reads the columns.

Implementation notes

Store ex-date, announce date and a monotone event id per event; derive the table on demand. Keep f64 only inside the adjuster; raw stays in its exact type (money-representation). Round adjusted volume only for display. Stamp results with the table version (bitemporal-data, point-in-time-join). Raw bars live in chunks with the table beside them (time-series-chunk-storage).

Pitfalls

  • Look-ahead: using today's table for a backtest at an earlier date leaks events not yet announced. Filter on announce date, not only ex-date.
  • Adjusting prices but not volume (or the reverse) breaks dollar volume and turnover.
  • Using the ex-date close instead of the close before it for P gives wrong dividend factors; use the last raw close before the ex-date.
  • Different factors for high, low and close can invert low and high; one factor per bar for all four prices.
  • Cached adjusted series go stale when a late or corrected event arrives; invalidate on event id.
  • Total-return indices reinvest dividends; price-only adjusted series do not match them.

Variants and alternatives

Materialized adjusted serieswins when reads are heavy, events very rare and one as-of is enough; must be rebuilt per event.
Forward adjustmentwins when old values are the reference (a calibration base) and appending must be cheap.
Total-return indexwins for performance reporting: reinvest each dividend on its ex-date into one index series.
Rebasinga pure scale by one reference value (new base year, new unit); the same table with one entry.

In production

  • Equity data vendors and backtesters ship raw prices plus split and dividend events or factors; LEAN's factor files hold date, price factor, split factor and reference price.
  • Zipline stores adjustments as (asset, effective date, ratio) rows applied to backward-looking pricing data.
  • Economic databases publish index series on a base year; telemetry stores keep calibration epochs beside raw sensor values.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
QuantConnect LEANC#QuantConnect/Leangit clone https://github.com/QuantConnect/Lean.git————factor files per symbol: date, price factor, split factor, reference price
zipline-reloadedPythonstefan-jansen/zipline-reloadedpip install zipline-reloaded————SQLite adjustment tables (splits, mergers, dividends) applied to daily bars
yfinancePythonranaroussi/yfinancepip install yfinance————downloads Yahoo history; auto_adjust option returns adjusted OHLC

Sources

  • QuantConnect, CorporateFactorRow (LEAN factor-file row: date, price factor, split factor, reference price). code
  • zipline-reloaded, adjustments.py (adjustment tables and dividend ratio calculation). code
  • CRSP (Center for Research in Security Prices), data documentation and methodology. site

Used by

tax lot accounting · plannedPer-lot cost basis (FIFO, specific ID), wash-sale rules and tax-loss harvesting

Systems (7):

Alternatives and related

bitemporal data · plannedValid time plus transaction time on every record for as-of queries, back-dating and corrections
point in time join · plannedAs-of join that picks the latest feature value at or before each event timestamp
fifo lot consumption · plannedConsuming quantities from dated lots in FIFO/FEFO order (points expiry, stock rotation)
Money representationSoftware that stores, adds, splits and converts amounts of money must get totals exactly right to the last cent, but ordinary decimal numbers on computers drift and round in surprising ways.
columnar memory format · plannedIn-memory column buffers with validity bitmaps and offsets (Arrow style) for zero-copy exchange
SIMD programmingHow do you speed up hot loops over arrays by processing several values per instruction, across different CPU types, while still running safely on machines that lack the newer instructions? This guide covers when and how to do it.Time-series chunk storageSystems that record timestamped readings from many sources, such as prices, metrics or sensor values, must keep long histories cheaply and still read one source's time range fast, without scanning everything.