Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Fintech Forward Split Adjustment — Corporate Actions Algorithm

A canonical, well-specified, cross-language (Python + TypeScript) reference implementation of forward split adjustment — carrying post-split observations back onto the share basis that was in force at an earlier date, which is what a cost basis, a historical NAV or a filed share count actually needs. Prices are multiplied and share quantities divided, the reverse of backward adjustment in both legs, with market capitalisation as the invariant. The adjustment takes two independent timestamps — which basis you want, and what you knew when you asked — because splits get re-announced and cancelled, and the same question legitimately answers differently at different knowledge times.

Python TypeScript License Tests

📖 Full article (canonical): Forward Split Adjustment — The Fintech Builder

This repository is the runnable, production-oriented companion to that article. The article teaches the concept; this repo is the code you install and build on.

🧭 Browse all algorithms: Awesome FinTech Algorithms — the full index of the library. 🗂️ This algorithm's domain: Corporate Actions and Security Master DataAdjustment Factors 📥 Just want to call it? It also ships in the fintech-algorithms npm package — see Two ways to use this.

Catalog topic D02-F01-A02
Domain D02 — Corporate Actions and Security Master Data
Family D02-F01 — Adjustment Factors
Difficulty 2 / 5
Languages Python, TypeScript
Mirror of Backward Split Adjustment

Table of contents


The question backward adjustment cannot answer

Backward adjustment restates history onto today's basis. That is the right answer for charting and for return series, and the wrong answer for a surprising number of other things.

A cost basis is quoted in the shares you actually bought. A NAV filed in 2019 is quoted in the shares that existed in 2019. A share count in a 10-K is the count on the cover date. When you need to reproduce one of those numbers after a subsequent split, you need to carry the later observations back:

raw prices:       [120, 30, 31]      <- 4-for-1 split between obs 0 and obs 1
on the old basis: [120, 120, 124]

Backward adjustment applied to the same series produces [30, 30, 31]. Both are correct; they answer different questions. Using one where the other belongs is a class of error that reconciles cleanly against itself and disagrees with every external document.


Market capitalisation is the invariant

Prices are multiplied by the cumulative ratio; share volumes and shares outstanding are divided by it. Both legs, in opposite directions — the exact reverse of backward adjustment.

2020-08-31  30 x 4,000,000 = 120,000,000   ->   120 x 1,000,000 = 120,000,000
2020-09-01  31 x 4,000,000 = 124,000,000   ->   124 x 1,000,000 = 124,000,000

A split creates no value. Moving the price without moving the share count manufactures some, and the resulting series looks entirely reasonable in a chart. verify_forward checks market capitalisation, turnover, and returns away from the boundary explicitly — and a test uses it as a positive control: leaving the share counts raw makes market_cap_preserved go False.


Two timestamps, not one

This is the part a tutorial leaves out, and the reason the input takes an eventRevisions list rather than an event.

Input Question it answers
targetBasisAt Which share basis do you want the answer in?
knowledgeAt What had the vendor told you at the moment you asked?

They are independent. Holding the basis fixed and moving only knowledgeAt isolates what changed in the vendor's story — and the sharp case is a split that gets cancelled:

knew at 2020-07-01: 0 known, 0 active
knew at 2020-09-02: 1 known, 1 active   <- the split became known
knew at 2020-09-06: 1 known, 0 active   <- the cancellation landed

cancellations:        ['SYNTH-SPLIT-2020']
observations changed: 2
largest move:         75.0%

Every number published on the old basis is now wrong by 75%, and nobody touched the code. restatement_impact names the cancelled events rather than leaving them to be inferred from numbers that moved, and basis_timeline gives the audit trail.

Revisions are chained through supersedesRevisionId, and a broken or branching chain is refused. A supersedes pointer with no matching predecessor means the feed dropped a revision, and quietly picking the newest one would produce a confident wrong answer.


The boundary is half-open at both ends

