Corporate actions: split adjustment without losing the tape
A stock split rewrites history. Every price before the effective date must be scaled, or every return computed across it is garbage.
That forces an engineering choice. Do you restate the stored series and lose the raw tape, or keep the raw tape canonical and adjust at read time?
With h5i-db you do not have to lose anything either way. A restatement is a
write() commit with a note, the pre-restatement series stays readable
forever through time travel, and the adjustment-factor pattern is a small SQL
join.
This recipe builds both patterns on real AAPL and NVDA data covering three splits: AAPL 4:1 (2020-08-31), NVDA 4:1 (2021-07-20) and NVDA 10:1 (2024-06-10).
import numpy as np
import pandas as pd
import pyarrow as pa
import h5i_db
from h5i_db import col, sql_expr
import cookbook_utils as cu
db = h5i_db.Database(cu.fresh_db("mde_corpactions"), create=True)
1. The data#
cu.fetch_daily pulls real daily bars from Yahoo Finance and caches them to
parquet, so this recipe is deterministic and works offline after the first
run. One row per symbol per session.
| column | type | meaning |
|---|---|---|
ts |
timestamp[us, tz=UTC] |
session date |
symbol |
string |
ticker |
open, high, low, close |
float64 |
session prices |
adj_close |
float64 |
close adjusted for splits and dividends |
volume |
int64 |
shares traded |
real = cu.fetch_daily(cu.SP500_EXAMPLES, start="2018-01-01", end="2026-07-01")
print(f"{real.num_rows:,} rows x {real.num_columns} columns, "
f"{len(set(real['symbol'].to_pylist()))} symbols")
real.to_pandas().head()
64,020 rows x 8 columns, 30 symbols
| ts | symbol | open | high | low | close | adj_close | volume | |
|---|---|---|---|---|---|---|---|---|
| 0 | 2018-01-02 20:00:00+00:00 | AAPL | 42.540001 | 43.075001 | 42.314999 | 43.064999 | 40.267075 | 102223600 |
| 1 | 2018-01-02 20:00:00+00:00 | NVDA | 4.894500 | 4.987500 | 4.862500 | 4.983750 | 4.922529 | 355616000 |
| 2 | 2018-01-02 20:00:00+00:00 | GE | 84.251045 | 86.215950 | 84.011429 | 86.168022 | 80.953125 | 16185981 |
| 3 | 2018-01-02 20:00:00+00:00 | AMZN | 58.599998 | 59.500000 | 58.525501 | 59.450500 | 59.450500 | 53890000 |
| 4 | 2018-01-02 20:00:00+00:00 | IBM | 147.705551 | 148.001907 | 146.787766 | 147.466537 | 102.657249 | 4395815 |
One wrinkle decides the whole recipe. Yahoo's close column is already
split-adjusted, and adj_close only adds dividend adjustments on top. The
AAPL close/adj_close ratio of about 1.07 in 2018 is pure dividends.
A real exchange feed would have printed AAPL at roughly \$499 on 2020-08-28, not \$125. So we un-adjust using the known split schedule to recover the as-printed tape. That is what a raw vendor feed delivers, and the honest starting point here.
px = (
real.to_pandas()
.query("symbol in ['AAPL', 'NVDA']")[["ts", "symbol", "close", "adj_close", "volume"]]
.sort_values(["ts", "symbol"])
.reset_index(drop=True)
)
SPLITS = pd.DataFrame(
{
"ts": pd.to_datetime(["2020-08-31", "2021-07-20", "2024-06-10"], utc=True),
"symbol": ["AAPL", "NVDA", "NVDA"],
"ratio": [4.0, 4.0, 10.0], # shares multiply by ratio, price divides
}
)
# Cumulative product of all splits *after* each row's date = the factor that
# was later divided out of the price. Multiply it back to get the raw print.
px["future_factor"] = 1.0
for s in SPLITS.itertuples():
mask = (px["symbol"] == s.symbol) & (px["ts"] < s.ts)
px.loc[mask, "future_factor"] *= s.ratio
px["close_raw"] = (px["close"] * px["future_factor"]).round(2)
px[(px["symbol"] == "AAPL") & px["ts"].between("2020-08-27", "2020-09-02")]
| ts | symbol | close | adj_close | volume | future_factor | close_raw | |
|---|---|---|---|---|---|---|---|
| 1336 | 2020-08-27 20:00:00+00:00 | AAPL | 125.010002 | 121.256409 | 155552400 | 4.0 | 500.04 |
| 1338 | 2020-08-28 20:00:00+00:00 | AAPL | 124.807503 | 121.059967 | 187630000 | 4.0 | 499.23 |
| 1340 | 2020-08-31 20:00:00+00:00 | AAPL | 129.039993 | 125.165375 | 225702700 | 1.0 | 129.04 |
| 1342 | 2020-09-01 20:00:00+00:00 | AAPL | 134.179993 | 130.151062 | 151948100 | 1.0 | 134.18 |
There is the cliff: \$499 on the 28th, \$129 on the 31st.
2. Store the raw tape as the canonical table#
The raw series is what the exchange actually printed, so it should be the immutable source of truth. Everything downstream (adjusted series, returns, factors) is derived and reproducible from it.
PRICE_SCHEMA = pa.schema(
[
pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False),
pa.field("symbol", pa.string()),
pa.field("close", pa.float64()),
pa.field("volume", pa.int64()),
]
)
db.create_table("prices", PRICE_SCHEMA, time_column="ts", sort_key=["ts", "symbol"])
raw_table = pa.Table.from_pandas(
px[["ts", "symbol", "close_raw", "volume"]].rename(columns={"close_raw": "close"}),
preserve_index=False,
).cast(PRICE_SCHEMA)
db.append("prices", raw_table, note="raw unadjusted tape, AAPL+NVDA 2018-2026")
{'table': 'prices',
'sequence': 1,
'op': 'append',
'rows_total': 4268,
'segments_total': 1,
'segments_added': 1,
'segments_deduped': 0,
'committed_at_ns': 1785195717265335705}3. The classic screwup: naive returns across a split#
Compute daily returns straight off the raw closes with a lag() window, then
look at the worst days on record.
Three of them are not crashes. They are splits masquerading as -75% and -90% returns. Any risk model, momentum signal or stop-loss fed this series would have fired on a non-event.
PREV_CLOSE = sql_expr("lag(close)").over(partition_by="symbol", order_by="ts")
def worst_days(version=None, n: int = 5):
"""The n most negative daily returns - re-run after the restatement."""
return (
db.table("prices", version=version)
.with_columns(ret=col("close") / PREV_CLOSE - 1)
.filter(col("ret").is_not_null())
.select("ts", "symbol", ret_pct=(col("ret") * 100).round(1))
.sort("ret_pct")
.limit(n)
.to_pandas()
)
worst = worst_days()
worst
| ts | symbol | ret_pct | |
|---|---|---|---|
| 0 | 2024-06-10 20:00:00+00:00 | NVDA | -89.9 |
| 1 | 2021-07-20 20:00:00+00:00 | NVDA | -75.2 |
| 2 | 2020-08-31 20:00:00+00:00 | AAPL | -74.2 |
| 3 | 2018-11-16 20:00:00+00:00 | NVDA | -18.8 |
| 4 | 2020-03-16 20:00:00+00:00 | NVDA | -18.5 |
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
for sym, g in px.groupby("symbol"):
axes[0].plot(g["ts"], g["close_raw"], lw=0.9, label=sym)
for s in SPLITS.itertuples():
axes[0].axvline(s.ts, color="crimson", ls="--", lw=0.8)
axes[0].set_yscale("log")
axes[0].set_title("Raw tape: split cliffs (dashed = effective dates)")
axes[0].set_xlabel("date")
axes[0].set_ylabel("close (USD, log)")
axes[0].legend()
nvda = px[px["symbol"] == "NVDA"]
axes[1].plot(nvda["ts"], nvda["close_raw"] / nvda["close_raw"].iloc[0], lw=0.9, label="naive (raw closes)")
axes[1].plot(nvda["ts"], nvda["close"] / nvda["close"].iloc[0], lw=0.9, label="split-adjusted")
axes[1].set_yscale("log")
axes[1].set_title("NVDA cumulative growth: naive vs adjusted")
axes[1].set_xlabel("date")
axes[1].set_ylabel("growth of $1 (log)")
axes[1].legend()
fig.tight_layout()
4. Pattern A: adjustment factors joined at read time#
Keep prices raw forever. Store the split schedule as its own tiny h5i-db
table and derive the adjusted series in SQL.
A row's adjustment factor is the product of all split ratios with a later
effective date. exp(sum(ln ...)) turns that product into an aggregate, so
one join plus a group-by does the whole job. When the next split is announced,
ingestion is a one-row append and no historical data is touched.
SPLIT_SCHEMA = pa.schema(
[
pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False),
pa.field("symbol", pa.string()),
pa.field("ratio", pa.float64()),
]
)
db.create_table("splits", SPLIT_SCHEMA, time_column="ts", sort_key=["ts", "symbol"])
db.append("splits", pa.Table.from_pandas(SPLITS, preserve_index=False).cast(SPLIT_SCHEMA),
note="split schedule through 2024-06-10")
# A non-equi LEFT JOIN (`s.ts > p.ts`) whose whole condition would have to be
# a raw fragment anyway - one of the places a SQL string stays clearer than
# the builder.
adjusted = db.sql(
"""
SELECT p.ts, p.symbol, p.close AS close_raw,
round(p.close / coalesce(exp(sum(ln(s.ratio))), 1.0), 4) AS close_adj
FROM prices p
LEFT JOIN splits s
ON s.symbol = p.symbol AND s.ts > p.ts
GROUP BY p.ts, p.symbol, p.close
ORDER BY p.ts, p.symbol
"""
).to_pandas()
adjusted[(adjusted["symbol"] == "AAPL") & adjusted["ts"].between("2020-08-27", "2020-09-02")]
| ts | symbol | close_raw | close_adj | |
|---|---|---|---|---|
| 1336 | 2020-08-27 20:00:00+00:00 | AAPL | 500.04 | 125.0100 |
| 1338 | 2020-08-28 20:00:00+00:00 | AAPL | 499.23 | 124.8075 |
| 1340 | 2020-08-31 20:00:00+00:00 | AAPL | 129.04 | 129.0400 |
| 1342 | 2020-09-01 20:00:00+00:00 | AAPL | 134.18 | 134.1800 |
Sanity check. Our SQL-adjusted series must agree with the vendor's
split-adjusted close to the penny. The only noise is the cent-rounding we
applied when reconstructing the raw tape.
check = adjusted.merge(px[["ts", "symbol", "close"]], on=["ts", "symbol"])
max_err = (check["close_adj"] - check["close"]).abs().max()
print(f"max |SQL-adjusted - vendor adjusted| = ${max_err:.4f} across {len(check):,} rows")
assert max_err < 0.01
max |SQL-adjusted - vendor adjusted| = $0.0001 across 4,268 rows
The splits table is a feed like any other, so appends must arrive in time
order. A backfilled 2019 split cannot be appended after the 2024 row. h5i-db
rejects it rather than silently corrupting the sort order, and historical
corrections go through write() or the plan/apply flow instead.
stale_split = pa.table(
{
"ts": pa.array([pd.Timestamp("2019-06-01", tz="UTC")], pa.timestamp("us", tz="UTC")),
"symbol": ["FAKE"],
"ratio": [2.0],
}
).cast(SPLIT_SCHEMA)
try:
db.append("splits", stale_split)
except h5i_db.H5iError as e:
print(f"rejected: code={e.code}\nhint: {e.hint}")
rejected: code=sort_order_violation hint: append requires input sorted by the time column with min >= current table max; use `write` or sort the input
5. Pattern B: restate in place, keep history via versioning#
Many desks prefer the adjusted series to be the table, so every consumer gets clean prices with zero join logic. The usual cost is that the raw tape is gone.
With versioned storage it is not. write() replaces the head, but every prior
version remains readable, so restatement becomes an audited, reversible
operation rather than a destructive one.
restated = px.copy()
restated["close"] = (restated["close_raw"] / restated["future_factor"]).round(4)
db.write(
"prices",
pa.Table.from_pandas(restated[["ts", "symbol", "close", "volume"]], preserve_index=False).cast(PRICE_SCHEMA),
note="restated: split-adjusted (AAPL 4:1 2020-08-31, NVDA 4:1 2021-07-20, NVDA 10:1 2024-06-10)",
)
[{k: v[k] for k in ("sequence", "op", "rows", "note") if k in v} for v in db.versions("prices")]
[{'sequence': 0, 'op': 'create', 'rows': 0},
{'sequence': 1,
'op': 'append',
'rows': 4268,
'note': 'raw unadjusted tape, AAPL+NVDA 2018-2026'},
{'sequence': 2,
'op': 'write',
'rows': 4268,
'note': 'restated: split-adjusted (AAPL 4:1 2020-08-31, NVDA 4:1 2021-07-20, NVDA 10:1 2024-06-10)'}]The version history is the audit trail. h5i('prices', 1) queries the
pre-restatement tape alongside the live table. Below, AAPL around its split,
with the implied factor recovered from the two versions:
now_px = col("close", relation="l")
was_px = col("close", relation="r")
(
db.table("prices") # head: restated
.join(db.table("prices", version=1), on=["ts", "symbol"]) # raw tape
.filter(
col("symbol", relation="l") == "AAPL",
col("ts", relation="l").between("2020-08-26", "2020-09-03"),
)
.select(
ts=col("ts", relation="l"),
symbol=col("symbol", relation="l"),
raw_close=was_px,
adj_close=now_px,
implied_factor=(was_px / now_px).round(2),
)
.sort("ts")
.to_pandas()
)
| ts | symbol | raw_close | adj_close | implied_factor | |
|---|---|---|---|---|---|
| 0 | 2020-08-26 20:00:00+00:00 | AAPL | 506.09 | 126.5225 | 4.0 |
| 1 | 2020-08-27 20:00:00+00:00 | AAPL | 500.04 | 125.0100 | 4.0 |
| 2 | 2020-08-28 20:00:00+00:00 | AAPL | 499.23 | 124.8075 | 4.0 |
| 3 | 2020-08-31 20:00:00+00:00 | AAPL | 129.04 | 129.0400 | 1.0 |
| 4 | 2020-09-01 20:00:00+00:00 | AAPL | 134.18 | 134.1800 | 1.0 |
| 5 | 2020-09-02 20:00:00+00:00 | AAPL | 131.40 | 131.4000 | 1.0 |
"What did the price series look like before the restatement?" also works by wall-clock commit time. That is useful when a downstream job only logged when it read, not which version.
raw_commit = db.versions("prices")[1] # sequence 1 = the raw-tape append
as_of = pd.Timestamp(raw_commit["committed_at_ns"], unit="ns", tz="UTC").isoformat()
pre = db.read("prices", as_of=as_of).to_pandas()
# Compare against a Timestamp, not a string: pandas parses a string literal to
# datetime64[ns] and the unit mismatch with our [us] column matches nothing.
split_eve = pd.Timestamp("2020-08-28 20:00", tz="UTC")
aapl_pre = pre[(pre["symbol"] == "AAPL") & (pre["ts"] == split_eve)]
print(f"as of {as_of}:")
print(aapl_pre[["ts", "symbol", "close"]].to_string(index=False))
as of 2026-07-27T23:41:57.265335705+00:00:
ts symbol close
2020-08-28 20:00:00+00:00 AAPL 499.23And with the head restated, the split artifacts are gone. The worst days in the series are now genuine market moves, COVID-era selloffs and earnings, rather than corporate-action ghosts.
worst_days()
| ts | symbol | ret_pct | |
|---|---|---|---|
| 0 | 2018-11-16 20:00:00+00:00 | NVDA | -18.8 |
| 1 | 2020-03-16 20:00:00+00:00 | NVDA | -18.5 |
| 2 | 2025-01-27 20:00:00+00:00 | NVDA | -17.0 |
| 3 | 2019-01-28 20:00:00+00:00 | NVDA | -13.8 |
| 4 | 2020-03-16 20:00:00+00:00 | AAPL | -12.9 |
Choosing between the patterns#
- Adjustment-factor join (Pattern A). The raw tape stays canonical, adjusted prices are always consistent with the current split schedule, and new actions are one-row appends. The cost is that every consumer must apply the join, or read a derived table you maintain.
- Restate in place (Pattern B). Consumers get clean prices with no join,
the restatement is one audited commit, reversible via
restore()and inspectable viah5i('prices', v). The cost is that the head is stale between action announcement and restatement, and each restatement rewrites the table. - They compose. Keep
pricesraw plus asplitstable as ground truth, and publish a restatedprices_adjtable for convenience. Versioning gives you the audit trail on both.
Takeaways#
- Vendor "close" columns are often already adjusted, so know what your feed delivers before you build on it. Here we reconstructed the true raw tape from the split schedule.
- Naive returns across a split print -75% (AAPL 4:1) and -90% (NVDA 10:1)
artifacts, and one
lag()window query makes them jump out. - The adjustment-factor pattern is a one-join SQL derivation,
exp(sum(ln(ratio))), off an append-onlysplitstable. write()plus anoteturns restatement into an audited commit.h5i('prices', 1)andread(as_of=...)keep every pre-restatement view queryable, so you never have to choose between clean data and the raw tape.
db.close()