D02-F01-A02 / Complete engineering topic

Forward Split Adjustment: carry later observations onto an earlier share basis

A production-minded guide to Forward Split Adjustment: carry later observations onto an earlier share basis.

D02 · CORPORATE ACTIONS AND SECURIT…
D02-F01-A02Canonical / 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 size of the unit called one share. It does not, by itself, create the price collapse or volume surge that an unadjusted chart appears to show. Forward split adjustment lets you express observations after a split on a selected earlier share basis, while keeping the raw history intact.

This is mainly useful for controlled reconciliation: comparing a later record with a pre-split ledger, reproducing an older report, or testing that two adjustment directions are true inverses before rounding. It is not the usual choice for a current-basis chart; backward split adjustment serves that different purpose.

Forward adjustment carries later records back to a declared earlier basis

Define the factor direction first

Split feeds do not all name factors in the same direction. Here the ratio is:

re=NeOer_e = \frac{N_e}{O_e}

where NeN_e is the number of new shares and OeO_e is the number of old shares for event ee. A four-for-one forward split therefore has Ne=4N_e=4, Oe=1O_e=1, and re=4r_e=4. This package rejects re1r_e \le 1 because unit factors and reverse splits belong to different contracts.

The target basis is the share unit in force at targetBasisAt. For an observation at time tt, the eligible event set is:

E(t)={etargetBasisAt<effectiveAtet}E(t)=\{e \mid \text{targetBasisAt} < \text{effectiveAt}_e \le t\}

The cumulative price factor and reciprocal quantity factor are:

FP(t)=eE(t)re,FQ(t)=1FP(t)F_P(t)=\prod_{e\in E(t)}r_e, \qquad F_Q(t)=\frac{1}{F_P(t)}

Then:

Ptearlier=PtrawFP(t),Qtearlier=QtrawFQ(t)P_t^{\text{earlier}}=P_t^{\text{raw}}F_P(t), \qquad Q_t^{\text{earlier}}=Q_t^{\text{raw}}F_Q(t)

QQ may be share-count volume or shares outstanding. Do not apply the quantity rule to trade count, currency notional, or an already adjusted field.

Why the boundary is inclusive

The event's effectiveAt is the first instant at which the input dataset says observations use the new share basis. Therefore an observation exactly at that instant is transformed; one before it is not. This is a package convention, not a claim that every vendor stamps daily or intraday records the same way.

The anchor is equally important. Events effective at or before targetBasisAt already belong to the chosen basis and must not be applied again. Multiple later splits compound in effective-time order. Because multiplication is commutative, simultaneous simple split factors produce the same arithmetic factor, but their event IDs are still retained for audit.

Rendering system map…

Takeaway: knowledge time chooses the event revision; effective time chooses which observations the selected revision transforms.

Work the synthetic example

Assume a validated four-for-one event and choose the pre-split share unit as the target basis. The numbers below are synthetic teaching values, not Apple prices.

ObservationRaw priceRaw share volumePrice factorQuantity factorEarlier-basis result
Before boundary1201,00011120 and 1,000
At boundary304,00040.25120 and 1,000
After boundary313,60040.25124 and 900

At the boundary, 30×4=12030\times4=120 and 4,000/4=1,0004{,}000/4=1{,}000. Before rounding, price times share quantity is invariant:

(PtFP)(Qt/FP)=PtQt(P_tF_P)(Q_t/F_P)=P_tQ_t

That identity catches arithmetic errors. It cannot prove that the ratio, timestamp, or event source is correct.

The worked example names both factor directions and the inclusive boundary

A real event, without fictional market data

Apple's July 30, 2020 issuer announcement said its board approved a four-for-one split, holders of record on August 24 would receive three additional shares per share held, and trading would begin on a split-adjusted basis on August 31 (Apple Newsroom). The related filing was accepted by EDGAR on July 30 at 18:55:04 (SEC filing detail).

A later Form 8-K records another boundary: at 5 p.m. Pacific daylight time on August 28, each outstanding share would automatically split into four (SEC Form 8-K). Apple's 2020 Form 10-K then states that share, restricted-stock-unit, and per-share information was retroactively adjusted for the split (SEC Form 10-K).

These are sourced facts. The teaching calculation 30×4=12030\times4=120 is a derived illustration of the declared ratio, not a report of Apple's traded prices. No provider series is used as authority. The case shows why a single field called splitDate is inadequate: announcement, record date, legal effect, new-basis trading date, provider publication, and local ingestion can differ.