rule: targetBasisAt < effectiveAt <= observation.timestamp
  • An event effective exactly at targetBasisAt is already in the basis you asked for, so it is not applied.
  • An observation exactly at effectiveAt is already on the new basis, so it is adjusted.

Getting either end wrong shifts exactly one row, and nothing downstream complains. Both ends are pinned by tests in both languages.


Two ways to use this

📥 The fast path — one call, TypeScript only:

npm install fintech-algorithms
import { calculate } from "fintech-algorithms/corporate-actions-and-security-master-data/adjustment-factors/forward-split-adjustment";

That package is the breadth option: 324 algorithms, one install, the tutorial-level kernel for each.

🔬 This repo — the depth option. Python and TypeScript, the restatement surface (basis timelines, restatement impact, invariant verification, rounding analysis), and 170 tests pinning both languages to one shared fixture.


Install

Python (3.10+, no dependencies):

git clone https://github.com/IslamBaraka90/Fintech-Forward-Split-Adjustment-Corporate-Actions-algorithm.git
cd Fintech-Forward-Split-Adjustment-Corporate-Actions-algorithm/python
pip install -e ".[dev]"

TypeScript (Node 20+, no runtime dependencies):

cd Fintech-Forward-Split-Adjustment-Corporate-Actions-algorithm/typescript
npm install
npm run build

Quickstart

Python

from fintech_forward_split import calculate

result = calculate({
    "targetBasisAt": "2020-08-30T23:59:59Z",   # the basis in force the night before
    "knowledgeAt": "2020-09-02T00:00:00Z",     # what the vendor had told us by then
    "roundDecimalPlaces": 6,
    "observations": [
        {"timestamp": "2020-08-28T20:00:00Z", "price": 120, "volume": 1000,
         "sharesOutstanding": 1_000_000},
        {"timestamp": "2020-08-31T00:00:00Z", "price": 30, "volume": 4000,
         "sharesOutstanding": 4_000_000},
    ],
    "eventRevisions": [{
        "eventId": "SYNTH-SPLIT-2020",
        "revisionId": "SYNTH-SPLIT-2020-R1",
        "supersedesRevisionId": None,
        "publishedAt": "2020-07-30T20:30:00Z",
        "effectiveAt": "2020-08-31T00:00:00Z",
        "newShares": 4, "oldShares": 1,
        "status": "active",
    }],
})

print([row["adjustedPrice"] for row in result["observations"]])   # [120, 120]

TypeScript

import { calculate } from "fintech-forward-split";

const result = calculate({
  targetBasisAt: "2020-08-30T23:59:59Z",
  knowledgeAt: "2020-09-02T00:00:00Z",
  roundDecimalPlaces: 6,
  observations: [...],
  eventRevisions: [...],
});

Worked example (exact)

Three observations around a 4-for-1 split effective 2020-08-31T00:00:00Z, evaluated on the basis in force the night before. Asserted verbatim by both test suites from one shared JSON fixture.

obs 0 obs 1 obs 2
timestamp 08-28 20:00 08-31 00:00 09-01 00:00
raw price 120 30 31
raw volume 1000 4000 3600
raw shares out 1,000,000 4,000,000 4,000,000
adjusted price 120 120 124
adjusted volume 1000 1000 900
adjusted shares out 1,000,000 1,000,000 1,000,000
price factor 1 4 4

Observation 0 sits before the split and is untouched. The raw series drops 75% across the boundary; the adjusted series is flat, which is what actually happened.


Restatement: the audit surface

This is the surface that does not fit in a tutorial, and the reason to install the repo rather than copy the snippet.

basis_timeline — what did we believe, and when?

One row per knowledge instant, with changed marking the moments a re-run would have produced different output. That is the audit trail behind a number that moved without anybody touching the code.

restatement_impact — what did the restatement actually do?

