Adjusted Prices Rewrite History


Published July 25, 2019

Adjusted Prices Rewrite History

A historical price changes after the trade has settled.

A four-for-one split in July can divide every close from January by four. A cash distribution can alter years of adjusted close prices. A vendor correction can change the return of a strategy that finished running last week.

The exchange did not revise those trades. The data provider revised a derived series.

An adjusted close price is a view over raw market data and a corporate-action policy. Treating it as a fact creates silent errors in backtests, market capitalization, factor research, and stock screeners. The raw bar should remain immutable. Splits, dividends, rights issues, spinoffs, and symbol changes belong in a separate ledger. Adjustment happens at query time or in a versioned materialized view.

That distinction becomes expensive only after it has been ignored.

One security has several valid price histories

For a single listed security, at least three daily series are useful:

  1. Raw close. The price printed for that session on the trading venue.
  2. Split-adjusted close. A per-share series restated onto a consistent share basis.
  3. Total-return index. A return series that assumes cash distributions are reinvested under a declared policy.

They answer different questions.

Raw close is required for transaction reconstruction, historical market capitalization, limit checks, and reconciliation against exchange records. Split-adjusted close is useful for technical features and return calculations where a mechanical change in share count should disappear. Total return is useful for performance measurement.

Substitution between these series is a type error.

The common failure is historical market capitalization computed as adjusted close multiplied by point-in-time shares outstanding. A split-adjusted price may be expressed on today’s share basis. The filing’s share count may be expressed on the basis in effect at the filing date. Multiplying them can create a fourfold error with clean arithmetic and no exception.

Store corporate actions as an append-only ledger

A corporate action needs more than an ex-date and a ratio. Point-in-time market data requires the date on which the system learned each version of the event.

from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from enum import Enum
from fractions import Fraction
from typing import Optional


class ActionKind(Enum):
    SPLIT = "split"
    CASH_DIVIDEND = "cash_dividend"


@dataclass(frozen=True)
class CorporateAction:
    security_id: int
    kind: ActionKind
    ex_date: date
    announced_at: datetime
    recorded_at: datetime
    source: str
    source_version: str
    old_shares: Optional[int] = None
    new_shares: Optional[int] = None
    cash_amount: Optional[Decimal] = None
    currency: Optional[str] = None

    def split_price_factor(self):
        if self.kind is not ActionKind.SPLIT:
            raise TypeError("price factor requested for a non-split action")
        if not self.old_shares or not self.new_shares:
            raise ValueError("split ratio is incomplete")
        return Fraction(self.old_shares, self.new_shares)

For a four-for-one split, old_shares=1 and new_shares=4. Historical prices before the ex-date receive a factor of 1/4. Historical share counts and volumes receive the reciprocal factor.

Use integers for split terms and Fraction for composition. Decimal approximations accumulate damage across reverse splits, stock dividends, and odd ratios. A sequence containing 3-for-2, 104-for-100, and 1-for-20 actions still has an exact rational factor.

The ledger is append-only. A corrected vendor record produces a new source_version and recorded_at. It does not overwrite the previous version. That permits an adjusted series to be reproduced as the system knew it on a prior date.

Two clocks matter:

A backtest evaluated on March 31 must not use a split record first received in April, even when the vendor later assigns the split an ex-date in March.

Build split factors by scanning backward

Split adjustment is a suffix product. Every bar before an action receives its factor. Every bar on or after the ex-date remains on the new basis.

A backward scan applies each action once. It avoids one join per bar and preserves exact arithmetic until the final price conversion.

from datetime import date, datetime
from fractions import Fraction
from typing import Dict, Iterable, Sequence


