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)
- Keep only events known on the as-of date (
known <= asof); sort by ex-date. - Scan from the newest event to the oldest with running price factor p = 1 and volume factor v = 1.
- 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.
- Store the running (p, v) as the factor for every bar strictly before that ex-date.
- For a bar dated d, binary-search the first event with ex-date > d; its entry is the factor; no such event means 1.
- 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.
- 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
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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| QuantConnect LEAN | C# | QuantConnect/Lean | git clone https://github.com/QuantConnect/Lean.git | — | — | — | — | factor files per symbol: date, price factor, split factor, reference price |
| zipline-reloaded | Python | stefan-jansen/zipline-reloaded | pip install zipline-reloaded | — | — | — | — | SQLite adjustment tables (splits, mergers, dividends) applied to daily bars |
| yfinance | Python | ranaroussi/yfinance | pip install yfinance | — | — | — | — | downloads Yahoo history; auto_adjust option returns adjusted OHLC |