The governed definitions this build depends on. Read them first if a term is unfamiliar.
- PrimaryReturn of CapitalA return of capital is a distribution classified as repayment of invested capital rather than ordinary earnings under a stated context.
- PrerequisiteCorporate ActionA corporate action is an issuer or security event that changes rights, quantities, cash flows, identity, or trading terms.
- ImportantAdjusted PriceAdjusted price is a transformed price expressed on a declared comparison basis after applying specified corporate-action or other adjustment factors.
- ImportantCash DividendA cash dividend is a dividend paid as a monetary amount per eligible security or holding.
- ImportantCorporate Action Effective DateA corporate action effective date is the market or legal date from which the declared event treatment applies.
- ImportantHolding Period ReturnHolding period return measures the economic gain or loss relative to starting value over a specified ownership interval.
- ImportantPayment DatePayment date is the scheduled or actual date on which a corporate-action entitlement is delivered to eligible holders.
- ImportantRecord DateRecord date is the date used under event terms to identify holders of record for a corporate-action entitlement.
- ImportantReturn BaseReturn base is the declared starting value, unit, and economic basis against which a return is measured.
- ImportantTotal ReturnTotal return measures price change together with eligible distributions over a declared holding period.
A cash payment can be one issuer event and still require several different answers. A market-data engineer needs a price-series bridge. A performance engine needs observed price plus cash. A U.S. holder may need a confirmed nondividend amount for basis records. The issuer's accounting team has yet another job.
This tutorial builds the first three views without pretending they are interchangeable.
Start with the question, not the event label
"Return of capital" is not a universal algorithm. An issuer description, an index-provider classification, and a tax classification can differ in purpose and timing. Store each with its source and revision.
| Output | What it measures | What it does not prove |
|---|---|---|
| Theoretical ex-price | A declared gross-cash price bridge | The first or closing ex-date trade |
| Backward factor | How to rescale pre-ex history | Universal vendor parity |
| Raw price return | Observed price change only | Cash-inclusive performance |
| Gross total return | Observed price plus gross cash | A holder's after-tax result |
| Net cash return | Observed price plus supplied net cash | A universal tax treatment |
| Basis illustration | Supplied U.S. nondividend amount against supplied basis | Tax due, filing status, or another jurisdiction |
Issuer accounting is outside every calculation in this package.
Build the economic price bridge
Let be a positive pre-ex reference price. Let be gross cash per share in the event currency and price-currency units per event-currency unit.
This package declares anchorPolicy = ON_OR_BEFORE_REFERENCE_OBSERVATION: the FX observation must occur no later than the previous-close reference observation used by the bridge. That keeps the cash conversion and price on one causal anchor. A methodology that fixes FX later must declare and implement a different contract; it must not silently insert a later rate into this factor.
Under the declared gross-deduction convention:
FTSE Russell's June 2026 v5.6 guide for non-market-capitalisation-weighted indices documents this shape for a capital repayment: deduct the repayment from the cum price, then divide the ex-capital-repayment price by the cum price to obtain its price adjustment factor. This guide is relevant only where an index series' ground rules specifically reference it; the general market-capitalisation guide is a different architecture. The formula is authoritative for the named methodology, not a law of every vendor.
The backward factor is:
Multiply pre-ex history by to express it on the post-event basis. If a system instead keeps pre-ex observations fixed and scales post-event observations, use . Naming the direction prevents a factor from being accidentally inverted.
The algorithm rejects . A distribution equal to or greater than the reference needs a different methodology or explicit provider decision.
Returns use an observed price
If is an observed price at or after the authoritative ex instant:
If is supplied withholding and supplied investor fees, both in event currency:
The gross distribution drives the market-data factor. Withholding and fees affect only the optional net cash diagnostic. That "net" result belongs to the declared fixture; it is not a universal after-tax return.
Use three clocks
exAt is an authoritative market boundary supplied by the caller. observedAt is when a price or FX fact occurred. availableAt is when the system could use it. Every selected item must be available by asOf.
A future cancellation cannot erase an earlier point-in-time result. The implementation selects the latest revision with availableAt <= asOf; an available cancellation returns applied=false.
Same-day actions also need ordering. Sequence two must name the sequence-one event whose effect is already present in the reference price. The calculator will not guess whether a split, special dividend, or capital repayment runs first.
Work a rich synthetic example
Every number below is synthetic:
- USD;
- USD;
- and ;
- ;
- confirmed U.S. nondividend portion: USD 1.20;
- illustrative pre-distribution basis: USD 1.50.
The theoretical price is , so and .
The observed returns are different by design:
- raw price return: ;
- gross total return: ;
- net cash return with the supplied deductions: .
The observed is not evidence that the theoretical price was wrong. One is a market observation; the other is an analytical bridge.
Add the U.S. basis view only when supported
IRS Publication 550 for tax year 2025 says that, for the U.S. federal individual-tax context it describes, a nondividend distribution reduces stock basis; after basis reaches zero, additional amount is reported as capital gain. Form 8937 instructions show that an issuer may need to report and later correct the quantitative basis effect of an organizational action.
The package therefore requires all of these before showing basis arithmetic:
- scope
US_FEDERAL_INDIVIDUAL_TAXABLE_2025; - USD event and basis amounts;
- a supplied confirmed return-of-capital amount and source;
- a supplied basis per share.
It computes:
In the synthetic case, tax character is USD 1.20 rather than the USD 2.00 gross cash. Basis falls from USD 1.50 to USD 0.30. If basis were only USD 0.50, it would stop at zero and the excess field would be USD 0.70. The result says "excess over basis," not "tax owed": holding period, rate, lot allocation, account type, state law, treaty status, and filing remain outside the algorithm.
A real event that shows why revisions matter
Logility's January 21, 2025 SEC filing states that four USD 0.11 distributions paid during 2024 on LGTY common stock had issuer-reported return-of-capital percentages of 26.587%, 29.313%, 53.137%, and 71.513%. The filing supplies record dates, payable dates, instrument identity, and U.S. federal tax characterization.
This is a valuable historical evidence pattern: the tax-classified portion varies and may be finalized after payment. It is not a complete historical market-adjustment example. The filing does not provide the authoritative market reference price, ex-date observation, provider convention, FX, fees, or withholding needed for a reproducible price factor. We therefore do not invent one.
Exact decimal parity
All monetary and FX inputs are plain decimal strings with no more than eight places. Python uses Decimal; TypeScript uses scaled BigInt. Both round half away from zero only at the eight-decimal output boundary. Tests include exact ties and a value beyond JavaScript's safe scaled-integer range.
The Python and TypeScript versions share one fixture and cover revisions, cancellations, future evidence, FX orientation, event boundaries, same-day ordering, basis exhaustion, unsupported tax scope, nonpositive prices, and input immutability.
Explore the boundaries
Open the guided return-of-capital lab. Step through evidence, the gross price bridge, observed returns, and the limited U.S. basis view. Compare canonical, basis-exhaustion, cross-currency, cancellation, and nonpositive-price cases. Reset is deterministic.
What to preserve in production
Preserve event and revision IDs, status, issuer wording, market-methodology classification, tax classification, ex/record/pay dates, observed and availability timestamps, price and FX source IDs, currencies and units, gross cash, deductions, same-day ordering, factor direction, rounding policy, and the original unadjusted observations.
Stop when a source is unavailable, a cancellation wins, a factor would be nonpositive, or a requested tax scope is unsupported. An explicit unsupported state is safer than a globally plausible number.
Summary
A return-of-capital event does not have one universal adjusted value. Calculate the gross market-data bridge under a named methodology, observed returns from actual prices, and tax basis only under a named jurisdiction and confirmed classification. Keep issuer accounting outside the pipeline, preserve revisions, and make factor direction explicit.
Continue with Permanent Security Identifier Mapping. Review Special-Dividend Adjustment for adjacent distribution classification and Point-in-Time Availability Guard for causal ingestion.
Asset map
| Asset | Teaching role | Source |
|---|---|---|
| Three-view hero | Separates price, return, and tax questions | visuals/static/article-hero.svg |
| Worked example | Reconciles synthetic factor and returns | visuals/static/worked-example.svg |
| Failure guard | Shows explicit unsupported states | visuals/static/failure-guard.svg |
| Guided lab | Steps through scenarios and diagnostics | visuals/animated/playground.html |
Source roles and limitations are in REFERENCES.md. This is educational material, not investment, legal, accounting, or tax advice.
Rendered from the canonical Mermaid sources linked by this article.
Point-in-time calculation flow
This flow separates knowable evidence from the three output views.
Takeaway: one validated event feeds separate calculations; no branch lends unsupported meaning to another.
Revision lifecycle
The selected state is the last revision the system could know at its as-of time.
Takeaway: later evidence creates a new point-in-time state; it does not silently rewrite earlier output.
References4 primary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
Accessed 2026-07-22. Public examples use issuer/regulator facts or explicitly synthetic data. No licensed provider observations are redistributed.
R1 — FTSE Russell Corporate Actions and Events Guide for Non-Market Capitalisation Weighted Indices
- Organization: FTSE Russell, LSEG
- Source type: Official index methodology
- Publication date and version: June 2026, version 5.6
- URL: https://www.lseg.com/content/dam/ftse-russell/en_us/documents/policy-documents/corporate-actions-and-events-guide-for-non-market-cap-weighted-indices.pdf
- Jurisdiction/applicability: Named FTSE Russell non-market-capitalisation-weighted indices; global methodology, not tax law
- Supports: The documented capital-repayment example deducts the repayment from the cum price and gives
PAF = ex-capital-repayment price / cum price; the guide distinguishes divisor, price-factor, weighting, and net-of-tax index treatments. - Why this guide: The current URL resolves to v5.6 and explicitly applies only where a non-market-capitalisation-weighted index's own ground rules reference it. It is used because its capital-repayment section states the exact factor shown here. The separate general market-capitalisation guide governs a different index architecture and is not substituted merely because its version number is higher.
- Limitations: This is one provider's index convention. It does not establish a universal security-master rule, investor tax treatment, or an observed ex-date trade.
R2 — IRS Publication 550 (2025), Investment Income and Expenses
- Organization: Internal Revenue Service
- Source type: Official U.S. federal tax publication
- Revision: 2025 tax year; posted March 9, 2026
- URL: https://www.irs.gov/publications/p550
- Jurisdiction/applicability: U.S. federal individual income tax; stock and mutual-fund nondividend distributions described in the publication
- Supports: A nondividend distribution reduces stock basis; basis does not go below zero; after basis is exhausted, additional amount is reported as capital gain, with holding period determining long- or short-term treatment.
- Limitations: It is not market-data methodology, issuer accounting guidance, legal advice, or a rule for every investor, account, entity, instrument, or country. The package computes only a per-share arithmetic illustration after a confirmed classification is supplied.
R3 — Instructions for Form 8937 (12/2017)
- Organization: Internal Revenue Service
- Source type: Official form instructions
- Revision: December 2017
- URL: https://www.irs.gov/instructions/i8937
- Jurisdiction/applicability: U.S. federal issuer reporting under section 6045B for specified securities
- Supports: Form 8937 covers organizational actions affecting security basis, including nontaxable cash distributions; corrected returns are required within 45 days after facts change the previously reported quantitative basis effect.
- Limitations: The instructions define reporting duties and exceptions. They do not supply an exchange ex-date, price source, market-data factor, or a holder-specific tax conclusion.
R4 — Logility Supply Chain Solutions announces tax treatment of 2024 distributions
- Organization: Logility Supply Chain Solutions, Inc.; filed with the U.S. Securities and Exchange Commission
- Source type: Issuer Form 8937 attachment and press release furnished through EDGAR
- Filing/publication date: January 21, 2025
- URL: https://www.sec.gov/Archives/edgar/data/713425/000162828025002043/logform8937divprv2-final.htm
- Jurisdiction/applicability: U.S. federal tax characterization stated by the issuer for Logility common stock, ticker LGTY, CUSIP 029683109, for its 2024 distributions
- Supports: Four USD 0.11 per-share distributions, their record/payable dates, and issuer-reported return-of-capital percentages: 26.587%, 29.313%, 53.137%, and 71.513%; basis consequence is stated for U.S. federal purposes.
- Limitations: This source does not provide the market reference price, ex-date observations, FX, fees, withholding, or any provider adjustment factor. The package therefore does not use these percentages to claim a historical price adjustment.
Evidence classification
| Claim type | Evidence used | Public status |
|---|---|---|
| Market price-factor convention | R1 | Provider-specific sourced convention |
| U.S. per-share basis illustration | R2 and R3 | Narrowly scoped sourced rule plus implementation arithmetic |
| Real issuer fact pattern | R4 | Sourced facts; no market-price derivation |
| USD 25, USD 2, USD 23.15, FX, fees, withholding, and basis values | Shared fixture | Explicitly synthetic |
| Calculated factors and returns | Implementations/tests | Derived from the synthetic fixture |
Full dependency-light reference implementations in both supported languages.
/** Exact, point-in-time return-of-capital reference implementation. */
const SCALE = 100000000n;
const US_TAX_SCOPE = "US_FEDERAL_INDIVIDUAL_TAXABLE_2025";
const text = (v: unknown, n: string): string => { if (typeof v !== "string" || !v.trim()) throw new TypeError(`${n} must be a non-empty string`); return v; };
const instant = (v: unknown, n: string): number => {
const s=text(v,n),m=s.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|([+-])(\d{2}):(\d{2}))$/); if(!m) throw new RangeError(`${n} must be a valid ISO 8601 instant`);
const year=+m[1],month=+m[2],day=+m[3],hour=+m[4],minute=+m[5],second=+m[6],offsetHour=m[7]==="Z"?0:+m[9],offsetMinute=m[7]==="Z"?0:+m[10]; const leap=year%4===0&&(year%100!==0||year%400===0),days=[31,leap?29:28,31,30,31,30,31,31,30,31,30,31]; if(month<1||month>12||day<1||day>days[month-1]||hour>23||minute>59||second>59||offsetHour>14||offsetMinute>59||(offsetHour===14&&offsetMinute!==0)) throw new RangeError(`${n} must be a valid ISO 8601 instant`);
const ms=Date.parse(s); if(!Number.isFinite(ms)) throw new RangeError(`${n} must be a valid ISO 8601 instant`); return ms;
};
const dec = (v: unknown, n: string, positive=false): bigint => {
if(typeof v!=="string" || !/^(?:0|[1-9]\d*)(?:\.\d{1,8})?$/.test(v)) throw new TypeError(`${n} must be a non-negative plain decimal string with at most 8 places`);
const [w,f=""]=v.split("."); const x=BigInt(w)*SCALE+BigInt((f+"00000000").slice(0,8)); if(positive && x===0n) throw new RangeError(`${n} must be positive`); return x;
};
const out = (v: bigint): string => { const sign=v<0n?"-":""; const x=v<0n?-v:v; return `${sign}${x/SCALE}.${(x%SCALE).toString().padStart(8,"0")}`; };
const roundDiv = (n: bigint,d: bigint): bigint => { if(d<=0n) throw new RangeError("nonpositive denominator"); const sign=n<0n?-1n:1n, x=n<0n?-n:n, q=x/d, r=x%d; return sign*(q+(r*2n>=d?1n:0n)); };
const mul = (a: bigint,b: bigint): bigint => roundDiv(a*b,SCALE);
const ratio = (a: bigint,b: bigint): bigint => roundDiv(a*SCALE,b);
function selectRevision(revisions: unknown, asOf: number): any {
if(!Array.isArray(revisions)||!revisions.length) throw new TypeError("revisions must be a non-empty list");
const seen=new Set<string>(); let eventId: string|undefined; const candidates: any[]=[];
for(const r of revisions){ if(!r||typeof r!=="object"||Array.isArray(r)) throw new TypeError("each revision must be an object"); const x:any=r; const eid=text(x.eventId,"eventId"); eventId ??= eid; if(eid!==eventId) throw new RangeError("all revisions must describe one eventId"); if(!Number.isInteger(x.revisionSequence)||x.revisionSequence<1) throw new RangeError("revisionSequence must be a positive integer"); const key=`${text(x.revisionId,"revisionId")}|${x.revisionSequence}`; if(seen.has(key)) throw new RangeError("duplicate revision identity"); seen.add(key); const observed=instant(x.observedAt,"observedAt"),available=instant(x.availableAt,"availableAt");text(x.sourceId,"sourceId");if(observed>available)throw new RangeError("event revision must satisfy observedAt <= availableAt");if(available<=asOf)candidates.push([available,x.revisionSequence,x]); }
if(!candidates.length) throw new RangeError("no event revision was available at asOf"); candidates.sort((a,b)=>a[0]-b[0]||a[1]-b[1]); return candidates.at(-1)[2];
}
function observation(value: any,name: string,asOf: number): [bigint,string] { if(!value||typeof value!=="object"||Array.isArray(value))throw new TypeError(`${name} must be an object`); const observed=instant(value.observedAt,`${name}.observedAt`),available=instant(value.availableAt,`${name}.availableAt`); if(observed>available||available>asOf)throw new RangeError(`${name} must satisfy observedAt <= availableAt <= asOf`); text(value.sourceId,`${name}.sourceId`); return [dec(value.price,`${name}.price`,true),text(value.currency,`${name}.currency`)]; }
export function calculate(payload: any): any {
if(!payload||typeof payload!=="object"||Array.isArray(payload))throw new TypeError("payload must be an object"); const data=structuredClone(payload); const asOf=instant(data.asOf,"asOf"),r=selectRevision(data.revisions,asOf),status=text(r.status,"status"); if(!["confirmed","cancelled"].includes(status))throw new RangeError("status must be confirmed or cancelled"); if(status==="cancelled")return {eventId:r.eventId,revisionId:r.revisionId,state:"cancelled",applied:false};
const exAt=instant(r.exAt,"exAt"); if(!r.terms||typeof r.terms!=="object")throw new TypeError("terms must be an object"); const t=r.terms,eventCurrency=text(t.eventCurrency,"eventCurrency"),gross=dec(t.grossCapitalReturnPerShare,"grossCapitalReturnPerShare"),withholding=dec(t.withholdingPerShare??"0","withholdingPerShare"),fees=dec(t.feesPerShare??"0","feesPerShare"); if(withholding+fees>gross)throw new RangeError("withholding plus fees cannot exceed the gross distribution");
const [reference,priceCurrency]=observation(data.referenceObservation,"referenceObservation",asOf); if(instant(data.referenceObservation.observedAt,"referenceObservation.observedAt")>=exAt)throw new RangeError("referenceObservation must precede exAt");
const fx=data.fxObservation; if(!fx||typeof fx!=="object")throw new TypeError("fxObservation must be an object"); const fxRate=dec(fx.priceCurrencyPerEventCurrency,"priceCurrencyPerEventCurrency",true);if(text(fx.anchorPolicy,"fxObservation.anchorPolicy")!=="ON_OR_BEFORE_REFERENCE_OBSERVATION")throw new RangeError("unsupported FX anchor policy");const fxObs=instant(fx.observedAt,"fxObservation.observedAt"),fxAvail=instant(fx.availableAt,"fxObservation.availableAt"); if(fxObs>fxAvail||fxAvail>asOf)throw new RangeError("FX must satisfy observedAt <= availableAt <= asOf");if(fxObs>instant(data.referenceObservation.observedAt,"referenceObservation.observedAt"))throw new RangeError("FX observation must be on or before the reference observation"); text(fx.sourceId,"fxObservation.sourceId"); if(eventCurrency===priceCurrency&&fxRate!==SCALE)throw new RangeError("same-currency events require an identity FX rate of 1"); if(eventCurrency!==priceCurrency&&text(fx.pair,"fxObservation.pair")!==`${eventCurrency}/${priceCurrency}`)throw new RangeError("FX pair must be eventCurrency/priceCurrency");
const order=r.sameDayOrdering; if(!order||typeof order!=="object"||!Number.isInteger(order.sequence)||order.sequence<1||!Array.isArray(order.priorEventIds)||order.priorEventIds.length!==order.sequence-1||order.priorEventIds.some((x:unknown)=>typeof x!=="string"||!x))throw new RangeError("reference price must identify every earlier same-day action");
const grossPrice=mul(gross,fxRate),netPrice=mul(gross-withholding-fees,fxRate),theoretical=reference-grossPrice; if(theoretical<=0n)throw new RangeError("gross distribution must leave a positive theoretical ex-price"); const factor=ratio(theoretical,reference);
let returns:any=null; if(data.exObservation!=null){const [exPrice,exCurrency]=observation(data.exObservation,"exObservation",asOf);if(exCurrency!==priceCurrency)throw new RangeError("exObservation currency must equal price currency");if(instant(data.exObservation.observedAt,"exObservation.observedAt")<exAt)throw new RangeError("exObservation must be at or after exAt");returns={rawPriceReturn:out(ratio(exPrice,reference)-SCALE),grossTotalReturn:out(ratio(exPrice+grossPrice,reference)-SCALE),netCashReturn:out(ratio(exPrice+netPrice,reference)-SCALE)};}
let taxResult:any=null; if(data.taxIllustration!=null){const tax=data.taxIllustration;if(!tax||typeof tax!=="object"||text(tax.jurisdiction,"taxIllustration.jurisdiction")!==US_TAX_SCOPE)throw new RangeError(`tax illustration supports only ${US_TAX_SCOPE}`);if(eventCurrency!=="USD"||text(tax.currency,"taxIllustration.currency")!=="USD")throw new RangeError("US tax illustration requires USD event and basis amounts");text(tax.sourceId,"taxIllustration.sourceId");const taxObs=instant(tax.observedAt,"taxIllustration.observedAt"),taxAvail=instant(tax.availableAt,"taxIllustration.availableAt");if(taxObs>taxAvail||taxAvail>asOf)throw new RangeError("tax illustration must satisfy observedAt <= availableAt <= asOf");const basis=dec(tax.basisPerShareBefore,"basisPerShareBefore"),classified=dec(tax.confirmedReturnOfCapitalPerShare,"confirmedReturnOfCapitalPerShare");if(classified>gross)throw new RangeError("tax-classified return of capital cannot exceed the gross distribution");const reduction=basis<classified?basis:classified;taxResult={basisPerShareAfter:out(basis-reduction),basisReductionPerShare:out(reduction),excessOverBasisPerShare:out(classified-reduction)};}
return {eventId:r.eventId,revisionId:r.revisionId,state:"confirmed",applied:true,priceCurrency,marketDataAdjustment:{referencePrice:out(reference),grossDistributionInPriceCurrency:out(grossPrice),theoreticalExPrice:out(theoretical),backwardHistoryFactor:out(factor),forwardPostEventFactor:out(ratio(SCALE,factor)),factorDirection:"multiply_pre_ex_history_by_backwardHistoryFactor",theoreticalPriceIsObservedTrade:false},investorReturnDiagnostics:returns,usFederalTaxIllustration:taxResult};
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.