def split_price_factors(
    trading_days: Sequence[date],
    actions: Iterable[CorporateAction],
    knowledge_at: datetime,
) -> Dict[date, Fraction]:
    eligible = [
        action
        for action in actions
        if action.kind is ActionKind.SPLIT
        and action.recorded_at <= knowledge_at
        and action.ex_date <= knowledge_at.date()
    ]
    eligible.sort(key=lambda action: action.ex_date, reverse=True)

    result = {}
    cumulative = Fraction(1, 1)
    action_index = 0

    for trading_day in reversed(trading_days):
        while (
            action_index < len(eligible)
            and eligible[action_index].ex_date > trading_day
        ):
            cumulative *= eligible[action_index].split_price_factor()
            action_index += 1

        result[trading_day] = cumulative

    return result

The knowledge_at cutoff is part of the query. Omitting it converts a point-in-time backtest into a present-day reconstruction.

The final conversion from a raw close to a split-adjusted close should happen at the edge of the calculation:

from decimal import Decimal, localcontext
from fractions import Fraction


def apply_price_factor(raw_close: Decimal, factor: Fraction) -> Decimal:
    with localcontext() as context:
        context.prec = 34
        numerator = Decimal(factor.numerator)
        denominator = Decimal(factor.denominator)
        return raw_close * numerator / denominator

Thirty-four decimal digits match decimal128 precision and are excessive for quoted prices. The excess is useful because rounding belongs at an output boundary, not inside a cumulative factor chain.

Adjust volume in the opposite direction

A split changes the unit of one share. It should not change the economic turnover of the session.

For a price factor f, the corresponding volume factor is 1/f:

from fractions import Fraction


def adjust_split_bar(raw_price, raw_volume, price_factor):
    adjusted_price = apply_price_factor(raw_price, price_factor)
    adjusted_volume = Fraction(raw_volume, 1) / price_factor
    return adjusted_price, adjusted_volume

For split-only adjustments, the product of price and volume is invariant apart from quote and lot-size rounding:

raw_price * raw_volume ≈ adjusted_price * adjusted_volume

That invariant catches reversed ratios and double-applied actions. Both errors are common because vendor feeds disagree on whether a field contains old-over-new or new-over-old.

Dollar volume screens should usually use raw close and raw volume. Technical indicators may use consistently split-adjusted price and volume. Mixing adjusted price with raw volume creates a discontinuity at every split.

Cash dividends require a declared return policy

A split factor is a unit conversion. A cash dividend is a distribution.

Many adjusted close feeds rewrite prior prices using a backward factor based on the close before the ex-date. The exact formula varies by provider, especially for special dividends, returns of capital, foreign withholding, and distributions larger than the reference price.

For internal research, a forward total-return index is easier to audit. Start at 100 and compound daily returns. Use split-adjusted closes so the share basis is stable. Add the cash distribution on the ex-date under the chosen reinvestment convention.

from datetime import date
from decimal import Decimal, localcontext
from typing import Dict, Iterable, List, Tuple


def total_return_index(
    split_adjusted_closes: Iterable[Tuple[date, Decimal]],
    cash_distributions: Dict[date, Decimal],
) -> List[Tuple[date, Decimal]]:
    rows = list(split_adjusted_closes)
    if not rows:
        return []

    index_level = Decimal("100")
    result = [(rows[0][0], index_level)]

    with localcontext() as context:
        context.prec = 34

        for position in range(1, len(rows)):
            current_date, current_close = rows[position]
            _, previous_close = rows[position - 1]
            distribution = cash_distributions.get(
                current_date, Decimal("0")
            )

            gross_return = (
                current_close + distribution
            ) / previous_close
            index_level *= gross_return
            result.append((current_date, index_level))

    return result

This calculation still embeds policy:

Those assumptions belong in the series identifier. A column named adjusted_close cannot carry them.

Give every per-share value a basis

A production stock screener should prevent accidental multiplication of values expressed on different split bases.

The lowest-cost control is a basis token attached to every per-share value and share count. The token can be the digest of all effective split actions through the observation date.

from dataclasses import dataclass
from decimal import Decimal


@dataclass(frozen=True)
class PerSharePrice:
    amount: Decimal
    split_basis: str


@dataclass(frozen=True)
class ShareCount:
    amount: Decimal
    split_basis: str