For this package's daily synthetic fixture, effectiveAt = 2020-08-31T00:00:00Z is an explicit dataset convention representing the start of the August 31 daily label. It is not asserted to be Nasdaq's market-open timestamp.

Revisions and point-in-time lineage

Corporate-action records can be corrected or cancelled. The implementation groups revisions by eventId, verifies a linear supersedesRevisionId chain, and selects the latest revision whose publishedAt is no later than knowledgeAt. A known cancellation removes the transformation but remains visible in selectedRevisions. A later correction cannot leak into an earlier knowledge cutoff.

This does not make a forward-adjusted series a historical traded series. It creates a derived comparison basis as of a declared knowledge cutoff. Preserve both raw and adjusted values, the selected revision, event source, anchor, effective boundary, and generation time.

Rounding and reversibility

Without rounding, the inverse is direct: divide adjusted price by FPF_P and divide adjusted quantity by FQF_Q. Public output may be rounded from zero to twelve decimal places. Once rounded, exact reversal is not guaranteed, so the result emits a warning. Production systems should retain unrounded decimal values internally and apply security- and venue-specific precision at a documented boundary.

Implementation contract

The Python and TypeScript implementations accept:

  • UTC targetBasisAt and knowledgeAt instants ending in Z;
  • strictly increasing observations with positive prices, non-negative share volume, and positive shares outstanding;
  • effective-dated event revisions with explicit new and old share counts;
  • an optional output precision, where null means no public rounding.

They return the raw and adjusted fields, both cumulative factor directions, applied event IDs, selected point-in-time revisions, the exact boundary rule, and rounding warnings. They reject malformed timestamps, duplicate observations, broken revision chains, non-forward ratios, and impossible quantities.

Explore the state changes

The guided playground provides four deterministic scenarios: one four-for-one boundary, two compounding splits, a known cancellation, and an invalid unit ratio. Step or play through 24 synthetic observations; Back reverses exactly one step and Reset restores the canonical state.

Invalid ratios and broken lineage stop before a plausible adjusted series is emitted

What this method does not prove

  • A smooth series does not validate the event source.
  • Price-volume invariance does not validate the boundary.
  • An issuer event does not validate a vendor's adjusted-price history.
  • Forward adjustment does not create a historical traded price.
  • A split-only factor does not cover dividends, rights, spin-offs, mergers, cash-in-lieu, taxes, or fractional entitlements.
  • Tests establish the package contract, not investment usefulness or provider parity.

Summary

Define rr as new shares per old share, declare an earlier target basis, and apply only active, knowable events with targetBasisAt < effectiveAt <= observation.timestamp. Multiply price by the compounded factor, divide share quantities by it, keep revision lineage, and round only at the public boundary.

Continue with Cash-Dividend Total-Return Adjustment for a cash distribution, or revisit Backward Split Adjustment when the target is a later share basis.

Asset map

AssetPlacementPurposeSourceStatus
Basis directionOpeningContrast raw post-split and earlier-basis valuesvisuals/static/article-hero.svgReady
Worked calculationWorked exampleShow factors, boundary, and invariantvisuals/static/worked-example.svgReady
Failure guardValidationMake invalid ratios and lineage visiblevisuals/static/failure-guard.svgReady
Revision flowContractSeparate knowledge and effective timevisuals/mermaid/calculation-flow.mdReady
Guided labExplorationCompare boundary, compounding, and cancellationvisuals/animated/playground.htmlReady

Source roles and limitations are recorded in ../REFERENCES.md. This material is educational, not investment, legal, accounting, or tax advice.

Forward Split Adjustment calculation flow

This flow separates revision availability from effective-time eligibility.

Rendering system map…

Takeaway: knowledge time chooses the revision; effective time chooses the transformed observations.

Corporate-action revision lifecycle

This state diagram shows why a cancellation is evidence, not a record to delete.

Rendering system map…

Takeaway: select the latest revision available at the knowledge cutoff and retain its complete chain.

References5 primary sources and evidence notes

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

Accessed 2026-07-22. Public claims use issuer and regulator records. The package contains no provider observations or licensed market data.

R1 — Apple reports third quarter results

  • Organization: Apple Inc.
  • Source type: Official issuer press release
  • Publication date: 2020-07-30
  • URL: https://www.apple.com/newsroom/2020/07/apple-reports-third-quarter-results/
  • Jurisdiction: United States
  • Supports: Board approval of a four-for-one split; August 24 record date; three additional shares per share held; split-adjusted trading beginning August 31
  • Limitations: Does not define this package's timestamp mapping, factor field names, adjusted-price methodology, or any teaching price

