D02-F02-A04 / Complete engineering topic

Special-Dividend Adjustment: Preserve Return Meaning Across the Ex-Date

A production-minded guide to Special-Dividend Adjustment: Preserve Return Meaning Across the Ex-Date.

D02 · CORPORATE ACTIONS AND SECURIT…
D02-F02-A04Canonical / Tested / Open
D02 / D02-F02
Key concepts

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

A stock can open lower after a large cash distribution without imposing the same economic loss on a holder who receives that cash. The useful outcome is not merely a smoother chart. It is a reproducible bridge that says which event version, ex-date, reference price, cash basis, FX rate, tax profile, and methodology produced the adjusted value.

This tutorial builds that bridge and makes the central ambiguity visible: ordinary versus special is not one universal market fact.

Price and total-return meaning across a special dividend

Why the label is not enough

An issuer may call a payment "special," an exchange may designate its ex-date, and an index provider may still classify or implement it under its own policy.

S&P DJI's March 2026 methodology defines special dividends by departure from the corporation's historical payment pattern, considers descriptors such as special or extra, and generally treats the third consecutive non-ordinary payment by timing as ordinary. It also documents regional exceptions. FTSE Russell v6.9, June 2026 generally follows a company's special description, but treats the fourth recurring non-extraordinary distribution as ordinary after more than three consecutive occasions.

These are named provider rules, not contradictions to resolve into a single "correct" threshold. FTSE's separate 10% condition in section 4.4 concerns a compensating net-of-tax adjustment when withholding implications are identified. FINRA Rule 11140 uses 25% of security value for ex-date timing in covered cases. Neither percentage is a universal special-dividend classifier.

DecisionEvidence needed
What did the issuer authorize?Issuer filing or release and its revisions
When does the security trade without the entitlement?Exchange or rule-governed ex-date record
Does a provider treat it as ordinary or special?Named, versioned methodology and event notice
What price anchors the factor?Declared official-close source and calendar
Is cash gross or net?Return-series definition and a scoped tax profile

Four returns, four meanings

Assume the price and cash entitlement are already on the same share basis and currency. Let:

  • P be the selected cum-dividend reference price;
  • Dg be the gross cash entitlement;
  • Dn be the net entitlement after a supplied withholding assumption;
  • Q be an observed ex-date price;
  • D* be the gross or net cash amount selected for the price bridge.

The theoretical ex-price and backward factor are:

Pex=PD,F=PDPP_{ex}=P-D^*, \qquad F=\frac{P-D^*}{P}

Multiply pre-event prices by F to express them on the post-distribution basis. The factor does not predict Q.

The observed return measures are:

rprice=QP1r_{price}=\frac{Q}{P}-1 rgross=Q+DgP1,rnet=Q+DnP1r_{gross}=\frac{Q+D_g}{P}-1, \qquad r_{net}=\frac{Q+D_n}{P}-1 radjusted=QPex1r_{adjusted}=\frac{Q}{P_{ex}}-1

Some index material calls price-only return capital return. Do not confuse that with a return of capital, which can be a legal or tax characterization of the distribution. This implementation uses the unambiguous name priceReturn.

Resolve evidence before arithmetic

The algorithm selects the latest event revision with availableAt <= asOf. A later revision is invisible to an earlier decision. If the latest available version is cancelled, the calculation stops; it does not resurrect an older announcement.

Rendering system map…

The reference anchor in this package is the previous-session official close. Its observation must precede exAt, and its publication must be known by asOf. The implementation deliberately does not infer sessions or holidays; an exchange-calendar component supplies the anchor.

Same-day actions change the unit

If a split precedes the dividend on the same effective date, the original close and the declared per-share dividend may be on different share bases. For ordered preceding actions:

P=P0jfjP=P_0\prod_j f_j

where f_j is each action's price factor. A separate perShareAmountFactor scales the dividend only for actions after the amount's declared basis:

Dbasis=Ddeclaredj: orderj>amountBasisOrdergjD_{basis}=D_{declared}\prod_{j:\ order_j>amountBasisOrder}g_j

The two factors are explicit because a generic price adjustment does not prove how a cash entitlement was quoted.

FX and tax are scoped inputs

For a dividend in a different currency, X is reference currency per one unit of dividend currency:

Dg=DbasisXD_g=D_{basis}X

Given a sourced withholding assumption w:

