D02-F01-A01 / Complete engineering topic

Backward Split Adjustment

A production-minded guide to Backward Split Adjustment.

D02 · CORPORATE ACTIONS AND SECURIT…
D02-F01-A01Canonical / Tested / Open
D02 / D02-F01
Key concepts

The governed definitions this build depends on. Read them first if a term is unfamiliar.

A stock split changes the unit in which one share is measured. If a historical dataset joins prices and share quantities from both sides of that boundary without declaring a common basis, an ordinary unit change can look like a price collapse or a volume surge.

Backward split adjustment solves the comparability problem by expressing earlier observations on a selected later share basis. It does not rewrite the raw trade record, and it does not create or remove economic value. A trustworthy system keeps both series and records exactly which event produced the derived values.

A raw event record becomes an audited post-split-basis history

Define the ratio before using it

Split feeds use reciprocal conventions, so the letter rr is unsafe until its direction is explicit. This article defines:

r=post-split sharespre-split sharesr = \frac{\text{post-split shares}}{\text{pre-split shares}}

That gives:

Event descriptionRatio rr
Two-for-one split2
Four-for-one split4
One-for-five reverse split0.2

A provider that reports old shares per new share would publish the reciprocal. Never copy a numeric factor without also copying its definition.

The calculation and its boundary

Let iei_e be the zero-based index of the first observation already expressed on the post-split basis. Let PiP_i be raw price and QiQ_i be raw share-count volume. The backward-adjusted values are:

