D02-F01-A03 / Complete engineering topic

Cash-Dividend Total-Return Adjustment: From Price Drop to Exact Return

A production-minded guide to Cash-Dividend Total-Return Adjustment: From Price Drop to Exact Return.

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

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

A cash dividend creates two legitimate histories. A price-return history follows only the traded price. A total-return history also follows distributed cash. This tutorial builds the second history without hiding the methodology choices that make it reproducible.

What the method delivers

You will calculate price, gross-total-return, and net-total-return links; derive the factor that converts those links into a backward-adjusted close series; and audit which event revision, currency conversion, tax rate, and effective date produced every number.

A price path, cash distribution, and auditable total-return path

The goal is not cosmetic smoothness. It is an exact equality between each adjusted-close link and the declared total-return formula.

Start with three different questions

For official closes Pt1P_{t-1} and PtP_t, price return asks only what happened to price:

rtP=PtPt11.r_t^P = \frac{P_t}{P_{t-1}}-1.

Gross total return adds all eligible cash on the ex-date. Net total return adds cash after an explicit withholding rate. If DeD_e is event cash, XeX_e converts it to the price currency, and wew_e is withholding, then

dtgross=DeXe,dtnet=DeXe(1we),d_t^{gross}=\sum D_eX_e, \qquad d_t^{net}=\sum D_eX_e(1-w_e),

and

rtTR,v=Pt+dt(v)Pt11.r_t^{TR,v}=\frac{P_t+d_t^{(v)}}{P_{t-1}}-1.

S&P DJI's published index mathematics separates price and total return, applies withholding to net return, and recognizes cash on the ex-date. FTSE Russell's published corporate-action method similarly describes gross cash as generally reinvested on the ex-date while separating special distributions from ordinary cash. These are authoritative examples, not proof that every vendor uses one universal rule.

The exact backward factor

This package reinvests cash at the same official ex-date close PtP_t. The factor is

bt(v)=PtPt+dt(v).b_t^{(v)}=\frac{P_t}{P_t+d_t^{(v)}}.

Apply it only to dates strictly before the ex-date. Do not multiply the ex-date or any later price. With the latest raw close as the anchor,

Ai(v)=Pit>ibt(v).A_i^{(v)}=P_i\prod_{t>i}b_t^{(v)}.

That direction is what makes the identity exact:

AtAt11=Pt+dt(v)Pt11.\frac{A_t}{A_{t-1}}-1 =\frac{P_t+d_t^{(v)}}{P_{t-1}}-1.

This factor is not the often-seen theoretical-drop ratio (Pt1D)/Pt1(P_{t-1}-D)/P_{t-1}. That other ratio answers a different question and only matches this total-return construction under restrictive price behavior. Naming the method prevents the two conventions from being silently mixed.

A synthetic worked example

Suppose the previous close is USD 102, the ex-date close is USD 99, and the latest available revision confirms an ordinary USD 3 dividend.

MeasureCalculationResult
Price return99/102199/102-12.941176%-2.941176\%
Gross total return(99+3)/1021(99+3)/102-10%0\%
Backward factor99/(99+3)99/(99+3)0.9705882352940.970588235294

Worked example with price return, cash, total return, and factor direction

Earlier closes 100 and 102 become 97.058823529412 and 99. The ex-date close remains 99. The gross total-return link is therefore 99/991=0%99/99-1=0\%, exactly matching the cash-added formula.

If withholding is 15%, the net cash is USD 2.55. Net return is lower than gross return, and its backward factor is closer to one. A number labeled only "adjusted" does not tell the reader which of those series it represents.

The operational contract matters as much as the formula

Rendering system map…

The same stable event ID may have multiple revisions. The calculation selects the highest revision available at asOf; a later cancellation supersedes an earlier confirmation without erasing history. A future correction never leaks into an earlier vintage.

Cash and price must share a currency. Same-currency events use FX 1. Cross-currency events need a documented rate whose direction is dividend currency to price currency and whose observation is available by asOf. Net treatment also needs an explicit withholding rate. The implementation never invents either input.

Ordinary and special distributions remain separate classifications. This package can include special cash under an explicit include_as_cash policy or exclude it with a diagnostic. It never assumes that a provider treats special cash like ordinary income; FTSE Russell, for example, documents cases where special distributions receive capital-return handling.

A real event, and an honest stopping point