Dn=Dg(1w)D_n=D_g(1-w)

S&P DJI documents specific FX timing for special dividends and defines net return for a nonresident institutional-investor convention. FTSE Russell documents its own withholding-tax logic and exceptions. A portfolio, another provider, or a treaty-eligible investor may require different inputs. This package never guesses them.

Synthetic example: one calculation that exposes the hard parts

Every value in this section is synthetic.

  • R1 reports EUR 15; later available revision R2 reports EUR 16.
  • A 2-for-1 split at action order 10 precedes the dividend at order 20.
  • The EUR 16 amount is on the pre-split basis, so it becomes EUR 8 per post-split share.
  • The previous-session official close is USD 100 and becomes USD 50 after the split factor.
  • Synthetic FX is USD 1.10 per EUR: gross cash is USD 8.80.
  • A synthetic nonresident profile supplies 25% withholding: net cash is USD 6.60.
  • The named synthetic methodology uses net cash for the price adjustment.

Therefore:

Pex=506.60=43.40P_{ex}=50-6.60=43.40 F=43.40/50=0.868F=43.40/50=0.868

With a synthetic ex-close of USD 43.75:

MeasureExact input calculationResult
Price return43.75 / 50 - 1-12.5%
Gross total return(43.75 + 8.80) / 50 - 1+5.1%
Net total return(43.75 + 6.60) / 50 - 1+0.7%
Adjusted price return43.75 / 43.40 - 1+0.806452%

The exact synthetic calculation

Open the guided playground to step through the revision, basis bridge, FX, withholding, classification, and result. Compare the canonical case with a provider threshold that classifies the distribution as ordinary, a cancellation, and a nonpositive theoretical-price rejection. Reset is deterministic.

A real event is an evidence checklist, not borrowed arithmetic

Costco's November 16, 2020 issuer release states that its board declared a $10 per-share special cash dividend, payable December 11 to holders of record on December 2.

Those facts make the event useful for teaching evidence collection. They do not supply an exchange-designated ex-date, official reference close, index-provider event treatment, or historical data availability record. This article therefore makes no Costco factor, ex-date, price, or return claim. A current data-provider value would not prove what a historical production system knew under its then-current methodology.

Guards that should fail loudly

Reject a nonpositive theoretical ex-price

The implementation rejects:

  • a latest cancellation;
  • a future event revision, reference, FX rate, tax profile, or ex-price observation;
  • a reference anchor on or after the ex-date;
  • an FX rate observed after the anchor;
  • a net bridge without a sourced tax profile;
  • duplicate or inconsistent same-day action orders;
  • invalid UTC calendar timestamps;
  • a zero or negative P-D*.

If the selected special dividend is at least as large as the event-basis reference price, clipping the theoretical price to a small positive number would hide a bad unit, stale anchor, or methodology exception. Stop and investigate.

Exact parity, not "close enough"

Python's round and JavaScript's Math.round do not share every tie rule. Both implementations here parse JSON numeric spellings into exact rationals and round half away from zero only at the public output boundary. Shared tests include positive and negative ties and a scaled value beyond JavaScript's safe integer range.

That proves parity for the declared contract. It does not prove parity with S&P DJI, FTSE Russell, an exchange, or a licensed vendor whose complete production rules and data are not part of this package.

Summary

A defensible special-dividend adjustment is a lineage problem before it is a subtraction problem:

  1. select the event revision available at the decision time;
  2. use an authoritative ex-date and reference-price anchor;
  3. name the classification and cash-basis methodology;
  4. bridge same-day actions, currency, and tax assumptions explicitly;
  5. keep price, gross total, net total, and adjusted price return separate;
  6. reject impossible economics instead of manufacturing a factor.

Continue with Return-of-Capital Adjustment to examine the distribution characterization that "capital return" language can otherwise blur.

Asset map

AssetArticle sectionVideo sceneSourceStatic fallback
Return meaningsOpening2visuals/static/article-hero.svgSame file
Evidence flowResolve evidence3visuals/mermaid/calculation-flow.mdREADME algorithm list
Synthetic bridgeWorked example5visuals/static/worked-example.svgWorked table
Guided labWorked example and guards4-7visuals/animated/playground.htmlWorked-example SVG
Nonpositive guardFailure modes7visuals/static/failure-guard.svgFailure-mode text

