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.
📖 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 Data › Adjustment 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 |
- The question backward adjustment cannot answer
- Market capitalisation is the invariant
- Two timestamps, not one
- The boundary is half-open at both ends
- Two ways to use this
- Install
- Quickstart
- Worked example (exact)
- Restatement: the audit surface
- Input shape
- API reference
- Edge cases & limitations
- Testing
- Related algorithms
- License
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.
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.
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.
rule: targetBasisAt < effectiveAt <= observation.timestamp
- An event effective exactly at
targetBasisAtis already in the basis you asked for, so it is not applied. - An observation exactly at
effectiveAtis 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.
📥 The fast path — one call, TypeScript only:
npm install fintech-algorithmsimport { 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.
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 buildPython
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: [...],
});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.
This is the surface that does not fit in a tutorial, and the reason to install the repo rather than copy the snippet.
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.
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.
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.
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.
| Field | Meaning |
|---|---|
targetBasisAt |
UTC Z timestamp — the basis to express results on |
knowledgeAt |
UTC Z timestamp — bounds which revisions may be used |
roundDecimalPlaces |
0–12 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.
| 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 |
- Forward splits only.
newSharesmust exceedoldShares. 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_errorturns it into a number. PassroundDecimalPlaces: nullwhen 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.
cd python && pytest -q # 85 tests
cd typescript && npm test # 85 testsBoth 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.
Same family — D02-F01 Adjustment Factors
- Backward Split Adjustment — the mirror image: restate history onto today's basis.
- Cash-Dividend Total-Return Adjustment — when the price drop is real value leaving the company.
- CRSP Cumulative Price Adjustment · CRSP Cumulative Share/Volume Adjustment (articles live; repos pending)
Related — D01 Market Data Engineering
- Point-in-Time Availability Guard — the same knowledge-time discipline, applied to the observation feed itself.
- Schema Drift Detector — for when the revision feed's shape changes underneath you.
MIT — see LICENSE.
The synthetic fixture data is CC0-1.0. No market data is redistributed.