Pi={Pi/r,i<iePi,iieQi={Qi×r,i<ieQi,iieP_i^* = \begin{cases} P_i / r, & i < i_e \\ P_i, & i \ge i_e \end{cases} \qquad Q_i^* = \begin{cases} Q_i \times r, & i < i_e \\ Q_i, & i \ge i_e \end{cases}

Only observations strictly before the boundary are transformed. The first post-split observation and everything after it remain unchanged.

Before rounding, price times share quantity is invariant:

PiQi=(Pi/r)(Qir)=PiQiP_i^*Q_i^*=(P_i/r)(Q_ir)=P_iQ_i

This is a useful arithmetic check, but it cannot validate the event source or effective date. A wrong factor can still preserve the identity.

Work the synthetic example by hand

Suppose a two-for-one split has already been validated. The input uses r=2r=2, and index 2 is the first post-split observation:

Plain text
prices       = [120, 123, 60, 62]
share volume = [1000, 1200, 2400, 2000]
eventIndex   = 2

Transform only indices 0 and 1:

Plain text
120 / 2 = 60       1000 × 2 = 2000
123 / 2 = 61.5     1200 × 2 = 2400

The values at indices 2 and 3 are already on the target basis, so they remain unchanged.

The worked example shows its input, substituted arithmetic, and complete result

The exact result shared by the Python and TypeScript tests is:

JSON
{
  "adjustedPrices": [60, 61.5, 60, 62],
  "adjustedVolumes": [2000, 2400, 2400, 2000],
  "eventIndex": 2,
  "ratioConvention": "post_split_shares_per_pre_split_share"
}

The explicit convention in the result is intentional. It prevents an otherwise plausible JSON array from losing the meaning of its factor.

A historical event that makes the need obvious

Apple announced on July 30, 2020 that its board approved a four-for-one split. The issuer said holders of record on August 24 would receive three additional shares for each share held and that trading would begin on a split-adjusted basis on August 31 (Apple Newsroom).

Apple’s Form 8-K adds a different operational timestamp: each outstanding common share would automatically split into four at 5 p.m. Pacific daylight time on August 28 (SEC filing). Its 2020 Form 10-K later states that share and per-share information was retroactively adjusted to reflect the split (SEC annual filing).

These primary sources establish the event and its ratio without relying on a chart vendor. Under this article’s convention, r=4r=4: an eligible earlier price PP becomes P/4P/4, while an eligible earlier share quantity QQ becomes 4Q4Q.

The case also exposes a common data-modeling mistake. “The split date” is not one timestamp. Announcement, record date, legal effectiveness, first trading session on the new basis, vendor dissemination, and local ingestion can all differ. The transformation boundary must match the purpose of the dataset.

Historical comparability is not point-in-time availability

A backward-adjusted series intentionally applies a later corporate action to earlier observations. That is useful when today’s reader wants one comparable share basis across a chart. It can be wrong in a backtest asking what was knowable at an earlier decision time.

For point-in-time research, retain at least:

  • the raw observation and its original basis;
  • event announcement or filing time;
  • legal-effective time and first new-basis trading session;
  • vendor dissemination and local ingestion time;
  • the version or as_of timestamp of the derived series.

Do not expose a factor to a simulated decision before the factor entered the permitted information set. The reference implementation deliberately avoids pretending to solve that upstream problem: it accepts an already-resolved eventIndex.

Implementation contract

The Python and TypeScript functions accept aligned price and share-volume arrays, eventIndex, and postSplitSharesPerPreSplitShare.

They reject:

  • empty or misaligned arrays;
  • non-finite or non-positive prices;
  • negative share volume;
  • an event index that leaves no observations on one side;
  • a non-positive factor or a unit factor that does not describe a split.

Both implementations copy rather than mutate the input and round non-negative public outputs half-up to six decimal places. Production code should retain decimal precision internally and apply rounding according to its stated data contract.

Explore forward and reverse cases

The guided playground uses 36 synthetic observations. Start with the two-for-one scenario, step across the first post-split observation, then switch to the one-for-five reverse split. The same ratio definition works in both directions. Finally choose the zero-ratio scenario to see why rejecting an invalid factor is safer than emitting a smooth-looking series.

Failure modes worth testing

Invalid evidence stops before a plausible-looking result is emitted

  • Reciprocal factor: price is multiplied when it should be divided.
  • Off-by-one boundary: the first post-split observation is transformed a second time.
  • Wrong quantity field: trade count or notional value is treated as share volume.
  • Compound events: a dividend, rights issue, merger, or symbol change is collapsed into a simple split.
  • Rounding drift: each intermediate event is rounded rather than the declared public output.
  • Hindsight leakage: a later factor appears in an earlier point-in-time research view.
  • Vendor overgeneralization: one provider’s cumulative field is presented as universal mathematics.

A visually continuous chart is not proof that any of these controls passed.

Summary

Backward split adjustment is small enough to calculate by hand but important enough to deserve an explicit contract. Define rr as post-split shares per pre-split share, transform only observations before the validated boundary, preserve raw and derived histories, and keep knowledge time separate from effective time.

Continue with Forward Split Adjustment to compare a different target basis. Use Point-in-Time Availability Guard when the research question depends on what was knowable at a historical decision time.

Asset map

AssetPlacementTeaching purposeSourceStatus
OverviewOpeningSeparate raw record, boundary, and audited outputvisuals/static/article-hero.svgXML/layout checks passed; rendered QA pending
CalculationWorked exampleShow input, arithmetic, and complete resultvisuals/static/worked-example.svgXML/layout checks passed; rendered QA pending
Failure guardFailure modesMake rejection visiblevisuals/static/failure-guard.svgXML/layout checks passed; rendered QA pending
Guided labExplorationCompare forward, reverse, and invalid ratiosvisuals/animated/playground.htmlAutomated checks passed; browser QA pending

Primary source roles, access dates, and limitations are recorded in ../REFERENCES.md. All teaching data are synthetic. This article is educational material, not investment, legal, accounting, or tax advice.

Backward Split Adjustment calculation flow

This flow shows where validation and the effective boundary protect the calculation.

Rendering system map…

Takeaway: Ratio direction and target basis are validated before the strict boundary rule is applied.

Backward Split Adjustment evidence lifecycle

The lifecycle separates event validation, calculation eligibility, and point-in-time availability.

Rendering system map…

Takeaway: an event can be valid yet still unavailable to an earlier point-in-time decision.

References4 primary sources and evidence notes

Expand the source trail, evidence role, and limitations behind the engineering choices.

Accessed 2026-07-22 unless otherwise stated. Claim-local links in the README and article identify which source supports each statement.

R1 — Apple Reports Third Quarter Results

  • Organization or authors: Apple Inc.
  • Source type: Issuer press release
  • Publication date: 2020-07-30
  • URL: https://www.apple.com/newsroom/2020/07/apple-reports-third-quarter-results/
  • Jurisdiction: United States issuer disclosure
  • Supports: Board approval of the four-for-one split; August 24 record date; three additional shares per share held; August 31 split-adjusted trading basis
  • Limitations: Issuer announcement; not a market-data adjustment methodology

R2 — Apple Form 8-K, accession 0001193125-20-213158

  • Organization or authors: Apple Inc.; filed with the U.S. Securities and Exchange Commission
  • Source type: Issuer regulatory filing
  • Filing date: 2020-08-07
  • URL: https://www.sec.gov/Archives/edgar/data/320193/000119312520213158/d49399d8k.htm
  • Jurisdiction: United States
  • Supports: Automatic four-for-one split of each outstanding common share at 5 p.m. Pacific daylight time on 2020-08-28; AAPL registration on Nasdaq
  • Limitations: Establishes the legal corporate event, not a vendor’s historical-price field semantics

R3 — Apple 2020 Form 10-K

  • Organization or authors: Apple Inc.; filed with the U.S. Securities and Exchange Commission
  • Source type: Annual regulatory filing
  • Filing date: 2020-10-30
  • URL: https://www.sec.gov/Archives/edgar/data/320193/000032019320000096/aapl-20200926.htm
  • Jurisdiction: United States
  • Supports: Four-for-one split effected for holders of record as of 2020-08-24; share and per-share information retroactively adjusted; AAPL traded on Nasdaq
  • Limitations: Financial-statement presentation does not define every market-data provider’s adjustment basis

R4 — CRSP US Stock Data Descriptions Guide

  • Organization or authors: Center for Research in Security Prices, surfaced through WRDS
  • Source type: Authoritative vendor data dictionary and calculation guide
  • URL: https://wrds-www.wharton.upenn.edu/documents/399/Data_Descriptions_Guide.pdf
  • Jurisdiction: United States dataset
  • Supports: Example of provider-specific cumulative price and share factor conventions
  • Limitations: CRSP conventions apply to CRSP fields and licensed data; they are not the universal definition implemented here
backward_split_adjustment.ts
/** Reference implementation for D02-F01-A01 — Backward Split Adjustment. */

export const RATIO_CONVENTION = "post_split_shares_per_pre_split_share" as const;

export interface BackwardSplitInput {
  prices: number[];
  volumes: number[];
  eventIndex: number;
  postSplitSharesPerPreSplitShare: number;
}

export interface BackwardSplitOutput {
  adjustedPrices: number[];
  adjustedVolumes: number[];
  eventIndex: number;
  ratioConvention: typeof RATIO_CONVENTION;
}

const roundSix = (value: number): number => {
  const scaled = value * 1_000_000;
  if (!Number.isFinite(value) || !Number.isFinite(scaled)) {
    throw new RangeError("calculation produced a non-finite output");
  }
  const result = Math.floor(scaled + 0.5) / 1_000_000;
  return Number.isInteger(result) ? Math.trunc(result) : result;
};

const numberValue = (value: unknown, name: string): number => {
  if (typeof value !== "number" || !Number.isFinite(value)) {
    throw new TypeError(`${name} must contain only finite numbers`);
  }
  return value;
};

const positive = (value: unknown, name: string): number => {
  const result = numberValue(value, name);
  if (result <= 0) throw new RangeError(`${name} must be positive`);
  return result;
};

const nonnegative = (value: unknown, name: string): number => {
  const result = numberValue(value, name);
  if (result < 0) throw new RangeError(`${name} must be non-negative`);
  return result;
};

const series = (
  data: Record<string, unknown>,
  key: string,
  validator: (value: unknown, name: string) => number,
): number[] => {
  const values = data[key];
  if (!Array.isArray(values) || values.length === 0) {
    throw new TypeError(`${key} must be a non-empty list`);
  }
  return values.map((value) => validator(value, key));
};

const eventIndex = (value: unknown, length: number): number => {
  if (!Number.isInteger(value) || (value as number) <= 0 || (value as number) >= length) {
    throw new RangeError(
      "eventIndex must identify the first post-split observation and leave data on both sides",
    );
  }
  return value as number;
};

export function calculate(input: BackwardSplitInput): BackwardSplitOutput {
  if (!input || typeof input !== "object" || Array.isArray(input)) {
    throw new TypeError("data must be an object");
  }
  const data = input as unknown as Record<string, unknown>;
  const prices = series(data, "prices", positive);
  const volumes = series(data, "volumes", nonnegative);
  if (prices.length !== volumes.length) throw new RangeError("prices and volumes must align");

  const firstPostSplitIndex = eventIndex(data.eventIndex, prices.length);
  const ratio = positive(
    data.postSplitSharesPerPreSplitShare,
    "postSplitSharesPerPreSplitShare",
  );
  if (ratio === 1) {
    throw new RangeError("postSplitSharesPerPreSplitShare must describe a non-unit split");
  }

  return {
    adjustedPrices: prices.map((price, index) =>
      roundSix(index < firstPostSplitIndex ? price / ratio : price),
    ),
    adjustedVolumes: volumes.map((volume, index) =>
      roundSix(index < firstPostSplitIndex ? volume * ratio : volume),
    ),
    eventIndex: firstPostSplitIndex,
    ratioConvention: RATIO_CONVENTION,
  };
}
Full-height labplaygroundOpen full screen