Source roles, exact versions, and limitations are in ../REFERENCES.md.

Evidence-to-adjustment calculation flow

This diagram shows why classification is only one decision inside a larger point-in-time contract.

Rendering system map…

Takeaway: an issuer label alone cannot supply the ex-date, anchor, provider policy, FX, or tax convention.

Point-in-time revision lifecycle

This state diagram prevents a later correction or cancellation from leaking into an earlier decision.

Rendering system map…

Takeaway: availability chooses the revision; effective time controls application; cancellation is terminal for this calculation.

References4 primary sources and evidence notes

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

All sources were accessed on 2026-07-22. The package uses a fully synthetic calculation fixture. No licensed vendor observation, FMP value, or unsourced historical price is published.

R1 - S&P Dow Jones Indices Equity Indices Policies & Practices

  • Organization: S&P Dow Jones Indices
  • Source type: Official index methodology
  • Publication date: March 2026
  • Version: March 2026 edition
  • URL: https://www.spglobal.com/spdji/en/documents/methodologies/methodology-sp-equity-indices-policies-practices.pdf
  • Jurisdiction: Global index methodology with documented regional variations
  • Supports: Sections "Dividends," "Regional Variations in the Treatment of Cash Dividends," "Post Ex-date Dividend Adjustment," "Foreign Exchange Conversions for Dividends," "Multiple Dividend Distributions on a Single Day," and "Total Return and Net Return Indices." In particular, the methodology distinguishes ordinary, variable, special, and return-of-capital distributions; says special dividends receive price and divisor adjustments; describes pattern-based reclassification; documents regional exceptions, FX timing, later corrections, and gross/net return treatment.
  • Limitations: It is S&P DJI policy, not a universal rule for exchanges, vendors, tax systems, portfolios, or another index family. The package does not claim S&P DJI parity.

R2 - FTSE Russell Corporate Actions and Events Guide for Market Capitalisation Weighted Indices

  • Organization: FTSE Russell, LSEG
  • Source type: Official index policy guide
  • Publication date: June 2026
  • Version: 6.9
  • URL: https://www.lseg.com/content/dam/ftse-russell/en_us/documents/policy-documents/corporate-actions-and-events-guide.pdf
  • Jurisdiction: Global index methodology with stated index-series exceptions
  • Supports: Section 4.2 defines FTSE Russell's ordinary/special/capital-repayment treatments. It generally follows a company's "special" description but treats the fourth recurring non-extraordinary distribution as ordinary after more than three consecutive occasions. It deducts a recognized special cash dividend from the stock price before the open on the ex-date. Section 4.4 documents a separate 10% threshold for a net-of-tax compensating adjustment where withholding implications are identified.
  • Limitations: The 10% rule is a tax-adjustment condition, not a universal definition of "special." Some series are excluded, complex events can be handled through provider notices, and the guide is not a shareholder tax opinion.

R3 - FINRA Rule 11140, Transactions in Securities "Ex-Dividend," "Ex-Rights" or "Ex-Warrants"

  • Organization: Financial Industry Regulatory Authority
  • Source type: Current rule text
  • Effective date: Current version effective May 28, 2024
  • URL: https://www.finra.org/rules-guidance/rulebooks/finra-rules/11140
  • Jurisdiction: FINRA-covered United States transactions, subject to exchange designation and the rule's scope
  • Supports: The ex-date is designated after definitive information or by the appropriate national securities exchange. For covered distributions below 25% of security value, normal timing follows the record-date rule; for covered distributions at least 25%, the ex-date is the first business day after the payable date.
  • Limitations: The 25% threshold governs ex-date timing under this rule. It does not define whether an issuer, index provider, exchange, or dataset labels a dividend ordinary or special. Calendar resolution remains an upstream responsibility.

R4 - Costco Wholesale Corporation declares special cash dividend of $10 per share

  • Organization: Costco Wholesale Corporation
  • Source type: Issuer investor-relations release
  • Publication date: November 16, 2020
  • URL: https://investor.costco.com/news/news-details/2020/Costco-Wholesale-Corporation-Declares-Special-Cash-Dividend-of-10-Per-Share-11-16-2020/default.aspx
  • Jurisdiction: United States issuer announcement
  • Supports: Costco called the distribution a special cash dividend, declared $10 per common share, named December 2, 2020 as the record date, and December 11, 2020 as the payable date.
  • Limitations: The release does not provide the exchange-designated ex-date, the previous-session official close, an index-provider classification, an FX observation, a tax profile, or an adjusted-price output. Therefore this package uses the event only as an evidence checklist and performs no Costco price-factor or return calculation.