def market_cap(price: PerSharePrice, shares: ShareCount) -> Decimal:
    if price.split_basis != shares.split_basis:
        raise ValueError(
            "price and share count use different split bases"
        )
    return price.amount * shares.amount

The token does not need business meaning. It needs equality semantics. A current-basis adjusted price and a filing-date share count will fail before they enter a ranking model.

The same rule applies to earnings per share, book value per share, free cash flow per share, option strike prices, and analyst estimates. Every per-share field inherits a corporate-action basis.

Cache projections by policy fingerprint

Query-time adjustment is deterministic, but recalculating long histories for every screen is wasteful. Materialized series are reasonable when their cache key includes the complete policy and event set.

import hashlib
import json
from datetime import datetime
from typing import Iterable


def adjustment_cache_key(
    security_id: int,
    mode: str,
    knowledge_at: datetime,
    actions: Iterable[CorporateAction],
) -> str:
    action_rows = [
        {
            "kind": action.kind.value,
            "ex_date": action.ex_date.isoformat(),
            "recorded_at": action.recorded_at.isoformat(),
            "source": action.source,
            "source_version": action.source_version,
            "old_shares": action.old_shares,
            "new_shares": action.new_shares,
            "cash_amount": (
                str(action.cash_amount)
                if action.cash_amount is not None
                else None
            ),
            "currency": action.currency,
        }
        for action in actions
        if action.recorded_at <= knowledge_at
    ]
    action_rows.sort(
        key=lambda row: (
            row["ex_date"],
            row["kind"],
            row["source"],
            row["source_version"],
        )
    )

    payload = {
        "security_id": security_id,
        "mode": mode,
        "knowledge_at": knowledge_at.isoformat(),
        "actions": action_rows,
    }
    encoded = json.dumps(
        payload,
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")

    return hashlib.blake2b(encoded, digest_size=20).hexdigest()

A vendor correction changes the digest and creates a new projection. Existing research remains reproducible because its prior digest still identifies the exact corporate-action set.

This design also localizes invalidation. A corrected split for one security does not require rebuilding a market-wide price table. It invalidates projections whose fingerprints include that event version.

Keep raw bars immutable

A market-data pipeline should preserve the received bar, the source, and the ingestion timestamp. Never update raw closes when a split arrives.

The storage model can remain narrow:

The raw layer supports reconciliation. The ledger supports corrections. The projection layer supports speed.

A single adjusted-price table combines all three concerns and makes each one weaker.

Test identities, not examples

A few famous splits make poor tests. The dangerous cases are chains of ordinary actions with corrections and same-day events.

Generate ratios, compose them exactly, and test algebraic identities:

from decimal import Decimal
from fractions import Fraction
from random import Random


def test_split_round_trip():
    random = Random(1729)

    for _ in range(10000):
        raw_price = Decimal(random.randrange(1, 500000)) / Decimal("100")
        old_shares = random.randrange(1, 200)
        new_shares = random.randrange(1, 200)
        factor = Fraction(old_shares, new_shares)

        adjusted = apply_price_factor(raw_price, factor)
        restored = apply_price_factor(adjusted, 1 / factor)

        assert abs(restored - raw_price) < Decimal("1e-25")

Additional properties should cover:

These tests survive new securities, new vendors, and new action ratios. Example-based tests often survive only the examples.

The adjusted close is a query result

Historical stock price adjustment belongs in the same category as currency conversion and inflation adjustment. It is a transformation with a policy, an effective date, a knowledge date, and a versioned input set.

A production stock screener needs all of those dimensions because valuation and ranking features combine market prices with filings, share counts, estimates, and corporate actions. Each input can arrive late. Each can be corrected. Each can use a different unit basis.

Store the trade as printed. Store the action as reported. Derive the adjusted close price with an explicit policy. Persist the fingerprint when the result enters a backtest, factor model, or valuation run.

Then a split can rewrite a chart without rewriting history.