Apple's SEC-furnished July 30, 2020 release says its board declared USD 0.82 per common share, payable August 13 to shareholders of record at the August 10 close. EDGAR records the filing as accepted at 18:55:04 that day. Apple's investor history labels it regular cash and notes that historical amounts there are not split-adjusted.

Those primary records establish declaration facts and public availability. They do not give this package an ex-date or an official ex-date close. They also do not select gross or net treatment. A numeric Apple factor would therefore require another authoritative market-data observation and a declared provider policy. None is invented here. The real event demonstrates the evidence checklist; the numerical example remains synthetic.

Reject undefined inputs visibly

Failure boundary for prices, cash, FX, and evidence

The implementation rejects nonpositive or unavailable prices, missing ex-date observations, cross-currency cash without observable FX, duplicated revisions, and revision timestamps that move backward. It also rejects aggregate eligible cash greater than or equal to the chosen pre-event close. That last guard defines the scope even though a formal ex-date-close ratio could still be computed.

Factors are multiplied without intermediate rounding. Only serialized output is rounded to 12 decimal places, and the output says so. Currency entitlement or legal accounting may need decimal arithmetic and jurisdiction-specific rounding; binary64 parity is not a substitute for those rules.

Explore the method

Open the guided total-return lab. Compare gross and net cash, step across the ex-date, change the scenario to the just-valid boundary, and then inspect the rejected case. Back, Step, Play, Pause, Reset, and speed all use the same deterministic transition.

What passing tests proves

Python and TypeScript share the same fixture. Tests cover the exact result, immutability, gross/net separation, latest-available revisions, cancellation, special treatment, cross-currency FX, multi-event compounding, anchor preservation, and impossible inputs.

They prove conformance to backward_adjusted_ex_date_close_total_return. They do not prove parity with a vendor adjusted-close field or that any return is investable after costs and taxes.

Summary

An auditable cash-dividend adjustment needs more than D/PD/P. Name the return variant, use the ex-date and a documented reinvestment close, convert currency, retain revisions, declare special-event policy, apply each factor backward only, preserve the anchor, and expose rounding lineage.

Continue with CRSP Cumulative Price Adjustment to study a vendor-specific cumulative-factor model.

Asset map

AssetPlacementVideo sceneSourceStatus
Method overviewOpening1-2visuals/static/article-hero.svgReady
Exact factorWorked example4-5visuals/static/worked-example.svgReady
Failure boundaryInvalid inputs7visuals/static/failure-guard.svgReady
Guided labExploration4-7visuals/animated/playground.htmlReady

Primary source roles and limitations are recorded in ../REFERENCES.md.

Revision-aware total-return calculation

This flow shows why availability, classification, currency, and tax are resolved before any factor is calculated.

Rendering system map…

Takeaway: a formula is applied only after the information vintage and economic convention are complete.

Point-in-time event revision lifecycle

The state model preserves revisions rather than rewriting a prior information set.

Rendering system map…

Takeaway: a later revision changes a later vintage; it does not become evidence that an earlier process could have known.

References6 primary sources and evidence notes

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

Accessed 2026-07-22. Public claims rely on official methodology, regulator, and issuer sources. No licensed provider observations or redistributed market prices are used.

R1 - S&P DJI Index Mathematics Methodology

  • Organization: S&P Dow Jones Indices
  • Source type: Official index methodology
  • Publication date/version: April 2026
  • URL: https://www.spglobal.com/spdji/en/documents/methodologies/methodology-index-math.pdf
  • Supports: Price versus total return; ex-date dividend recognition; gross and withholding-adjusted net total return; post-ex-date dividend corrections; FX treatment for corrections
  • Applicability: Authoritative example of one published index-provider convention
  • Limitations: S&P DJI index mathematics is not a universal single-security adjusted-close specification. The package derives its backward close factor algebraically from the documented total-return link and labels that derivation as a package choice.

R2 - FR Global Equity Corporate Action Methodology

  • Organization: FTSE Russell, London Stock Exchange Group
  • Source type: Official index methodology
  • Publication date/version: January 2025, version 1.1
  • URL: https://www.lseg.com/content/dam/ftse-russell/en_us/documents/policy-documents/fr-global-equity-corporate-action-methodology.pdf
  • Supports: Regular cash affecting total-return rather than price-return indices; gross cash generally reinvested on ex-date; price, dividend, shares, and FX as end-of-day inputs; distinct special-dividend handling
  • Applicability: Corroborates why classification, timing, and currency must be explicit
  • Limitations: The document describes FR Global Equity Indices and contains local-market and committee exceptions; its rules are not copied as universal policy.