special_dividend_adjustment.ts
/** Point-in-time special-dividend adjustment with exact decimal-rational math. */
type Rational = { n: bigint; d: bigint };

const gcd = (a: bigint, b: bigint): bigint => {
  a = a < 0n ? -a : a;
  b = b < 0n ? -b : b;
  while (b !== 0n) [a, b] = [b, a % b];
  return a || 1n;
};
const rational = (n: bigint, d: bigint = 1n): Rational => {
  if (d === 0n) throw new RangeError("rational denominator cannot be zero");
  if (d < 0n) { n = -n; d = -d; }
  const g = gcd(n, d);
  return { n: n / g, d: d / g };
};
const add = (a: Rational, b: Rational) => rational(a.n * b.d + b.n * a.d, a.d * b.d);
const sub = (a: Rational, b: Rational) => rational(a.n * b.d - b.n * a.d, a.d * b.d);
const mul = (a: Rational, b: Rational) => rational(a.n * b.n, a.d * b.d);
const div = (a: Rational, b: Rational) => {
  if (b.n === 0n) throw new RangeError("division by zero");
  return rational(a.n * b.d, a.d * b.n);
};
const compare = (a: Rational, b: Rational) => {
  const difference = a.n * b.d - b.n * a.d;
  return difference < 0n ? -1 : difference > 0n ? 1 : 0;
};
const ONE = rational(1n);
const ZERO = rational(0n);

const objectValue = (value: unknown, name: string): Record<string, any> => {
  if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${name} must be an object`);
  return value as Record<string, any>;
};
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 integerValue = (value: unknown, name: string, low: number, high: number): number => {
  if (!Number.isInteger(value) || (value as number) < low || (value as number) > high) throw new RangeError(`${name} must be an integer from ${low} to ${high}`);
  return value as number;
};
const numberRational = (value: unknown, name: string, options: { positive?: boolean; unitInterval?: boolean } = {}): Rational => {
  if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be a finite JSON number`);
  const match = String(value).match(/^(-?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i);
  if (!match) throw new TypeError(`${name} has an unsupported numeric spelling`);
  const sign = match[1] ? -1n : 1n;
  const fractionDigits = match[3] ?? "";
  const exponent = Number(match[4] ?? 0) - fractionDigits.length;
  let n = sign * BigInt(match[2] + fractionDigits);
  let d = 1n;
  if (exponent >= 0) n *= 10n ** BigInt(exponent); else d = 10n ** BigInt(-exponent);
  const result = rational(n, d);
  if (options.positive && compare(result, ZERO) <= 0) throw new RangeError(`${name} must be positive`);
  if (options.unitInterval && (compare(result, ZERO) < 0 || compare(result, ONE) > 0)) throw new RangeError(`${name} must be between 0 and 1`);
  return result;
};
const roundHalfAway = (value: Rational, decimals: number): number => {
  const scale = 10n ** BigInt(decimals);
  const absolute = value.n < 0n ? -value.n : value.n;
  let whole = (absolute * scale) / value.d;
  const remainder = (absolute * scale) % value.d;
  if (remainder * 2n >= value.d) whole += 1n;
  if (value.n < 0n) whole = -whole;
  if (decimals === 0) return Number(whole);
  const sign = whole < 0n ? "-" : "";
  const digits = (whole < 0n ? -whole : whole).toString().padStart(decimals + 1, "0");
  const rendered = `${sign}${digits.slice(0, -decimals)}.${digits.slice(-decimals)}`.replace(/\.?0+$/, "");
  return Number(rendered);
};