R2 — Apple Form 8-K filing detail, accession 0000320193-20-000060

  • Organization: U.S. Securities and Exchange Commission; filer Apple Inc.
  • Source type: Official EDGAR filing index
  • Filing and acceptance date: 2020-07-30; accepted 2020-07-30 18:55:04
  • URL: https://www.sec.gov/Archives/edgar/data/320193/000032019320000060/0000320193-20-000060-index.htm
  • Jurisdiction: United States
  • Supports: Reproducible EDGAR accession and public acceptance timestamp for the issuer announcement exhibit
  • Limitations: EDGAR acceptance is a publication timestamp, not the split's legal or trading-effective time

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

  • Organization: U.S. Securities and Exchange Commission; filer Apple Inc.
  • Source type: Official current report
  • Filing date: 2020-08-07; earliest event reported 2020-08-03
  • URL: https://www.sec.gov/Archives/edgar/data/320193/000119312520213158/d49399d8k.htm
  • Jurisdiction: United States
  • Supports: Each outstanding share automatically split into four at 5 p.m. Pacific daylight time on 2020-08-28
  • Limitations: The legal-effective timestamp is distinct from the first trading date on the new basis

R4 — Apple 2020 Form 10-K, accession 0000320193-20-000096

  • Organization: U.S. Securities and Exchange Commission; filer Apple Inc.
  • Source type: Official annual report
  • 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 on August 28; share, restricted-stock-unit, and per-share information retroactively adjusted
  • Limitations: Accounting presentation does not define a universal market-data adjustment algorithm

R5 — Apple dividend and split history

  • Organization: Apple Inc. Investor Relations
  • Source type: Official issuer event-history table
  • URL: https://investor.apple.com/dividend-history/default.aspx
  • Jurisdiction: United States
  • Supports: Cross-check of July 30 announcement, August 24 record date, August 31 split date, and four-for-one description
  • Limitations: Summary table omits the legal-effective intraday timestamp and engineering lineage fields

Evidence classification

  • Sourced fact: event ratio and the issuer/SEC dates and timestamps above.
  • Implementation choice: newShares / oldShares, the inclusive effectiveAt boundary, the earlier anchor-basis convention, revision-chain validation, and rounding behavior.
  • Derived teaching calculation: 30 times 4 equals 120; 4,000 divided by 4 equals 1,000.
  • Synthetic data: every price, volume, shares-outstanding value, event ID, and daily UTC boundary in fixtures and visuals.
  • Provider observation: none. A vendor series would be methodology- and license-dependent and could only be a separately labeled observation, never issuer-event authority.
forward_split_adjustment.ts
/** Forward-adjust split-basis observations with point-in-time event revisions. */
type RecordValue = Record<string, any>;

function utc(value: unknown, field: string): number {
  if (typeof value !== "string" || !value.endsWith("Z")) throw new RangeError(`${field} must be an ISO 8601 UTC instant ending in Z`);
  const parsed = Date.parse(value);
  if (!Number.isFinite(parsed)) throw new RangeError(`${field} must be a valid ISO 8601 UTC instant`);
  return parsed;
}

