Lesson 1.4.3 · 55 min
Adjusting for splits and dividends: adjusted and unadjusted prices
On August 23, 2021 NOVA “fell” 75% and nobody lost any money: it was a 4-for-1 split. This lesson builds by hand the adjustment factors for eight years of corporate actions, checks the method against the example published by a provider and shows when to use each series and what breaks when you use the wrong one.
- Module
- 1.4 · Financial data engineering
- Duration
- 55 min
- Prerequisites
- 1.2.1 · Stocks: splits, dividends and corporate actions, 1.4.2 · Data cleaning: missing values, bad ticks, gaps and time zones
- Author
- Jesús Cuesta
- Updated
Level 1 lessons
1.1 · How markets work
1.2 · Asset classes
- 1.2.1 Stocks: splits, dividends and corporate actions
- 1.2.2 ETFs: replication, tracking error, leveraged ETFs and UCITS
- 1.2.3 Futures: specifications, margin, roll and contango
- 1.2.4 Forex: pairs, pips, lots, rollover and CFDs
- 1.2.5 Options: calls, puts, intrinsic value and time value
- 1.2.6 Crypto: spot, perpetuals, funding and custody
- 1.2.7 Fixed income and interest rates; CFDs and leveraged products
1.3 · Market data sources
- 1.3.1 Types of market data: EOD, bars, ticks, order book and reference
- 1.3.2 Free market data sources
- 1.3.3 Retail and prosumer market data APIs
- 1.3.4 Professional market data providers
- 1.3.5 Crypto and on-chain data
- 1.3.6 Brokers as market data sources
- 1.3.7 Market data licensing and terms of use
- 1.3.8 How to choose a market data provider
1.4 · Financial data engineering
- 1.4.1 Downloading over REST and websockets: pagination, rate limits and retries
- 1.4.2 Data cleaning: missing values, bad ticks, gaps and time zones
- 1.4.3 Adjusting for splits and dividends: adjusted and unadjusted prices
- 1.4.4 Universes without survivorship bias: delistings, mergers and symbol changes
- 1.4.5 Building bars: time, tick, volume, dollar and imbalance
- 1.4.6 Continuous futures series: roll rules, difference adjustment and ratio adjustment
- 1.4.7 Storage: CSV, Parquet, partitioning, DuckDB, TimescaleDB and ArcticDB
- 1.4.8 Reproducible pipeline: download, validation, incremental updates and quality tests
1.5 · Fundamental and macro data
1.6 · Financial exploratory analysis
1.7 · Programmed technical analysis
A crash that was not one
The NOVA daily series in this lesson has the prices as they were traded: unadjusted. Eight years of NYSE sessions, from 2018 to 2025, with all the corporate actions that lesson 1.2.1 described: one split and 33 dividends, one of them a special dividend. One week of August 2021 is enough to see it:
import numpy as np
import pandas as pd
bruto = pd.read_csv("nova_diario_sin_ajustar.csv",
parse_dates=["fecha"], index_col="fecha")
bruto.loc["2021-08-19":"2021-08-25"] apertura maximo minimo cierre volumen
fecha
2021-08-19 239.60 251.84 238.19 241.05 2471611
2021-08-20 238.75 240.55 227.85 240.00 2203366
2021-08-23 60.28 60.41 58.11 59.55 13427377
2021-08-24 59.63 60.11 57.33 59.55 13974110
2021-08-25 59.17 62.40 58.71 60.18 6931159Friday the 20th closes at 240.00 and Monday the 23rd opens at 60.28, with the volume multiplied by six. There was no panic: from Monday on, each old share is four new ones, and each new one is worth a quarter. Whoever had 100 shares at 240 USD (24,000 USD) has 400 at 60 (24,000 USD). In the raw series, however, it is the biggest drop in its history:
r_bruto = bruto["cierre"].pct_change()
(100 * r_bruto.nsmallest(4)).round(2)fecha
2021-08-23 -75.19
2021-06-02 -12.21
2025-03-26 -12.10
2020-06-23 -12.06
Name: cierre, dtype: float64A −75.19% that cost nobody anything, ahead of three real drops of around 12%. Any calculation on this series (volatility, drawdown, a signal) is contaminated by it. And there are more jumps, smaller and more frequent: on each ex-date of a dividend, the price opens lower because whoever buys that day no longer collects the dividend. The adjustment corrects both things.
The events and their factors
The events table has one row per event: the ex-date, the type, the split ratio or the dividend amount (in the shares that existed on that date: dividends before the split are per old share) and the payment date.
eventos = pd.read_csv("nova_eventos.csv",
parse_dates=["fecha_ex", "fecha_pago"])
print(eventos["tipo"].value_counts().to_dict())
eventos.iloc[13:18]{'dividendo': 32, 'split': 1, 'dividendo_extraordinario': 1}
fecha_ex tipo ratio importe fecha_pago
13 2021-05-10 dividendo NaN 0.60 2021-06-01
14 2021-08-10 dividendo NaN 0.60 2021-08-31
15 2021-08-23 split 4.0 NaN 2021-08-20
16 2021-11-10 dividendo NaN 0.15 2021-12-01
17 2022-02-10 dividendo NaN 0.16 2022-03-03The quarterly dividend goes from 0.60 to 0.15 USD with the split: it is the same dividend per company, spread over four times as many shares. Each event becomes a factor, the number by which all prices before its ex-date have to be multiplied so that the series does not jump:
where is the dividend, the unadjusted close of the session before the ex-date, the cumulative factor of day (the product of the factors of all events after that day) and the adjusted price. It is a backward adjustment: the last stretch of the series keeps the real prices and the past is rescaled. It is CRSP's multiplicative method, the one Yahoo describes for its adjusted close.
Mermaid source
flowchart LR B[Raw prices] --> F[Factor per event] E[Events] --> F F --> A[Cumulative factor] A --> J[Adjusted series] B --> J
def factores(precios, eventos):
cierre_ant = precios["cierre"].shift()
ev = eventos.copy()
es_split = ev["tipo"] == "split"
ev["cierre_ant"] = cierre_ant.reindex(ev["fecha_ex"]).to_numpy()
ev["factor"] = np.where(es_split, 1 / ev["ratio"],
1 - ev["importe"] / ev["cierre_ant"])
return ev
ev = factores(bruto, eventos)
ev.loc[[14, 15, 25, 33],
["fecha_ex", "tipo", "importe", "cierre_ant", "factor"]] fecha_ex tipo importe cierre_ant factor
14 2021-08-10 dividendo 0.60 213.21 0.997186
15 2021-08-23 split NaN 240.00 0.250000
25 2023-12-14 dividendo_extraordinario 1.50 42.75 0.964912
33 2025-11-10 dividendo 0.19 66.13 0.997127The split weighs 0.25; a quarterly dividend, between 0.9956 and 0.9983 depending on that day's price; the special dividend of 1.50 USD, 0.965. A day's cumulative factor is the product of all those that come after it. It is computed by walking through the events: each one multiplies by its factor all the days before its ex-date.
def ajustar(precios, ev):
"""F_t: product of the factors of the events after t."""
F = pd.Series(1.0, index=precios.index)
for fecha_ex, f in zip(ev["fecha_ex"], ev["factor"]):
F[F.index < fecha_ex] *= f
return F
F = ajustar(bruto, ev)
precios = ["apertura", "maximo", "minimo", "cierre"]
ajustado = bruto[precios].mul(F, axis=0)
pd.DataFrame({"bruto": bruto["cierre"], "factor": F,
"ajustado": ajustado["cierre"]}).loc[
["2018-01-02", "2021-08-20", "2021-08-23", "2025-12-31"]] bruto factor ajustado
fecha
2018-01-02 192.00 0.219581 42.159572
2021-08-20 240.00 0.228378 54.810708
2021-08-23 59.55 0.913512 54.399628
2025-12-31 68.19 1.000000 68.190000The cumulative factor is a staircase: it equals 1 in the last stretch (after the last dividend, on November 10, 2025) and goes down one step at each ex-date, going backward. The big step is the split. On January 2, 2018, the factor is 0.2196: that day's close of 192.00 USD is equivalent to 42.16 USD today, counting the split and the 33 dividends.
The adjusted series does not jump
The proof that the adjustment is right is that the return of the adjusted series is the one a shareholder got. On the day of the split, no jump; on an ex-date, the price drop offset by the dividend collected:
r_aj = ajustado["cierre"].pct_change()
print(f"Split, raw: {r_bruto['2021-08-23']:+.2%}")
print(f"Split, adjusted: {r_aj['2021-08-23']:+.2%}")
dividendo = ev[ev["tipo"] != "split"].set_index("fecha_ex")["importe"]
c, c_ant = bruto["cierre"], bruto["cierre"].shift()
exd = dividendo.index
total = (c[exd] + dividendo) / c_ant[exd] - 1 # price + dividend
print("Largest difference on ex-dates (pp):",
round(100 * (r_aj[exd] - total).abs().max(), 3))Split, raw: -75.19%
Split, adjusted: -0.75%
Largest difference on ex-dates (pp): 0.03On the split, the adjusted series moves as much as the market moved that day (−0.75%), not −75%. On the ex-dates, the adjusted return and the total return computed by hand (price plus dividend, over the previous close) match to within three thousandths of a point. The remaining difference is one of definition: the multiplicative factor is equivalent to reinvesting the dividend in the stock itself at the ex-date price, , while the formula by hand leaves it in cash that day. Either one is “total return”; what is not acceptable is mixing the two without knowing it.
The figure at the top compares the two series on a log scale. The raw one has a step in August 2021 and a misleading slope; the adjusted one is continuous and ends at the same price, because the last stretch is not adjusted.
Against a provider's example
The method is not an invention of this lesson. Yahoo Finance's help page on its adjusted close publishes an example with a 2-for-1 split and a dividend of 0.08 USD, and says it follows the CRSP standard. With the same two functions, the result has to be theirs:
dias = pd.to_datetime(["2001-02-16", "2001-02-17", "2001-02-18",
"2001-02-19", "2001-02-20", "2001-02-21",
"2001-02-22"])
ej = pd.DataFrame({"cierre": [46.99, 48.30, 24.96, 24.91, 24.95,
24.53, 24.54]}, index=dias)
ev_ej = pd.DataFrame({"fecha_ex": pd.to_datetime(["2001-02-18",
"2001-02-21"]),
"tipo": ["split", "dividendo"],
"ratio": [2.0, np.nan],
"importe": [np.nan, 0.08]})
ev_ej = factores(ej, ev_ej)
print(ev_ej[["tipo", "factor"]].round(4).to_string(index=False))
(ej["cierre"] * ajustar(ej, ev_ej)).round(2).to_list() tipo factor
split 0.5000
dividendo 0.9968
[23.42, 24.07, 24.88, 24.83, 24.87, 24.53, 24.54]The seven adjusted closes are those in Yahoo's table (23.42; 24.07; 24.88; 24.83; 24.87; 24.53; 24.54), with the same dividend multiplier, 0.9968. The year and the days are filler: Yahoo's example only gives day and month, and for the calculation it does not matter whether they are real sessions. That the function reproduces a provider's example is a better proof than any explanation: in a project, this example would go into a test (lesson 0.4.5).
Not every provider adjusts the same way. Some adjust only for splits in the “close” and for splits and dividends in
the “adjusted close”; others adjust the whole OHLC bar; some let you choose. With yfinance, for example,
auto_adjust=True (the default) returns the four columns already adjusted, and actions=True adds the dividends and
splits. The block is not run because it needs a network connection:
import yfinance as yf
ajustada = yf.download("AAPL", start="2018-01-01", auto_adjust=True)
bruta = yf.download("AAPL", start="2018-01-01", auto_adjust=False,
actions=True) # Dividends and Stock Splits columnsBefore using a provider's series you need to know which of them it is. The quick way to find out is the one in this section: look for a known split and see whether the series jumps.
Price return and total return
There are two useful adjusted series, and they answer different questions. The one adjusted only for splits gives the price return: how much the stock has gone up. The one adjusted for splits and dividends gives the total return: how much whoever held it and reinvested the dividends has earned. The difference is the dividend yield.
F_split = ajustar(bruto, ev[ev["tipo"] == "split"])
precio = bruto["cierre"] * F_split
anos = (bruto.index[-1] - bruto.index[0]).days / 365.25
def tasa_anual(s):
return (s.iloc[-1] / s.iloc[0]) ** (1 / anos) - 1
for nombre, s in [("unadjusted", bruto["cierre"]),
("splits only (price)", precio),
("splits and dividends (total)", ajustado["cierre"])]:
print(f"{nombre:>28}: {tasa_anual(s):+.2%} a year") unadjusted: -12.15% a year
splits only (price): +4.49% a year
splits and dividends (total): +6.20% a yearThe unadjusted series says that NOVA loses 12% a year: the split, read as a loss. The price series, that it gains 4.5%; the total one, 6.2%. Dividends contribute 1.7 points a year, more than a quarter of the total return. To compare a strategy with “buy and hold”, the benchmark is the total return: comparing against the price return hands the strategy those 1.7 points for free. The same goes for indices: a price index (like the course's FIX) does not include dividends, and a total return index (like ISX) does (lessons 1.2.2 and 1.2.3).
False signals in a backtest
With the raw series, any rule that looks at changes or levels stumbles over the events. Two examples:
caida = r_bruto[r_bruto < -0.20]
print("Days with a drop > 20% (raw):", list(caida.index.date))
print("Days with a drop > 20% (adjusted):", (r_aj < -0.20).sum())
despues = slice("2021-08-23", "2022-06-10")
for nombre, s in [("raw", bruto["cierre"]),
("adjusted", ajustado["cierre"])]:
bajo = (s < s.rolling(200).mean())[despues]
print(f"Below its 200-session average ({nombre}): "
f"{bajo.mean():.0%}")Days with a drop > 20% (raw): [datetime.date(2021, 8, 23)]
Days with a drop > 20% (adjusted): 0
Below its 200-session average (raw): 95%
Below its 200-session average (adjusted): 15%With the raw series, a “buy the crash” rule buys on the day of the split; a trend filter (price below its 200-session average = bear market) declares NOVA bearish in 95% of the sessions of the following ten months, while the average drags along the pre-split prices. With the adjusted series, the same filter sees a downtrend in only 15%. These are the most visible false signals; those from the dividends are small and repeated:
diferencia = (r_bruto[exd] - r_aj[exd]) * 100
print(f"Ex-dates: {len(exd)}")
print(f"Average drop the shareholder did not suffer: "
f"{diferencia.mean():.2f}%")
print(f"Sum over eight years: {diferencia.sum():.1f} points")Ex-dates: 33
Average drop the shareholder did not suffer: -0.39%
Sum over eight years: -12.9 pointsOn each ex-date, the raw series shows a drop of around 0.4% that the shareholder did not suffer, because they collected the dividend. A strategy that sold short the day before each ex-date would appear to earn that 0.4% regularly in a backtest on raw data; in reality, whoever is short on the ex-date pays the dividend to the lender of the shares and the edge disappears.
Volume is adjusted too, but only for splits and in the opposite direction: before the split there were four times fewer shares, so the volume in shares is multiplied by the ratio. Dividends do not change the number of shares:
vol_aj = bruto["volumen"] / F_split
antes = slice("2021-07-23", "2021-08-20")
despues = slice("2021-08-23", "2021-09-21")
for nombre, v in [("raw", bruto["volumen"]), ("adjusted", vol_aj)]:
print(f"{nombre:>8}: median {v[antes].median():>11,.0f} before, "
f"{v[despues].median():>11,.0f} after") raw: median 2,578,329 before, 9,953,681 after
adjusted: median 10,313,316 before, 9,953,681 afterThe past changes every time a dividend arrives
Backward adjustment has an awkward consequence: the 2018 adjusted series is not the same whether it was downloaded in October 2025 or in December. The dividend of November 10, 2025 adds one more factor, and that factor multiplies the entire previous history. You can rebuild what a system would have seen on each date using only the events known at the time:
def ajustado_a_fecha(precios, ev, fecha):
conocidos = ev[ev["fecha_ex"] <= fecha]
hasta = precios.loc[:fecha]
return hasta["cierre"] * ajustar(hasta, conocidos)
oct25 = ajustado_a_fecha(bruto, ev, "2025-10-31")
dic25 = ajustado_a_fecha(bruto, ev, "2025-12-31")
print("Adjusted close on 2018-01-02:")
print(f" seen on 2025-10-31: {oct25.iloc[0]:.4f}")
print(f" seen on 2025-12-31: {dic25.iloc[0]:.4f}")
r_oct = oct25.pct_change().loc[:"2025-10-31"]
r_dic = dic25.pct_change().loc[:"2025-10-31"]
print("Returns through October equal:",
np.allclose(r_oct, r_dic, equal_nan=True))Adjusted close on 2018-01-02:
seen on 2025-10-31: 42.2811
seen on 2025-12-31: 42.1596
Returns through October equal: TrueThe 2018 adjusted prices drop 0.29% from one version to the other; the returns of the days before the event do not change even in the last decimal. A system that uses only returns or ratios (moving averages on the same series, for example) does not notice the difference. One that uses absolute levels does: “the 2018 high was 52.10” is a sentence that expires every quarter. And a database that stores the adjusted series as if it were a fact will, after a few incremental updates, mix stretches adjusted with different factors.
Mermaid source
flowchart LR G[Store raw prices] --> T[Adjust at t] E[Store events] --> T T --> R[Returns] G --> O[Orders and levels]
The solution is the one in the diagram: store the unadjusted prices and the events table, which do not change, and compute the adjustment when needed, with the events known on the date of interest. It is the point-in-time idea that lesson 1.4.4 applies to stock universes and lesson 1.5.1 to fundamental data: every data point, as it was known at the time.
Exercises
- Adjust the series with the additive method: instead of multiplying by a factor, subtract the dividend from all prices before each ex-date (and divide by the ratio on the split). Compare the annual total return with that of the multiplicative method and look at the lowest adjusted price of each series.
Solution
aditivo = bruto["cierre"] * F_split
for fecha_ex, d in dividendo.items():
d_hoy = d * F_split[fecha_ex] # in today's shares
aditivo[aditivo.index < fecha_ex] -= d_hoy
print(f"Additive: {tasa_anual(aditivo):+.2%} a year, "
f"minimum {aditivo.min():.2f}")
print(f"Multiplicative: {tasa_anual(ajustado['cierre']):+.2%} a year, "
f"minimum {ajustado['cierre'].min():.2f}")Additive: +6.40% a year, minimum 28.24
Multiplicative: +6.20% a year, minimum 30.12The dividend is first converted to today's shares (those before the split are divided by 4) and then subtracted from everything before. The additive method preserves price differences in dollars, but not the ratios: by subtracting a fixed amount, the old prices, proportionally lower, fall more in percentage terms, and the return at the start of the series comes out inflated. With many dividends and low prices it can leave negative prices in the distant past. That is why the multiplicative method is the standard for stock series; the additive one only makes sense when what matters are differences in points, as in some continuous futures series (lesson 1.4.6).
- Check, with
ajustado_a_fecha, that the adjusted close of 2018-01-02 changes by exactly the factor of the November 2025 dividend. Which factor is it?
Solution
f_nov = ev.loc[ev["fecha_ex"] == "2025-11-10", "factor"].item()
print(f"Factor of 2025-11-10: {f_nov:.6f}")
print(f"Ratio dec/oct: {dic25.iloc[0] / oct25.iloc[0]:.6f}")Factor of 2025-11-10: 0.997127
Ratio dec/oct: 0.997127The ratio between the two versions is exactly the factor of the new dividend, 1 − 0.19 / close of November 7. It is the way to detect, by comparing two downloads from the same provider, that an event has appeared: the whole past moves in the same proportion.
- A NOVA backtest uses adjusted prices to decide and places limit orders “at 50 USD” in 2019. What is the problem? How would you fix it?
Solution
In 2019 NOVA traded unadjusted at around 200 USD (before the split) and its adjusted price today is around 45. A limit order at 50 USD computed on the adjusted series would be filled in the backtest at a price that never existed in the market; placed on the real price, it would never have been filled. The rule can decide with the adjusted series (or with returns), but the order price must be converted to that day's real price by dividing by the day's cumulative factor (), and the size in shares is also computed with the real price.
Each answer is folded under its question
Self-assessment
The close before the ex-date is 50 USD and the dividend, 0.50 USD. What is the multiplicative factor of that dividend?
- A0.50
- B0.99
- C1.01
- D0.95
Show answer
Answer B. 0.99
f = 1 − D / C = 1 − 0.50 / 50 = 0.99. All prices before the ex-date are multiplied by 0.99, so that the mechanical price drop on the ex-date disappears from the series.
A stock does a 4-for-1 split. What is done with the volume of the earlier sessions in the adjusted series?
- AIt is divided by 4
- BIt is left untouched
- CIt is multiplied by 4
- DIt is set to zero
Show answer
Answer C. It is multiplied by 4
After the split there are four times as many shares and each is worth a quarter. For the volume in shares to be comparable, the earlier volume is multiplied by the ratio (just as prices are divided by it). Dollar volume does not change.
A backtest on unadjusted prices buys when the price falls more than 20% in a day. What happens with NOVA on August 23, 2021?
- AIt buys: the raw series falls 75%, although the value of the position did not change
- BNothing: there was no drop that day
- CIt sells
- DIt raises an error
Show answer
Answer A. It buys: the raw series falls 75%, although the value of the position did not change
In the raw series the split shows up as a 75% drop. The rule reads it as a crash and trades. In the adjusted series that day is a normal day.
A new dividend arrives in November 2025. What changes in NOVA's adjusted series?
- AOnly the price on the dividend day
- BNothing
- CEvery return in the history
- DAll prices before the ex-date, which are multiplied by the new factor; the earlier returns do not change
Show answer
Answer D. All prices before the ex-date, which are multiplied by the new factor; the earlier returns do not change
Backward adjustment multiplies the whole past by the new factor. The 2018 price levels change; the ratios between two days before the event do not. That is why a rule that uses absolute levels (a 50 USD price, a limit order) gives different results depending on the day the series was downloaded.
What should be stored in a data lake so as not to depend on when the adjustment was computed?
- AOnly the provider's adjusted series
- BOnly the returns
- CThe unadjusted prices and the events table, to compute the adjustment at each date with the events known then
- DThe two adjusted series, price and total
Show answer
Answer C. The unadjusted prices and the events table, to compute the adjustment at each date with the events known then
With raw prices and events you can rebuild any version of the adjustment, including the one a system would have seen on a specific date. A stored adjusted series is a snapshot of one day: the next dividend makes it stale.
Key points
- Traded (unadjusted) prices have jumps that are neither losses nor gains: the split and the price drop on each ex-date.
- Factor of a split: 1 / ratio. Factor of a dividend: 1 − D / C of the close before the ex-date. Each day's factor is the product of those of all later events.
- The adjusted series gives the total return (price plus reinvested dividends); the series adjusted only for splits, the price return. For NOVA, 6.2% versus 4.5% a year.
- A backtest on unadjusted prices sees false crashes (the split) and drops on every ex-date; with prices adjusted for splits but not for dividends, it underestimates the buy-and-hold return.
- Backward adjustment rewrites the past with every new event: store raw prices and events and adjust on the fly. For orders and absolute levels, use the unadjusted price.