const ISO_Z = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?Z$/;
const instant = (value: unknown, name: string): Date => {
  const source = textValue(value, name);
  const match = source.match(ISO_Z);
  if (!match) throw new RangeError(`${name} must be an ISO 8601 UTC instant ending in Z`);
  const parsed = new Date(source);
  if (Number.isNaN(parsed.getTime()) ||
      parsed.getUTCFullYear() !== Number(match[1]) || parsed.getUTCMonth() + 1 !== Number(match[2]) ||
      parsed.getUTCDate() !== Number(match[3]) || parsed.getUTCHours() !== Number(match[4]) ||
      parsed.getUTCMinutes() !== Number(match[5]) || parsed.getUTCSeconds() !== Number(match[6])) {
    throw new RangeError(`${name} is not a real calendar instant`);
  }
  return parsed;
};
const available = (observedAt: Date, availableAt: Date, asOf: Date, name: string) => {
  if (availableAt < observedAt) throw new RangeError(`${name}.availableAt cannot precede its observation or announcement`);
  if (availableAt > asOf) throw new RangeError(`${name} is not available as of the decision time`);
};

function selectRevision(data: Record<string, any>, asOf: Date) {
  const revisions = data.eventRevisions;
  if (!Array.isArray(revisions) || revisions.length === 0) throw new TypeError("eventRevisions must be a non-empty list");
  const securityId = textValue(data.securityId, "securityId");
  const eventId = textValue(data.eventId, "eventId");
  const seen = new Set<string>();
  const eligible: Array<{ availableAt: Date; revisionId: string; revision: Record<string, any> }> = [];
  revisions.forEach((raw, index) => {
    const revision = objectValue(raw, `eventRevisions[${index}]`);
    const revisionId = textValue(revision.revisionId, `eventRevisions[${index}].revisionId`);
    if (seen.has(revisionId)) throw new RangeError("revisionId values must be unique");
    seen.add(revisionId);
    if (revision.securityId !== securityId || revision.eventId !== eventId) throw new RangeError("every revision must match the requested securityId and eventId");
    const announcedAt = instant(revision.announcedAt, `eventRevisions[${index}].announcedAt`);
    const availableAt = instant(revision.availableAt, `eventRevisions[${index}].availableAt`);
    if (availableAt < announcedAt) throw new RangeError("a revision cannot be available before it is announced");
    textValue(revision.sourceId, `eventRevisions[${index}].sourceId`);
    if (!new Set(["announced", "cancelled"]).has(revision.status)) throw new RangeError("revision status must be announced or cancelled");
    if (availableAt <= asOf) eligible.push({ availableAt, revisionId, revision });
  });
  if (eligible.length === 0) throw new RangeError("no event revision is available as of the decision time");
  eligible.sort((a, b) => a.availableAt.getTime() - b.availableAt.getTime() || a.revisionId.localeCompare(b.revisionId));
  const selected = eligible.at(-1)!;
  if (selected.revision.status === "cancelled") throw new RangeError("the latest available event revision is cancelled");
  return selected;
}