R3 - Apple July 30, 2020 Form 8-K filing detail

  • Organization: U.S. Securities and Exchange Commission
  • Source type: Official EDGAR filing metadata
  • Filing/accepted date: July 30, 2020; accepted 18:55:04
  • Accession: 0000320193-20-000060
  • URL: https://www.sec.gov/Archives/edgar/data/320193/000032019320000060/0000320193-20-000060-index.htm
  • Supports: Filing identity, form, date, acceptance timestamp, and the Exhibit 99.1 relationship
  • Limitations: Filing metadata does not provide the market-data inputs required for a numeric adjustment.

R4 - Apple Reports Third Quarter Results, Exhibit 99.1

  • Organization: Apple Inc.; furnished to the SEC
  • Source type: Issuer press release in an SEC filing exhibit
  • Publication date: July 30, 2020
  • URL: https://www.sec.gov/Archives/edgar/data/320193/000032019320000060/a8-kexhibit991q3202062.htm
  • Supports: Board declaration of USD 0.82 per common share, August 10 record date, and August 13 payment date
  • Limitations: Does not state the ex-date, ex-date official close, provider vintage, withholding convention, or FX input. It cannot support a numeric total-return adjustment by itself.

R5 - Apple Dividend History

  • Organization: Apple Inc. Investor Relations
  • Source type: Issuer historical event table
  • URL: https://investor.apple.com/dividend-history/default.aspx
  • Supports: Classification of the July 30, 2020 declaration as regular cash and the issuer's warning that displayed dividend amounts are not split-adjusted
  • Limitations: Does not provide the ex-date or a licensed official close. It is corroborating event evidence, not a price source.

R6 - RFC 3339 and RFC 9557

  • Organization: Internet Engineering Task Force
  • Source type: Internet standards
  • Publication/version: RFC 3339 (2002), updated by RFC 9557 (2024)
  • URLs: https://www.rfc-editor.org/rfc/rfc3339 and https://www.rfc-editor.org/rfc/rfc9557
  • Supports: Offset-bearing Internet timestamp syntax and the distinction between an instant and a named time zone
  • Limitations: These standards do not define exchange calendars, ex-dates, or market close selection.
cash_dividend_total_return_adjustment.ts
/** Auditable ex-date-close cash-dividend total-return adjustment. */
const DECIMALS = 12;
const round = (value: number): number => Number(value.toFixed(DECIMALS));
const objectValue = (value: unknown, name: string): Record<string, any> => {
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${name} must be an object`);
  return value as Record<string, any>;
};
const listValue = (value: unknown, name: string): any[] => {
  if (!Array.isArray(value) || value.length === 0) throw new TypeError(`${name} must be a non-empty list`);
  return value;
};
const textValue = (value: unknown, name: string): string => {
  if (typeof value !== "string" || value.trim() === "") throw new TypeError(`${name} must be a non-empty string`);
  return value;
};
const numberValue = (value: unknown, name: string): number => {
  if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be finite`);
  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 rate = (value: unknown, name: string): number => {
  const result = numberValue(value, name); if (result < 0 || result >= 1) throw new RangeError(`${name} must be in [0, 1)`); return result;
};
const instant = (value: unknown, name: string): number => {
  const text = textValue(value, name);
  if (!/(Z|[+-]\d{2}:\d{2})$/.test(text)) throw new RangeError(`${name} must include a UTC offset`);
  const result = Date.parse(text); if (!Number.isFinite(result)) throw new RangeError(`${name} must be an ISO 8601 instant`); return result;
};
const dateValue = (value: unknown, name: string): string => {
  const text = textValue(value, name);
  const parsed = new Date(`${text}T00:00:00Z`);
  if (!/^\d{4}-\d{2}-\d{2}$/.test(text) || Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== text) throw new RangeError(`${name} must use YYYY-MM-DD`);
  return text;
};
const currency = (value: unknown, name: string): string => {
  const text = textValue(value, name); if (!/^[A-Z]{3}$/.test(text)) throw new RangeError(`${name} must be an uppercase three-letter currency code`); return text;
};
const revisionValue = (value: unknown): number => {
  if (!Number.isInteger(value) || (value as number) < 1) throw new RangeError("revision must be a positive integer"); return value as number;
};

