The governed definitions this build depends on. Read them first if a term is unfamiliar.
- PrimaryMergerA merger is a corporate transaction combining entities or securities under legally specified consideration and continuity terms.
- PrerequisiteCorporate ActionA corporate action is an issuer or security event that changes rights, quantities, cash flows, identity, or trading terms.
- ImportantAcquisitionAn acquisition is a transaction in which one entity obtains control of another business or its assets under stated terms.
- ImportantCorporate Action Effective DateA corporate action effective date is the market or legal date from which the declared event treatment applies.
- ImportantDelistingDelisting is the removal of a security's listing from a trading venue.
- ImportantHolding Period ReturnHolding period return measures the economic gain or loss relative to starting value over a specified ownership interval.
- ImportantSecurity MasterA security master is a governed reference-data system that organizes instruments, issuers, identifiers, listings, currencies, statuses, and effective dates.
- ImportantShare ClassA share class is a category of an issuer's equity with a defined set of rights, restrictions, or economic terms.
- ImportantStable Security IdentifierA stable security identifier is a governed key intended to preserve instrument identity across changes in mutable labels such as ticker or name.
- ImportantTotal ReturnTotal return measures price change together with eligible distributions over a declared holding period.
- ImportantUniverse MembershipUniverse membership is an effective-dated assertion that a security belongs to a defined eligible set for a specified interval.
A merger record should answer two different questions. What did the holder receive? And what identity relation, if any, now connects the cancelled instrument to a successor instrument? Treating both questions as “change the ticker” is how cash disappears, share classes collapse, and unrelated price histories get spliced.
This tutorial builds an auditable mapping that keeps identity lineage, economic entitlement, listing state, and historical price treatment separate.
The result before the formula
The canonical synthetic position starts with 101 predecessor shares. A stock election is fulfilled at 75%, the rest falls back to cash, and the stock leg creates a fractional share. The mapped output is:
- 45 whole shares of one explicitly identified successor instrument;
- USD 1,260.30 net cash after sourced consideration, cash-in-lieu, withholding, and fees;
- one effective-dated predecessor-instrument to successor-instrument edge;
- an inactive predecessor listing after its delisting instant;
not_establishedfor successor listing activation; andnot_computedfor historical price adjustment.
Those last two guards matter. A delivered instrument can be known before its listing activation is proven. A legal or economic relation does not prove that two market-price series share one basis.
Model five identity layers
“Company,” “stock,” and “ticker” are not synonyms.
| Layer | Example question | Merger risk |
|---|---|---|
| Issuer | Which legal entity issued the claim? | The target may disappear while the acquirer survives. |
| Instrument | Which contractual security exists? | The predecessor may be cancelled and replaced. |
| Share class | Which voting and economic rights apply? | Different delivered classes cannot be collapsed. |
| Listing | Where and when can the instrument trade? | Closing, effectiveness, suspension, and listing activation can differ. |
| Identifier | Which scheme names which layer? | Tickers and issue identifiers can change or be reused under their own rules. |
ISO 6166 standardizes the International Securities Identification Number for a securities issue; it does not turn an ISIN into a universal event-history key. NYSE's security-master specification separately describes ticker, CUSIP, ISIN, issuer name, and a venue stock number, and notes that some corporate actions can cause a new stock number. The safe design is therefore a graph of typed nodes and sourced, effective-dated edges—not one overwritten “permanent security” row. See the ISO record and NYSE specification.
Keep identity and money on parallel tracks
The identity track says which predecessor instrument leads to which successor instrument under the event. The entitlement track says how many successor units and how much cash this position receives. Neither track establishes a historical return by itself.
Select knowledge before calculating economics
Each event is an append-only sequence of revisions. Every revision carries one event ID, predecessor identity, revision number, availability instant, status, terms, and source lineage.
The engine enforces four guards before selection:
- every version belongs to the same event ID;
- revision numbers are unique;
- availability increases strictly in revision order; and
- predecessor identity is immutable across versions.
At asOf, the unique highest revision with availableAt <= asOf wins. A correction published tomorrow cannot enter yesterday's holdings. A higher revision from another merger cannot hijack this event. Z and explicit UTC offsets are both accepted, but every instant is compared on one UTC timeline.
The outcome is then one of five states:
pending: not known, not effective, or waiting for a required CIL/appraisal input;ambiguous: election or proration fallback is unresolved;unsupported: a declared term is outside the engine;cancelled: the latest known revision cancels the event; ormapped: one complete identity and entitlement result exists.
Malformed data—such as a negative quantity or impossible timestamp—is rejected rather than disguised as a business state.
The entitlement mathematics
Let q be predecessor quantity and q_a an explicitly sourced appraisal quantity. The quantity available for the ordinary merger options is
If the elected option is fulfilled by fraction p, then
For stock leg j with ratio r_j, gross successor quantity is
For a whole-share cash-in-lieu policy with lot size l,
P_j is not a convenient market close. It is the cash-in-lieu price supplied by the applicable source, with observation time, availability time, currency, and source ID. Until it is known, the correct state is pending.
Cash remains split by component and currency:
The package never invents foreign exchange, tax, appraisal value, or a fee.
Every amount is non-negative, every stock ratio is strictly positive, and every stock or cash leg has one event-unique audit ID. An option with positive allocation cannot have an empty leg set. If legitimate non-negative deductions still exceed positive cash components, the engine returns unsupported for review rather than throwing or posting negative cash.
Walk the synthetic position
The fixture uses a 75% fulfilled stock election and a 25% cash fallback:
| Step | Exact arithmetic | Result |
|---|---|---|
| Elected allocation | 101 × 0.75 | 75.75 shares |
| Fallback allocation | 101 × 0.25 | 25.25 shares |
| Gross successor stock | 75.75 × 0.6 | 45.45 shares |
| Whole delivery | floor(45.45) | 45 shares |
| Cash in lieu | 0.45 × USD 40 | USD 18.00 |
| Consideration cash | 75.75 × 5 + 25.25 × 35 | USD 1,262.50 |
| Withholding | 75.75 × 0.20 | USD 15.15 |
| Fees | 101 × 0.05 | USD 5.05 |
| Net cash | 1,262.50 + 18 - 15.15 - 5.05 | USD 1,260.30 |
The implementation preserves exact rational values and rounds only the public output to six decimals, with half ties away from zero. Decimal strings remain exact even beyond JavaScript's safe-integer boundary.
A historical event that proves why final terms matter
Disney's SEC-filed closing materials for its 2019 acquisition of Twenty-First Century Fox (21CF) provide unusually clear evidence of election and proration.
The issuer release said the acquisition would become effective at 12:02 a.m. Eastern Time on 20 March 2019 and stated headline alternatives of USD 51.572626 cash or 0.4517 Disney shares per eligible 21CF common share, subject to election and proration. The closing 8-K then reported that the cash election was oversubscribed. A valid cash-election share actually received 0.007929 Disney shares plus USD 50.667340, while stock-election and no-election shares received 0.4517 Disney shares. Disney also reported that it issued no fractional shares and paid cash in lieu. SEC-filed issuer release; closing Form 8-K.
That is the real engineering lesson: the headline choice was not the final entitlement for every holder. Storing only “cash or 0.4517 shares” would misstate cash electors after proration.
The article does not add a Disney or 21CF market price, ticker mapping, CUSIP, ISIN, tax rate, fee, or CIL sale price. The cited filings do not establish every field needed by this teaching contract, so those fields stay outside the historical example. No provider-adjusted series is claimed.
Effective, closing, and delisting times are different fields
This engine requires
Equal instants follow the named order: closing, entitlement conversion, then delisting. That ordering is a package contract, not a claim that every venue uses the same operational clock.
U.S. SEC rules separately address removal from listing and successor securities, which is why legal effectiveness and listing status should not be collapsed into one timestamp. SEC Rule 12d2-2 release.
The mapped result marks the predecessor listing inactive only once asOf reaches its sourced delistingAt. It returns not_established for successor listing activation because the current contract does not carry a sourced activation clock. Silence would be easier; an explicit unknown is safer.
Provider treatment is a separate decision
S&P Dow Jones Indices describes an acquisition as potentially cash, stock, or both and explains that, for its branded indices, the target is deleted and an acquirer funded with stock may have share and investable-weight-factor changes. Its target deletion and pricing rules are index methodology, not universal identity law. S&P DJI Equity Indices Policies & Practices.
This distinction prevents three common errors:
- using an index deletion rule as a custodian settlement instruction;
- treating a successor identity edge as authorization to splice prices; and
- assuming one provider's adjusted history defines what every other provider should show.
Implementation walkthrough
The Python and TypeScript implementations consume the same fixture.
They:
- validate revision lineage and timestamp calendars;
- select only knowledge available by
asOf; - stop on cancellation, ambiguity, unsupported terms, or pending evidence;
- separate appraisal quantity before ordinary allocation;
- allocate elected and fallback options exactly;
- process multiple stock and cash legs;
- settle fractions only under an explicit policy;
- aggregate by full successor identity and currency;
- preserve source IDs; and
- refuse to compute a historical price adjustment.
Tests cover the canonical result, input immutability, cancellation, no-look-ahead, mixed event IDs, changing predecessor identity, non-monotone revisions, missing elections, fractional rejection, CIL availability, appraisal, invalid calendar dates, exact large quantities, and rounding ties.
Explore the states
Open the guided merger-mapping lab. Choose a canonical, pre-effective, cancelled, missing-election, or unsupported-fraction scenario. Step moves exactly one state transition. Reset always returns to the same preview. The audit panel keeps revision, allocation, stock, cash, lineage, and price-series guard visible together.
What not to infer
- Legal successor does not mean same instrument.
- Same issuer does not mean same share class.
- Delivered security does not prove an active listing.
- Ticker similarity does not prove identity.
- Deal consideration does not equal a traded market price.
- Cash in lieu does not use an arbitrary close.
- Appraisal, tax, and fees are not universal formulas.
- A mapped entitlement does not reconstruct a delisting return.
Use Point-in-Time Availability Guard before this process. Build identity nodes with Permanent Security Identifier Mapping, Ticker-Change Chain Resolution, and Share-Class Relationship Mapping. Continue to Delisting Return Reconstruction only after the position and cash ledger reconcile.
Asset map
| Asset | Article role | Video scene | Static fallback |
|---|---|---|---|
article-hero.svg | Separate identity layers from entitlements | 2 | Same SVG |
calculation-flow.md | Show point-in-time branch logic | 3 | Text algorithm |
worked-example.svg | Reconcile synthetic stock and cash | 5 | Worked table |
playground.html | Compare mapped and failure states | 6-8 | Worked SVG |
failure-guard.svg | Make non-inferences memorable | 9 | Guard list |
Source roles, dates, jurisdictions, and limitations are in REFERENCES.md. This material is educational and is not investment, legal, tax, accounting, index, or settlement advice.
Rendered from the canonical Mermaid sources linked by this article.
Point-in-time merger resolution
This flow separates event-version validity from economic calculation.
Takeaway: arithmetic begins only after point-in-time lineage, status, timing, election, and settlement evidence pass their gates.
Merger mapping lifecycle
This state diagram shows business outcomes; malformed records are rejected before entering it.
Takeaway: pending, ambiguous, and unsupported preserve distinct reasons; they must not be flattened into a zero entitlement.
References7 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. The historical example uses issuer materials filed with the U.S. Securities and Exchange Commission (SEC). No market-data-provider observation is used.
R1 — Disney closing Form 8-K for the 21CF acquisition
- Organization: The Walt Disney Company, filed with the SEC
- Source type: Current Report on Form 8-K
- Filing date: 2019-03-20
- URL: SEC filing
- Jurisdiction: United States; Delaware transaction documents described in the filing
- Supports: completion; per-share cash and stock alternatives; final election totals; cash-election oversubscription and proration;
0.007929Disney shares plusUSD 50.667340for valid cash-election shares;0.4517Disney shares for stock/no-election shares; no fractional shares and CIL; approximately 306.8 million Disney shares issued. - Limitations: Does not establish this package's synthetic identities, CIL price, holder tax, fee, or market-price data. The filing contains transaction-specific terms, not a universal merger algorithm.
R2 — Disney and 21CF filed issuer release announcing per-share value
- Organizations: The Walt Disney Company and Twenty-First Century Fox, Inc.; filed with the SEC
- Source type: Issuer press release, Exhibit 99.1
- Publication date: 2019-03-19
- URL: SEC-filed exhibit
- Jurisdiction: United States
- Supports: announced
2019-03-20 12:02 a.m. Eastern Timeeffectiveness;USD 51.572626cash or0.4517New Disney shares; election, proration, and adjustment dependency. - Limitations: Announcement evidence immediately before closing. R1 supplies the final election outcome. It does not establish downstream provider mappings.
R3 — Disney and 21CF definitive joint proxy statement/prospectus
- Organizations: The Walt Disney Company and Twenty-First Century Fox, Inc.; filed with the SEC
- Source type: Definitive joint proxy statement/prospectus
- Filing date: 2018-06-28
- URL: SEC filing
- Jurisdiction: United States; includes Delaware-law transaction mechanics
- Supports: the need to read election, proration, conditions, appraisal, and transaction-document terms rather than relying on a headline ratio alone.
- Limitations: Pre-closing document. Final terms and outcomes must be reconciled to R1 and R2. This package does not provide legal advice or reproduce the agreement.
R4 — S&P DJI Equity Indices Policies & Practices
- Organization: S&P Dow Jones Indices
- Source type: Official index-provider methodology
- Publication/version: 2026 edition available at access date
- URL: Official methodology PDF
- Jurisdiction: Global index methodology; local market rules may vary
- Supports: acquisitions can use cash, stock, or both; an acquisition generally deletes the target from S&P DJI branded indices and may change acquirer shares/IWF; target removal and deletion pricing follow provider-specific rules.
- Limitations: Licensed provider methodology for S&P DJI indices. It is not a legal identity standard, custodian instruction, or universal price-adjustment rule.
R5 — ISO 6166:2021, International Securities Identification Number
- Organization: International Organization for Standardization
- Source type: International standard landing page
- Publication date: 2021
- Version: ISO 6166:2021
- URL: ISO standard record
- Jurisdiction: International
- Supports: an ISIN identifies a security issue under a defined standard; identity schemes need explicit scope.
- Limitations: Full standard text is not reproduced. An ISIN alone does not encode this package's event relation, holder election, or economic entitlement.
R6 — NYSE Group Security Master Client Specification
- Organization: New York Stock Exchange / Intercontinental Exchange
- Source type: Official security-master data specification
- Version: 4.0.6
- URL: Official specification PDF
- Jurisdiction: NYSE Group markets
- Supports: listing/security-master records distinguish ticker, CUSIP, ISIN, issuer name, and a venue stock number; certain corporate actions can cause a new stock index number.
- Limitations: Venue-specific data specification. It does not establish legal successor relations or consideration entitlements outside NYSE data.
R7 — SEC rule release on removal from listing and successor securities
- Organization: U.S. Securities and Exchange Commission
- Source type: Final rule release concerning Exchange Act Rule 12d2-2 and Form 25
- Publication date: 2004-06-15
- URL: SEC rule release
- Jurisdiction: United States
- Supports: delisting and successor-security admission can have distinct legal and operational timing; substitution by operation of law is handled explicitly.
- Limitations: Regulatory filing mechanics, not a formula for holder entitlements or historical returns.
Evidence boundary
The Disney/21CF values above are sourced transaction facts. The package's 101-share worked example, 75% fulfillment, 0.6 stock ratio, USD 5 and USD 35 cash legs, USD 40 CIL price, withholding, fees, identities, and timestamps are deliberately synthetic. No FMP data, closing-price observation, or claimed provider-adjusted series is used.
Full dependency-light reference implementations in both supported languages.
/** Point-in-time merger identity and entitlement mapping.
*
* Decimal strings are converted to exact bigint fractions. Public values are
* rounded to six decimals, ties away from zero. No price history is created.
*/
type JsonObject = Record<string, any>;
const SCALE = 1_000_000n;
const PRICE_REASON =
"Identity and entitlement mapping does not create a historical price-adjustment series.";
function 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;
}
class Rational {
readonly n: bigint;
readonly d: bigint;
constructor(n: bigint, d: bigint = 1n) {
if (d === 0n) throw new Error("zero denominator");
if (d < 0n) [n, d] = [-n, -d];
const divisor = gcd(n, d);
this.n = n / divisor;
this.d = d / divisor;
}
static parse(value: unknown, field: string, nonnegative = true): Rational {
if (typeof value === "number") {
if (!Number.isSafeInteger(value)) throw new Error(`${field} must be an integer or decimal string`);
value = String(value);
}
if (typeof value !== "string" || !/^[+-]?\d+(?:\.\d+)?$/.test(value)) {
throw new Error(`${field} must be an exact decimal`);
}
const sign = value.startsWith("-") ? -1n : 1n;
const unsigned = value.replace(/^[+-]/, "");
const [whole, decimals = ""] = unsigned.split(".");
const denominator = 10n ** BigInt(decimals.length);
const result = new Rational(sign * BigInt(whole + decimals), denominator);
if (nonnegative && result.n < 0n) throw new Error(`${field} must be non-negative`);
return result;
}
add(other: Rational): Rational {
return new Rational(this.n * other.d + other.n * this.d, this.d * other.d);
}
sub(other: Rational): Rational {
return new Rational(this.n * other.d - other.n * this.d, this.d * other.d);
}
mul(other: Rational): Rational {
return new Rational(this.n * other.n, this.d * other.d);
}
div(other: Rational): Rational {
return new Rational(this.n * other.d, this.d * other.n);
}
compare(other: Rational): number {
const delta = this.n * other.d - other.n * this.d;
return delta < 0n ? -1 : delta > 0n ? 1 : 0;
}
floor(): bigint {
if (this.n < 0n) throw new Error("floor is only used for non-negative entitlements");
return this.n / this.d;
}
format6(): string {
const negative = this.n < 0n;
const absolute = negative ? -this.n : this.n;
let scaled = (absolute * SCALE) / this.d;
const remainder = (absolute * SCALE) % this.d;
if (remainder * 2n >= this.d) scaled += 1n;
const text = scaled.toString().padStart(7, "0");
return `${negative ? "-" : ""}${text.slice(0, -6)}.${text.slice(-6)}`;
}
}
const ZERO = new Rational(0n);
const ONE = new Rational(1n);
function instant(value: unknown, field: string): number {
if (typeof value !== "string" || value.length === 0) throw new Error(`${field} must be a non-empty ISO 8601 instant`);
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})$/);
if (!match) throw new Error(`${field} must be a valid ISO 8601 instant with an offset`);
const [, yText, moText, dText, hText, miText, sText, zone] = match;
const y = Number(yText), mo = Number(moText), d = Number(dText);
const h = Number(hText), mi = Number(miText), s = Number(sText);
const days = new Date(Date.UTC(y, mo, 0)).getUTCDate();
if (mo < 1 || mo > 12 || d < 1 || d > days || h > 23 || mi > 59 || s > 59) {
throw new Error(`${field} must be a valid calendar instant`);
}
if (zone !== "Z") {
const [zh, zm] = zone.slice(1).split(":").map(Number);
if (zh > 23 || zm > 59) throw new Error(`${field} has an invalid UTC offset`);
}
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) throw new Error(`${field} must be a valid ISO 8601 instant`);
return parsed;
}
function status(state: string, reason: string, event?: JsonObject): JsonObject {
const result: JsonObject = { state, reason };
if (event) {
result.eventId = event.eventId;
result.selectedRevision = event.revision;
result.effectiveAt = event.effectiveAt;
}
return result;
}
function requireIdentity(identity: unknown, field: string): JsonObject {
if (!identity || typeof identity !== "object" || Array.isArray(identity)) throw new Error(`${field} must be an object`);
const item = identity as JsonObject;
for (const name of ["issuerId", "instrumentId", "shareClassId", "listingId"]) {
if (typeof item[name] !== "string" || !item[name]) throw new Error(`${field}.${name} is required`);
}
if (!Array.isArray(item.identifiers) || item.identifiers.length === 0) throw new Error(`${field}.identifiers must be a non-empty list`);
item.identifiers.forEach((identifier: unknown, index: number) => {
if (!identifier || typeof identifier !== "object" || Array.isArray(identifier)) throw new Error(`${field}.identifiers[${index}] must be an object`);
for (const name of ["scheme", "value", "sourceId"]) {
if (typeof (identifier as JsonObject)[name] !== "string" || !(identifier as JsonObject)[name]) {
throw new Error(`${field}.identifiers[${index}].${name} is required`);
}
}
});
return item;
}
function addSource(sources: Set<string>, sourceId: unknown, field: string): void {
if (typeof sourceId !== "string" || !sourceId) throw new Error(`${field} is required`);
sources.add(sourceId);
}
type CashBucket = {
consideration: Rational;
appraisal: Rational;
withholding: Rational;
fees: Rational;
cashInLieu: Rational;
};
function newCashBucket(): CashBucket {
return { consideration: ZERO, appraisal: ZERO, withholding: ZERO, fees: ZERO, cashInLieu: ZERO };
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
if (value && typeof value === "object") {
const object = value as JsonObject;
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function selectVersion(versions: unknown, asOf: number): { event?: JsonObject; issue?: string } {
if (!Array.isArray(versions) || versions.length === 0) throw new Error("eventVersions must be a non-empty list");
let eventId: string | undefined;
let predecessorSignature: string | undefined;
const normalized: Array<{ revision: number; availableAt: number }> = [];
const available = versions.filter((version, index) => {
if (!version || typeof version !== "object" || Array.isArray(version)) throw new Error(`eventVersions[${index}] must be an object`);
const item = version as JsonObject;
if (!Number.isInteger(item.revision) || item.revision < 1) throw new Error(`eventVersions[${index}].revision must be a positive integer`);
if (typeof item.eventId !== "string" || !item.eventId) throw new Error(`eventVersions[${index}].eventId is required`);
if (eventId === undefined) eventId = item.eventId;
else if (item.eventId !== eventId) throw new Error("all eventVersions must describe the same eventId");
const predecessor = requireIdentity(item.predecessor, `eventVersions[${index}].predecessor`);
const signature = stableStringify(predecessor);
if (predecessorSignature === undefined) predecessorSignature = signature;
else if (signature !== predecessorSignature) throw new Error("predecessor identity must remain immutable across eventVersions");
const availableAt = instant(item.availableAt, `eventVersions[${index}].availableAt`);
normalized.push({ revision: item.revision, availableAt });
return availableAt <= asOf;
}) as JsonObject[];
normalized.sort((a, b) => a.revision - b.revision);
for (let index = 1; index < normalized.length; index += 1) {
if (normalized[index].revision === normalized[index - 1].revision) throw new Error("eventVersions revisions must be unique");
if (normalized[index].availableAt <= normalized[index - 1].availableAt) throw new Error("eventVersions availableAt must increase strictly in revision order");
}
if (available.length === 0) return { issue: "No event version was available by asOf." };
const highest = Math.max(...available.map((item) => item.revision));
const selected = available.filter((item) => item.revision === highest);
if (selected.length !== 1) return { issue: "Multiple available versions share the highest revision." };
return { event: selected[0] };
}
export function resolveMerger(payload: JsonObject): JsonObject {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("payload must be an object");
const snapshot = JSON.stringify(payload);
const asOf = instant(payload.asOf, "asOf");
const selected = selectVersion(payload.eventVersions, asOf);
if (!selected.event) {
const state = selected.issue?.startsWith("Multiple") ? "ambiguous" : "pending";
return status(state, selected.issue ?? "No resolvable event version.");
}
const event = selected.event;
for (const field of ["eventId", "status", "announcedAt", "closingAt", "effectiveAt", "delistingAt"]) {
if (typeof event[field] !== "string" || !event[field]) throw new Error(`selected event.${field} is required`);
}
const announced = instant(event.announcedAt, "selected event.announcedAt");
const available = instant(event.availableAt, "selected event.availableAt");
const closing = instant(event.closingAt, "selected event.closingAt");
const effective = instant(event.effectiveAt, "selected event.effectiveAt");
const delisting = instant(event.delistingAt, "selected event.delistingAt");
if (announced > available) throw new Error("announcedAt must not be after availableAt");
if (announced > closing) throw new Error("announcedAt must not be after closingAt");
if (closing > effective || effective > delisting) throw new Error("timeline must satisfy closingAt <= effectiveAt <= delistingAt");
if (event.status === "cancelled") return status("cancelled", "The latest available event version is cancelled.", event);
if (!["confirmed", "pending"].includes(event.status)) return status("unsupported", "The event status is outside the supported state model.", event);
if (event.status === "pending" || asOf < effective) return status("pending", "The merger is not yet effective at asOf.", event);
if (event.termsSupported !== true) return status("unsupported", "The selected terms are explicitly marked unsupported.", event);
const predecessor = requireIdentity(event.predecessor, "predecessor");
if (!payload.position || typeof payload.position !== "object" || Array.isArray(payload.position)) throw new Error("position must be an object");
const position = payload.position as JsonObject;
const totalQuantity = Rational.parse(position.predecessorQuantity, "position.predecessorQuantity");
if (totalQuantity.compare(ZERO) <= 0) throw new Error("position.predecessorQuantity must be positive");
const appraisalQuantity = Rational.parse(position.appraisalQuantity ?? "0", "position.appraisalQuantity");
if (appraisalQuantity.compare(totalQuantity) > 0) throw new Error("position.appraisalQuantity cannot exceed predecessorQuantity");
const sources = new Set<string>();
if (!Array.isArray(event.sourceIds) || event.sourceIds.length === 0) throw new Error("selected event.sourceIds must be a non-empty list");
event.sourceIds.forEach((sourceId: unknown) => addSource(sources, sourceId, "selected event.sourceIds[]"));
predecessor.identifiers.forEach((identifier: JsonObject) => addSource(sources, identifier.sourceId, "predecessor identifier.sourceId"));
const cash = new Map<string, CashBucket>();
const cashBucket = (currency: string): CashBucket => {
if (!cash.has(currency)) cash.set(currency, newCashBucket());
return cash.get(currency)!;
};
if (appraisalQuantity.compare(ZERO) > 0) {
const resolution = event.appraisalResolution;
if (!resolution || typeof resolution !== "object" || Array.isArray(resolution)) {
return status("pending", "Appraisal quantity exists but no appraisal resolution is available.", event);
}
if (instant(resolution.availableAt, "appraisalResolution.availableAt") > asOf) {
return status("pending", "The appraisal resolution was not available by asOf.", event);
}
if (typeof resolution.currency !== "string" || !resolution.currency) throw new Error("appraisalResolution.currency is required");
addSource(sources, resolution.sourceId, "appraisalResolution.sourceId");
const bucket = cashBucket(resolution.currency);
bucket.appraisal = bucket.appraisal.add(appraisalQuantity.mul(Rational.parse(resolution.cashPerShare, "appraisalResolution.cashPerShare")));
bucket.withholding = bucket.withholding.add(appraisalQuantity.mul(Rational.parse(resolution.withholdingPerShare ?? "0", "appraisalResolution.withholdingPerShare")));
bucket.fees = bucket.fees.add(appraisalQuantity.mul(Rational.parse(resolution.feePerShare ?? "0", "appraisalResolution.feePerShare")));
}
const allocatable = totalQuantity.sub(appraisalQuantity);
if (!Array.isArray(event.options) || event.options.length === 0) throw new Error("selected event.options must be a non-empty list");
const optionMap = new Map<string, JsonObject>();
const seenLegIds = new Set<string>();
for (const option of event.options) {
if (!option || typeof option !== "object" || Array.isArray(option) || typeof option.optionId !== "string") throw new Error("each option must have an optionId");
if (optionMap.has(option.optionId)) throw new Error("optionId values must be unique");
for (const collectionName of ["stockLegs", "cashLegs"]) {
const collection = option[collectionName] ?? [];
if (!Array.isArray(collection)) throw new Error(`${collectionName} must be a list`);
for (const leg of collection) {
if (!leg || typeof leg !== "object" || Array.isArray(leg)) throw new Error(`${collectionName} entries must be objects`);
if (typeof leg.legId !== "string" || !leg.legId) throw new Error("every stock and cash leg requires a legId");
if (seenLegIds.has(leg.legId)) throw new Error("legId values must be unique across the event");
seenLegIds.add(leg.legId);
}
}
optionMap.set(option.optionId, option);
}
const elected = position.electedOptionId || event.defaultOptionId;
if (typeof elected !== "string" || !optionMap.has(elected)) return status("ambiguous", "No valid elected or default consideration option is known.", event);
const rules = event.electionRules ?? {};
if (!rules || typeof rules !== "object" || Array.isArray(rules)) throw new Error("electionRules must be an object");
const fractions = rules.fulfilledFractions ?? {};
if (!fractions || typeof fractions !== "object" || Array.isArray(fractions)) throw new Error("electionRules.fulfilledFractions must be an object");
const fulfilled = Rational.parse(fractions[elected] ?? "1", `fulfilledFractions.${elected}`);
if (fulfilled.compare(ONE) > 0) throw new Error("fulfilled fraction cannot exceed one");
const allocations: Array<{ optionId: string; role: string; fraction: Rational; quantity: Rational }> = [
{ optionId: elected, role: "elected", fraction: fulfilled, quantity: allocatable.mul(fulfilled) },
];
if (fulfilled.compare(ONE) < 0) {
const fallback = rules.fallbackOptionId;
if (typeof fallback !== "string" || !optionMap.has(fallback) || fallback === elected) {
return status("ambiguous", "Proration requires a distinct known fallback option.", event);
}
const remainder = ONE.sub(fulfilled);
allocations.push({ optionId: fallback, role: "fallback", fraction: remainder, quantity: allocatable.mul(remainder) });
}
const positions = new Map<string, { identity: JsonObject; quantity: Rational }>();
const fractionalSettlements: JsonObject[] = [];
const identityEdges = new Map<string, JsonObject>();
for (const allocation of allocations) {
const option = optionMap.get(allocation.optionId)!;
const stockLegs = option.stockLegs ?? [];
const cashLegs = option.cashLegs ?? [];
if (!Array.isArray(stockLegs) || !Array.isArray(cashLegs)) throw new Error("stockLegs and cashLegs must be lists");
if (allocation.quantity.compare(ZERO) > 0 && stockLegs.length === 0 && cashLegs.length === 0) {
throw new Error("an option with positive allocation must contain at least one consideration leg");
}
for (const leg of cashLegs) {
if (!leg || typeof leg !== "object" || Array.isArray(leg)) throw new Error("cash legs must be objects");
const targetKey = ({ consideration: "consideration", appraisal: "appraisal", withholding: "withholding", fee: "fees" } as Record<string, keyof CashBucket>)[leg.kind];
if (!targetKey) return status("unsupported", `Unsupported cash-leg kind: ${JSON.stringify(leg.kind)}.`, event);
if (typeof leg.currency !== "string" || !leg.currency) throw new Error("cash leg currency is required");
addSource(sources, leg.sourceId, "cash leg.sourceId");
const amount = Rational.parse(leg.amountPerAllocatedPredecessorShare, "cash leg amount");
const bucket = cashBucket(leg.currency);
bucket[targetKey] = bucket[targetKey].add(allocation.quantity.mul(amount));
}
for (const leg of stockLegs) {
if (!leg || typeof leg !== "object" || Array.isArray(leg)) throw new Error("stock legs must be objects");
if (typeof leg.legId !== "string" || !leg.legId) throw new Error("stock leg.legId is required");
addSource(sources, leg.sourceId, "stock leg.sourceId");
const successor = requireIdentity(leg.successor, `stock leg ${leg.legId}.successor`);
successor.identifiers.forEach((identifier: JsonObject) => addSource(sources, identifier.sourceId, "successor identifier.sourceId"));
const ratio = Rational.parse(leg.ratioPerAllocatedPredecessorShare, "stock leg ratio");
if (ratio.compare(ZERO) <= 0) throw new Error("stock leg ratio must be positive");
const gross = allocation.quantity.mul(ratio);
const policy = leg.fractionalPolicy;
if (!policy || typeof policy !== "object" || Array.isArray(policy)) throw new Error("stock leg.fractionalPolicy must be an object");
const mode = policy.mode;
const lot = Rational.parse(policy.lotSize ?? "1", "fractionalPolicy.lotSize");
if (lot.compare(ZERO) <= 0) throw new Error("fractionalPolicy.lotSize must be positive");
const lots = gross.div(lot).floor();
const deliverable = mode === "keep_fractional" ? gross : lot.mul(new Rational(lots));
const fractional = gross.sub(deliverable);
let cil = ZERO;
let cilCurrency: string | null = null;
let cilSource: string | null = null;
if (mode === "cash_in_lieu" && fractional.compare(ZERO) > 0) {
const observed = instant(policy.observedAt, "fractionalPolicy.observedAt");
const priceAvailable = instant(policy.availableAt, "fractionalPolicy.availableAt");
if (observed > priceAvailable) throw new Error("cash-in-lieu observedAt must not be after availableAt");
if (priceAvailable > asOf) return status("pending", "Cash-in-lieu price was not available by asOf.", event);
if (typeof policy.currency !== "string" || !policy.currency) throw new Error("cash-in-lieu currency is required");
cilCurrency = policy.currency;
cilSource = policy.sourceId;
addSource(sources, cilSource, "fractionalPolicy.sourceId");
const cilPrice = Rational.parse(policy.pricePerShare, "fractionalPolicy.pricePerShare");
cil = fractional.mul(cilPrice);
const bucket = cashBucket(cilCurrency);
bucket.cashInLieu = bucket.cashInLieu.add(cil);
} else if (mode === "reject" && fractional.compare(ZERO) > 0) {
return status("unsupported", "A fractional entitlement conflicts with the reject policy.", event);
} else if (!["cash_in_lieu", "keep_fractional", "round_down"].includes(mode)) {
return status("unsupported", `Unsupported fractional-share policy: ${JSON.stringify(mode)}.`, event);
}
const key = [successor.issuerId, successor.instrumentId, successor.shareClassId, successor.listingId].join("\u0000");
const current = positions.get(key) ?? { identity: successor, quantity: ZERO };
current.quantity = current.quantity.add(deliverable);
positions.set(key, current);
const edgeKey = `${predecessor.instrumentId}\u0000${successor.instrumentId}`;
if (!identityEdges.has(edgeKey)) {
identityEdges.set(edgeKey, {
relation: "predecessor_instrument_to_successor_instrument",
fromInstrumentId: predecessor.instrumentId,
toInstrumentId: successor.instrumentId,
effectiveAt: event.effectiveAt,
sourceId: leg.sourceId,
});
}
if (fractional.compare(ZERO) > 0) {
fractionalSettlements.push({
stockLegId: leg.legId,
grossQuantity: gross.format6(),
deliverableQuantity: deliverable.format6(),
fractionalQuantity: fractional.format6(),
cashInLieu: cil.format6(),
currency: cilCurrency,
sourceId: cilSource,
});
}
}
}
const cashByCurrency: JsonObject[] = [];
for (const currency of [...cash.keys()].sort()) {
const bucket = cash.get(currency)!;
const net = bucket.consideration.add(bucket.appraisal).add(bucket.cashInLieu).sub(bucket.withholding).sub(bucket.fees);
if (net.compare(ZERO) < 0) return status("unsupported", `Net cash is negative in ${currency}.`, event);
cashByCurrency.push({
currency,
consideration: bucket.consideration.format6(),
appraisal: bucket.appraisal.format6(),
withholding: bucket.withholding.format6(),
fees: bucket.fees.format6(),
cashInLieu: bucket.cashInLieu.format6(),
net: net.format6(),
});
}
const successorPositions = [...positions.keys()].sort().map((key) => {
const record = positions.get(key)!;
return {
issuerId: record.identity.issuerId,
instrumentId: record.identity.instrumentId,
shareClassId: record.identity.shareClassId,
listingId: record.identity.listingId,
quantity: record.quantity.format6(),
};
});
const result = {
state: "mapped",
eventId: event.eventId,
selectedRevision: event.revision,
effectiveAt: event.effectiveAt,
predecessor: JSON.parse(JSON.stringify(predecessor)),
allocations: allocations.map((item) => ({
optionId: item.optionId,
role: item.role,
fulfilledFraction: item.fraction.format6(),
allocatedPredecessorQuantity: item.quantity.format6(),
})),
successorPositions,
cashByCurrency,
fractionalSettlements,
identityEdges: [...identityEdges.keys()].sort().map((key) => identityEdges.get(key)),
listingState: { predecessor: asOf >= delisting ? "inactive" : "active_until_delisting", successors: "not_established" },
priceAdjustment: { state: "not_computed", reason: PRICE_REASON },
diagnostics: {
asOf: payload.asOf,
selectedVersionAvailableAt: event.availableAt,
sourceIds: [...sources].sort(),
},
};
if (JSON.stringify(payload) !== snapshot) throw new Error("resolveMerger mutated its input");
return result;
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.