export function calculate(rawData: unknown): Record<string, any> {
  const data = objectValue(rawData, "data");
  const asOf = instant(data.asOf, "asOf");
  const methodology = objectValue(data.methodology, "methodology");
  const methodologyId = textValue(methodology.id, "methodology.id");
  const methodologyVersion = textValue(methodology.version, "methodology.version");
  const methodologySource = textValue(methodology.sourceId, "methodology.sourceId");
  const decimals = integerValue(methodology.outputDecimals, "methodology.outputDecimals", 0, 12);
  const anchor = methodology.referenceAnchor;
  if (anchor !== "previous-session-official-close") throw new RangeError("this implementation requires the previous-session-official-close anchor");
  const cashBasis = methodology.priceAdjustmentCashBasis;
  if (!new Set(["gross", "net"]).has(cashBasis)) throw new RangeError("priceAdjustmentCashBasis must be gross or net");
  const mode = methodology.classificationMode;
  if (!new Set(["source-designated", "yield-threshold"]).has(mode)) throw new RangeError("classificationMode must be source-designated or yield-threshold");

  const selected = selectRevision(data, asOf);
  const revision = selected.revision;
  const exAt = instant(revision.exAt, "selectedRevision.exAt");
  const paymentAt = instant(revision.paymentAt, "selectedRevision.paymentAt");
  if (paymentAt < exAt) throw new RangeError("paymentAt cannot precede exAt");
  if (!new Set(["ordinary", "special", "unspecified"]).has(revision.issuerClassification)) throw new RangeError("issuerClassification must be ordinary, special, or unspecified");
  const grossAmount = numberRational(revision.grossAmount, "selectedRevision.grossAmount", { positive: true });
  const dividendCurrency = textValue(revision.currency, "selectedRevision.currency");
  const actionOrder = integerValue(revision.actionOrder, "selectedRevision.actionOrder", 1, 1_000_000);
  const amountBasisOrder = integerValue(revision.amountBasisOrder, "selectedRevision.amountBasisOrder", 0, actionOrder);

  const reference = objectValue(data.referencePrice, "referencePrice");
  const referenceValue = numberRational(reference.value, "referencePrice.value", { positive: true });
  const referenceCurrency = textValue(reference.currency, "referencePrice.currency");
  if (reference.anchorType !== anchor) throw new RangeError("referencePrice.anchorType does not match the methodology");
  const anchorAt = instant(reference.anchorAt, "referencePrice.anchorAt");
  const observedAt = instant(reference.observedAt, "referencePrice.observedAt");
  const referenceAvailable = instant(reference.availableAt, "referencePrice.availableAt");
  available(observedAt, referenceAvailable, asOf, "referencePrice");
  if (anchorAt.getTime() !== observedAt.getTime() || anchorAt >= exAt) throw new RangeError("the reference observation must be the stated anchor before exAt");
  textValue(reference.sourceId, "referencePrice.sourceId");

  let basisPrice = referenceValue;
  let amountOnEventBasis = grossAmount;
  const actions = data.precedingActions ?? [];
  if (!Array.isArray(actions)) throw new TypeError("precedingActions must be a list");
  const parsedActions: Array<{ order: number; priceFactor: Rational; amountFactor: Rational }> = [];
  const orders = new Set<number>();
  actions.forEach((raw, index) => {
    const action = objectValue(raw, `precedingActions[${index}]`);
    const order = integerValue(action.actionOrder, `precedingActions[${index}].actionOrder`, 1, actionOrder - 1);
    if (orders.has(order)) throw new RangeError("preceding actionOrder values must be unique");
    orders.add(order);
    if (instant(action.effectiveAt, `precedingActions[${index}].effectiveAt`).getTime() !== exAt.getTime()) throw new RangeError("preceding actions must share the dividend exAt");
    const actionObserved = instant(action.observedAt, `precedingActions[${index}].observedAt`);
    const actionAvailable = instant(action.availableAt, `precedingActions[${index}].availableAt`);
    available(actionObserved, actionAvailable, asOf, `precedingActions[${index}]`);
    textValue(action.actionId, `precedingActions[${index}].actionId`);
    textValue(action.sourceId, `precedingActions[${index}].sourceId`);
    parsedActions.push({ order, priceFactor: numberRational(action.priceFactor, "priceFactor", { positive: true }), amountFactor: numberRational(action.perShareAmountFactor, "perShareAmountFactor", { positive: true }) });
  });
  parsedActions.sort((a, b) => a.order - b.order).forEach(action => {
    basisPrice = mul(basisPrice, action.priceFactor);
    if (action.order > amountBasisOrder) amountOnEventBasis = mul(amountOnEventBasis, action.amountFactor);
  });

  let fxRate = ONE;
  let fxSource: string | null = null;
  if (dividendCurrency !== referenceCurrency) {
    const fx = objectValue(data.fxRate, "fxRate");
    if (fx.baseCurrency !== dividendCurrency || fx.quoteCurrency !== referenceCurrency) throw new RangeError("fxRate must quote reference currency per dividend currency");
    fxRate = numberRational(fx.rate, "fxRate.rate", { positive: true });
    const fxObserved = instant(fx.observedAt, "fxRate.observedAt");
    const fxAvailable = instant(fx.availableAt, "fxRate.availableAt");
    available(fxObserved, fxAvailable, asOf, "fxRate");
    if (fxObserved > anchorAt) throw new RangeError("fxRate must be observed no later than the reference anchor");
    fxSource = textValue(fx.sourceId, "fxRate.sourceId");
  } else if (data.fxRate !== undefined && data.fxRate !== null) {
    throw new RangeError("fxRate must be omitted when dividend and reference currencies match");
  }

  const grossReference = mul(amountOnEventBasis, fxRate);
  let withholdingRate = ZERO;
  let taxSource: string | null = null;
  if (data.taxProfile !== undefined && data.taxProfile !== null) {
    const tax = objectValue(data.taxProfile, "taxProfile");
    withholdingRate = numberRational(tax.withholdingRate, "taxProfile.withholdingRate", { unitInterval: true });
    textValue(tax.jurisdiction, "taxProfile.jurisdiction");
    textValue(tax.investorCategory, "taxProfile.investorCategory");
    taxSource = textValue(tax.sourceId, "taxProfile.sourceId");
    const taxObserved = instant(tax.observedAt, "taxProfile.observedAt");
    const taxAvailable = instant(tax.availableAt, "taxProfile.availableAt");
    available(taxObserved, taxAvailable, asOf, "taxProfile");
  }
  if (cashBasis === "net" && (data.taxProfile === undefined || data.taxProfile === null)) throw new RangeError("a sourced taxProfile is required for a net cash adjustment");
  const netReference = mul(grossReference, sub(ONE, withholdingRate));
  const yieldFraction = div(grossReference, basisPrice);

  let isSpecial: boolean;
  let classificationReason: string;
  if (mode === "source-designated") {
    isSpecial = revision.issuerClassification === "special";
    classificationReason = `source designation: ${revision.issuerClassification}`;
  } else {
    const threshold = numberRational(methodology.specialYieldThreshold, "methodology.specialYieldThreshold", { unitInterval: true });
    if (compare(threshold, ZERO) === 0) throw new RangeError("specialYieldThreshold must be greater than zero");
    isSpecial = compare(yieldFraction, threshold) >= 0;
    classificationReason = `gross yield ${isSpecial ? "meets" : "is below"} the methodology threshold`;
  }

  const adjustmentCash = isSpecial ? (cashBasis === "gross" ? grossReference : netReference) : ZERO;
  const theoreticalEx = sub(basisPrice, adjustmentCash);
  if (compare(theoreticalEx, ZERO) <= 0) throw new RangeError("the selected adjustment would create a nonpositive theoretical ex price");
  const factor = div(theoreticalEx, basisPrice);

  const result: Record<string, any> = {
    eventState: asOf >= exAt ? "effective" : "announced",
    selectedRevisionId: revision.revisionId,
    classificationDecision: isSpecial ? "special" : "ordinary",
    classificationReason,
    adjustmentApplied: isSpecial,
    referencePriceOnEventBasis: roundHalfAway(basisPrice, decimals),
    grossDividendOnEventBasis: roundHalfAway(grossReference, decimals),
    netDividendOnEventBasis: roundHalfAway(netReference, decimals),
    adjustmentCashAmount: roundHalfAway(adjustmentCash, decimals),
    theoreticalExPrice: roundHalfAway(theoreticalEx, decimals),
    adjustmentFactor: roundHalfAway(factor, decimals),
    grossDistributionYield: roundHalfAway(yieldFraction, decimals),
    methodologyId,
    methodologyVersion,
    lineage: {
      eventSourceId: revision.sourceId,
      eventAvailableAt: selected.availableAt.toISOString().replace(".000Z", "Z"),
      referenceSourceId: reference.sourceId,
      methodologySourceId: methodologySource,
      fxSourceId: fxSource,
      taxSourceId: taxSource,
    },
  };

  if (data.exPriceObservation !== undefined && data.exPriceObservation !== null) {
    const exPrice = objectValue(data.exPriceObservation, "exPriceObservation");
    if (exPrice.currency !== referenceCurrency) throw new RangeError("exPriceObservation currency must equal reference currency");
    const exValue = numberRational(exPrice.value, "exPriceObservation.value", { positive: true });
    const exObserved = instant(exPrice.observedAt, "exPriceObservation.observedAt");
    const exAvailable = instant(exPrice.availableAt, "exPriceObservation.availableAt");
    available(exObserved, exAvailable, asOf, "exPriceObservation");
    if (exObserved < exAt) throw new RangeError("exPriceObservation must be observed at or after exAt");
    textValue(exPrice.sourceId, "exPriceObservation.sourceId");
    result.returns = {
      priceReturn: roundHalfAway(sub(div(exValue, basisPrice), ONE), decimals),
      grossTotalReturn: roundHalfAway(sub(div(add(exValue, grossReference), basisPrice), ONE), decimals),
      netTotalReturn: roundHalfAway(sub(div(add(exValue, netReference), basisPrice), ONE), decimals),
      adjustedPriceReturn: roundHalfAway(sub(div(exValue, theoreticalEx), ONE), decimals),
    };
    result.lineage.exPriceSourceId = exPrice.sourceId;
  }
  return result;
}
Full-height labplaygroundOpen full screen