function numberValue(value: unknown, field: string, positive = false): number {
  if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${field} must be a finite number`);
  if (positive ? value <= 0 : value < 0) throw new RangeError(`${field} must be ${positive ? "positive" : "non-negative"}`);
  return value;
}

function textValue(value: unknown, field: string): string {
  if (typeof value !== "string" || !value.trim()) throw new TypeError(`${field} must be a non-empty string`);
  return value;
}

function rounded(value: number, places: number | null): number {
  if (places === null) return Number.isInteger(value) ? Math.trunc(value) : value;
  const scale = 10 ** places;
  const result = Math.round((value + Number.EPSILON) * scale) / scale;
  return Number.isInteger(result) ? Math.trunc(result) : result;
}

function selectRevisions(revisions: RecordValue[], knowledgeAt: number): { active: RecordValue[]; lineage: RecordValue[] } {
  const groups = new Map<string, RecordValue[]>();
  const revisionIds = new Set<string>();
  for (const raw of revisions) {
    if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new TypeError("each event revision must be an object");
    const revision = structuredClone(raw);
    const eventId = textValue(revision.eventId, "eventId");
    const revisionId = textValue(revision.revisionId, "revisionId");
    if (revisionIds.has(revisionId)) throw new RangeError("revisionId values must be unique");
    revisionIds.add(revisionId);
    revision._published = utc(revision.publishedAt, "publishedAt");
    revision._effective = utc(revision.effectiveAt, "effectiveAt");
    const newShares = numberValue(revision.newShares, "newShares", true);
    const oldShares = numberValue(revision.oldShares, "oldShares", true);
    if (newShares <= oldShares) throw new RangeError("a forward split requires newShares greater than oldShares");
    if (revision.status !== "active" && revision.status !== "cancelled") throw new RangeError("status must be active or cancelled");
    if (revision.supersedesRevisionId !== null) textValue(revision.supersedesRevisionId, "supersedesRevisionId");
    groups.set(eventId, [...(groups.get(eventId) ?? []), revision]);
  }

  const active: RecordValue[] = [];
  const lineage: RecordValue[] = [];
  for (const [eventId, group] of groups) {
    group.sort((a, b) => a._published - b._published || a.revisionId.localeCompare(b.revisionId));
    let previous: string | null = null;
    for (const revision of group) {
      if (revision.supersedesRevisionId !== previous) throw new RangeError(`broken or branching revision chain for ${eventId}`);
      previous = revision.revisionId;
    }
    const known = group.filter((revision) => revision._published <= knowledgeAt);
    if (!known.length) continue;
    const chosen = known.at(-1)!;
    const publicRevision = Object.fromEntries(Object.entries(chosen).filter(([key]) => !key.startsWith("_")));
    lineage.push(publicRevision);
    if (chosen.status === "active") active.push(chosen);
  }
  active.sort((a, b) => a._effective - b._effective || a.eventId.localeCompare(b.eventId));
  lineage.sort((a, b) => a.eventId.localeCompare(b.eventId));
  return { active, lineage };
}

export function calculate(data: RecordValue): RecordValue {
  if (!data || typeof data !== "object" || Array.isArray(data)) throw new TypeError("data must be an object");
  const targetAt = utc(data.targetBasisAt, "targetBasisAt");
  const knowledgeAt = utc(data.knowledgeAt, "knowledgeAt");
  const places = data.roundDecimalPlaces;
  if (places !== null && (!Number.isInteger(places) || places < 0 || places > 12)) throw new RangeError("roundDecimalPlaces must be null or an integer from 0 to 12");
  if (!Array.isArray(data.observations) || !data.observations.length) throw new TypeError("observations must be a non-empty list");
  if (!Array.isArray(data.eventRevisions)) throw new TypeError("eventRevisions must be a list");
  const { active, lineage } = selectRevisions(data.eventRevisions, knowledgeAt);

  let previousTime: number | null = null;
  const observations = data.observations.map((raw: RecordValue) => {
    if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new TypeError("each observation must be an object");
    const timestamp = utc(raw.timestamp, "timestamp");
    if (previousTime !== null && timestamp <= previousTime) throw new RangeError("observation timestamps must be unique and strictly increasing");
    previousTime = timestamp;
    const price = numberValue(raw.price, "price", true);
    const volume = numberValue(raw.volume, "volume");
    const shares = numberValue(raw.sharesOutstanding, "sharesOutstanding", true);
    const applied = active.filter((event) => targetAt < event._effective && event._effective <= timestamp);
    const factor = applied.reduce((value, event) => value * event.newShares / event.oldShares, 1);
    return {
      timestamp: raw.timestamp,
      rawPrice: rounded(price, places), adjustedPrice: rounded(price * factor, places),
      rawVolume: rounded(volume, places), adjustedVolume: rounded(volume / factor, places),
      rawSharesOutstanding: rounded(shares, places), adjustedSharesOutstanding: rounded(shares / factor, places),
      cumulativePriceFactor: rounded(factor, 12), cumulativeQuantityFactor: rounded(1 / factor, 12),
      appliedEventIds: applied.map((event) => event.eventId),
    };
  });

  return {
    basis: {
      targetBasisAt: data.targetBasisAt, knowledgeAt: data.knowledgeAt,
      ratioConvention: "new_shares_per_old_share",
      boundaryRule: "targetBasisAt < effectiveAt <= observation.timestamp",
      priceDirection: "multiply_post_boundary_values_to_earlier_basis",
      quantityDirection: "divide_post_boundary_share_quantities_to_earlier_basis",
      roundDecimalPlaces: places,
    },
    selectedRevisions: lineage,
    observations,
    warnings: places === null ? [] : ["rounded outputs may not reverse exactly"],
  };
}
Full-height labplaygroundOpen full screen