Holds the observations and targetBasisAt fixed and moves only knowledgeAt, so the diff isolates the vendor's new information. Reports the moved rows, the largest relative change, newly-known events, and — separately — the cancellations.

verify_forward — is the arithmetic sound?

Market capitalisation preserved, turnover preserved, returns unchanged away from a boundary. Rows that cross a boundary are supposed to move relative to their predecessor and are excluded from the returns check; that is the boundary doing its job.

The default tolerance is 1e-6 relative rather than machine epsilon, deliberately: the module rounds its output, so a correct adjustment of a price that does not divide evenly carries representation error, and a tighter bound would fail the very output it is checking. A real error moves these quantities by order 1.

rounding_error — the warning, as a number

6 dp on clean prices:  reversible=True   worst error=0.00e+00
2 dp on 31.007:        reversible=False  worst error=8.06e-05

Only unrounded output round-trips exactly. Rounding is a storage decision, and it is worth measuring rather than discovering during a reconciliation.


Input shape

Field Meaning
targetBasisAt UTC Z timestamp — the basis to express results on
knowledgeAt UTC Z timestamp — bounds which revisions may be used
roundDecimalPlaces 012 integer, or null for unrounded output
observations Non-empty, timestamps strictly increasing, price and sharesOutstanding strictly positive, volume ≥ 0
eventRevisions Possibly empty; newShares must exceed oldShares

Timestamps must be explicit UTC with a Z suffix. A naive 2020-08-30T23:59:59 is refused rather than assumed to be UTC, and parsing is done with explicit civil-date arithmetic rather than the platform date parser — which silently rolls 2026-02-30 into March.

Rounding is half-up, not banker's rounding: split arithmetic lands on exact halves often, and round-half-to-even would send otherwise identical values in two directions.


API reference

Python TypeScript Purpose
calculate(data) calculate(...) The adjustment
select_revisions(revs, at) selectRevisions(...) Active + full lineage at a knowledge instant
basis_timeline(revs, times) basisTimeline(...) What was in force, when
restatement_impact(data, t0, t1) restatementImpact(...) The diff across a restatement
verify_forward(result) verifyForward(...) Market cap, turnover, returns
rounding_error(result) roundingError(...) Cost of the rounding choice
parse_utc(text, field) parseUtc(...) Strict UTC parsing
BASIS_CONTRACT BASIS_CONTRACT The conventions, in the output

Edge cases & limitations

  • Forward splits only. newShares must exceed oldShares. A reverse split carries different rounding and fractional-share consequences and is not silently accepted here.
  • This applies already-validated events. Matching a revision feed to an instrument and resolving corporate-action identifiers are upstream concerns, deliberately out of scope.
  • Splits only. Dividends, spin-offs and rights issues change the value of a holding and need their own treatment — see the related algorithms below.
  • A broken or branching revision chain is refused, not repaired. Picking the newest revision of a chain with a missing link produces a confident wrong answer.
  • Rounded output may not reverse exactly. The result carries that as a warning, and rounding_error turns it into a number. Pass roundDecimalPlaces: null when exact reversibility matters more than a stable column width.
  • No currency or fractional-share handling. A split that pays cash in lieu of fractional shares is a split plus a distribution, and the cash leg is not this algorithm's.

Testing

cd python && pytest -q          # 85 tests
cd typescript && npm test       # 85 tests

Both suites read the same fixtures.json and assert its exact expected output in each language.

The suites also pin the behaviours most likely to drift: both ends of the half-open boundary, market capitalisation preserved at every row, a cancellation un-adjusting history, a broken revision chain being refused, strict UTC parsing, half-up rounding, and — as positive controls — verify_forward actually failing when the share counts are left raw or a price is tampered with.


Related algorithms

Same family — D02-F01 Adjustment Factors

Related — D01 Market Data Engineering

🧭 Browse all algorithms →


License

MIT — see LICENSE.

The synthetic fixture data is CC0-1.0. No market data is redistributed.