export function calculate(data: unknown): Record<string, any> {
  const payload = objectValue(data, "data");
  const asOfText = textValue(payload.asOf, "asOf");
  const asOf = instant(asOfText, "asOf");
  const variant = textValue(payload.returnVariant, "returnVariant");
  if (!new Set(["gross", "net"]).has(variant)) throw new RangeError("returnVariant must be gross or net");
  const priceCurrency = currency(payload.priceCurrency, "priceCurrency");
  if (payload.anchorBasis !== "latest_raw_close") throw new RangeError("anchorBasis must be latest_raw_close");
  const specialPolicy = payload.specialDistributionPolicy;
  if (!new Set(["include_as_cash", "exclude"]).has(specialPolicy)) throw new RangeError("specialDistributionPolicy must be include_as_cash or exclude");

  const observations: any[] = [];
  const seenDates = new Set<string>();
  for (const raw of listValue(payload.observations, "observations")) {
    const row = objectValue(raw, "observation");
    const rowDate = dateValue(row.date, "observation.date");
    if (seenDates.has(rowDate)) throw new RangeError("observation dates must be unique");
    if (observations.length && rowDate <= observations.at(-1).date) throw new RangeError("observations must be strictly ordered by date");
    const availableAt = textValue(row.availableAt, "observation.availableAt");
    if (instant(availableAt, "observation.availableAt") > asOf) throw new RangeError("an observation is unavailable as of asOf");
    observations.push({ date: rowDate, close: positive(row.close, "observation.close"), availableAt });
    seenDates.add(rowDate);
  }
  if (observations.length < 2) throw new RangeError("at least two observations are required");
  const dateToIndex = new Map(observations.map((row, index) => [row.date, index]));

  const grouped = new Map<string, any[]>();
  for (const raw of listValue(payload.eventRevisions, "eventRevisions")) {
    const row = objectValue(raw, "eventRevision");
    const eventId = textValue(row.eventId, "eventId");
    const revision = revisionValue(row.revision);
    const status = textValue(row.status, "status");
    if (!new Set(["confirmed", "cancelled"]).has(status)) throw new RangeError("status must be confirmed or cancelled");
    const classification = textValue(row.classification, "classification");
    if (!new Set(["ordinary", "special"]).has(classification)) throw new RangeError("classification must be ordinary or special");
    const dividendCurrency = currency(row.dividendCurrency, "dividendCurrency");
    const fxRate = positive(row.fxRateToPriceCurrency, "fxRateToPriceCurrency");
    const fxObservedAt = row.fxObservedAt;
    let fxInstant: number | null = null;
    if (dividendCurrency === priceCurrency) {
      if (fxRate !== 1 || fxObservedAt !== null) throw new RangeError("same-currency dividends require FX=1 and fxObservedAt=null");
    } else if (fxObservedAt === null || fxObservedAt === undefined) {
      throw new RangeError("cross-currency FX must have an observation timestamp");
    } else {
      fxInstant = instant(fxObservedAt, "fxObservedAt");
    }
    const availableAt = textValue(row.availableAt, "event.availableAt");
    const availableInstant = instant(availableAt, "event.availableAt");
    const normalized = {
      eventId, revision, status, classification,
      exDate: dateValue(row.exDate, "exDate"),
      grossDividend: positive(row.grossDividend, "grossDividend"),
      dividendCurrency,
      withholdingRate: rate(row.withholdingRate, "withholdingRate"),
      fxRateToPriceCurrency: fxRate,
      fxObservedAt,
      fxInstant,
      availableAt,
      availableInstant,
      sourceId: textValue(row.sourceId, "sourceId"),
    };
    grouped.set(eventId, [...(grouped.get(eventId) ?? []), normalized]);
  }

  const selected: any[] = [];
  const excludedEvents: any[] = [];
  for (const eventId of [...grouped.keys()].sort()) {
    const revisions = grouped.get(eventId)!.sort((a, b) => a.revision - b.revision);
    if (new Set(revisions.map(row => row.revision)).size !== revisions.length) throw new RangeError(`duplicate revision for ${eventId}`);
    for (let index = 1; index < revisions.length; index++) if (revisions[index].availableInstant < revisions[index - 1].availableInstant) throw new RangeError(`revision availability regresses for ${eventId}`);
    const available = revisions.filter(row => row.availableInstant <= asOf);
    if (!available.length) { excludedEvents.push({ eventId, reason: "unavailable_as_of" }); continue; }
    const current = available.at(-1);
    if (current.status === "cancelled") { excludedEvents.push({ eventId, selectedRevision: current.revision, reason: "cancelled" }); continue; }
    if (current.classification === "special" && specialPolicy === "exclude") { excludedEvents.push({ eventId, selectedRevision: current.revision, reason: "special_excluded_by_policy" }); continue; }
    if (current.fxInstant !== null && current.fxInstant > asOf) throw new RangeError(`cross-currency FX for ${eventId} is unavailable as of asOf`);
    if (!dateToIndex.has(current.exDate)) throw new RangeError(`exDate for ${eventId} is absent from observations`);
    const exIndex = dateToIndex.get(current.exDate)!;
    if (exIndex === 0) throw new RangeError(`exDate for ${eventId} needs a prior close`);
    const grossConverted = current.grossDividend * current.fxRateToPriceCurrency;
    let effectiveDividend = grossConverted;
    if (variant === "net") effectiveDividend *= 1 - current.withholdingRate;
    selected.push({ ...current, exIndex, grossConverted, effectiveDividend });
  }

  const byIndex = new Map<number, any[]>();
  for (const event of selected) byIndex.set(event.exIndex, [...(byIndex.get(event.exIndex) ?? []), event]);
  const eventAdjustments: any[] = [];
  const factors = new Map<number, number>();
  const dividendByIndex = new Map<number, number>();
  for (const exIndex of [...byIndex.keys()].sort((a, b) => a - b)) {
    const events = byIndex.get(exIndex)!.sort((a, b) => a.eventId.localeCompare(b.eventId));
    const grossDividend = events.reduce((sum, row) => sum + row.grossConverted, 0);
    const dividend = events.reduce((sum, row) => sum + row.effectiveDividend, 0);
    const priorClose = observations[exIndex - 1].close;
    const exDateClose = observations[exIndex].close;
    if (grossDividend >= priorClose) throw new RangeError("gross converted dividend must be below the chosen pre-event close");
    const factor = exDateClose / (exDateClose + dividend);
    if (!Number.isFinite(factor) || factor <= 0 || factor > 1) throw new RangeError("event factor is undefined");
    factors.set(exIndex, factor); dividendByIndex.set(exIndex, dividend);
    eventAdjustments.push({
      exDate: observations[exIndex].date, exIndex,
      eventIds: events.map(row => row.eventId), selectedRevisions: events.map(row => row.revision), sourceIds: events.map(row => row.sourceId),
      priorClose: round(priorClose), exDateClose: round(exDateClose), grossDividendPriceCurrency: round(grossDividend), effectiveDividend: round(dividend), backwardFactor: round(factor),
      factorAppliesTo: `indices < ${exIndex}`,
    });
  }

  let running = 1;
  const cumulativeFactors = Array(observations.length).fill(1);
  const adjustedPrices = Array(observations.length).fill(0);
  for (let index = observations.length - 1; index >= 0; index--) {
    cumulativeFactors[index] = running;
    adjustedPrices[index] = observations[index].close * running;
    if (factors.has(index)) running *= factors.get(index)!;
  }
  const returnLinks = [];
  for (let index = 1; index < observations.length; index++) {
    const previous = observations[index - 1].close;
    const current = observations[index].close;
    const dividend = dividendByIndex.get(index) ?? 0;
    returnLinks.push({ date: observations[index].date, priceReturn: round(current / previous - 1), totalReturn: round((current + dividend) / previous - 1), effectiveDividend: round(dividend) });
  }
  return {
    methodology: "backward_adjusted_ex_date_close_total_return", returnVariant: variant, priceCurrency, asOf: asOfText,
    anchor: { basis: "latest_raw_close", date: observations.at(-1).date, rawClose: round(observations.at(-1).close) },
    adjustedPrices: adjustedPrices.map(round), cumulativeFactors: cumulativeFactors.map(round), eventAdjustments, returnLinks, excludedEvents,
    rounding: { internal: "unrounded IEEE-754 binary64", serializedDecimalPlaces: DECIMALS, factorCompounding: "multiply unrounded factors, round only serialized output" },
  };
}
Full-height labplaygroundOpen full screen