D02-F04-A01 / Complete engineering topic

Historical Constituent Reconstruction: Rebuild the Roster Without Looking Ahead

Use effective time and knowledge time to reconstruct an index roster without today's constituents, later corrections, or silent gap filling.

D02 · CORPORATE ACTIONS AND SECURIT…
D02-F04-A01Canonical / Tested / Open
D02 / D02-F04
Key concepts

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

A historical backtest needs the roster that existed then—not the names that survived until today. But “existed then” is only half the requirement. The simulation may also use only the evidence its system had received by that moment in the research timeline.

Historical constituent reconstruction therefore answers:

Which stable security identities belonged to this index at effective instant t, using only evidence available by knowledge instant k?

That second clock prevents a later correction from quietly rewriting an earlier backtest.

Why one date is not enough

Suppose a provider announces an addition for next month. A later notice identifies the deletion, and a still later correction changes one security identity. The effective date can remain constant while the knowable roster changes across announcement versions.

The two clocks have different jobs:

  • effectiveAt asks when membership applies;
  • knownAt limits which snapshots and notice revisions the simulated system may select.

An announcement timestamp is also not automatically your availableAt. The latter records when your own research system could actually use the artifact.

A two-clock timeline showing an initial event revision, a later correction, and two knowledge cutoffs.

The key takeaway is simple: a revision published after knownAt must remain excluded even when it describes an earlier effective date.

What the algorithm reconstructs

The output is membership. It does not infer:

  • constituent weights;
  • free-float or other investability factors;
  • trading eligibility or liquidity;
  • a continuous price series;
  • index levels, returns, or divisors.

Those objects can share timestamps and identifiers, but they require their own evidence and rules.

A boundary diagram separating membership from weights, investability, price continuity, and trading eligibility.

The evidence contract

Every input instant is a calendar-valid UTC ISO 8601 value ending in Z. Every index and security uses a stable identity, not a display ticker.

A base snapshot contains a versioned roster, effective and available times, and lastSequence: the last ledger position already reflected in that roster. An event version contains:

  • an immutable eventId, sequence, type, effective instant, and same-instant order;
  • announcement and local availability instants;
  • a contiguous revision number;
  • active or cancelled status;
  • explicit add and delete operations;
  • a source lineage identifier.

The implementation recognizes addition, deletion, replacement, rebalance, reconstitution, and corporate-action membership events. A rebalance may carry no membership changes: weights are outside this contract.

Four honest outcomes

The resolver refuses to manufacture certainty:

StatusMeaning
resolvedone roster follows from the eligible evidence
incompleteno eligible base exists or a ledger sequence is missing
ambiguousevidence overlaps, conflicts, or requests an impossible state transition
unsupporteda relevant event type has no implemented semantics
Rendering system map…

The diagram's important branch is not the happy path. It is the refusal to turn missing or conflicting evidence into a plausible-looking list.

Replay rule

For event i, let M_(i-1) be the current member set, D_i its deletions, and A_i its additions:

Mi=(Mi1Di)AiM_i = (M_{i-1} \setminus D_i) \cup A_i

Before applying the formula, every deletion must already be present, and every addition must be absent after deletion processing. Within one event, deletions run before additions. Across events, order is deterministic by effective instant, declared same-instant order, sequence, and event identity.

Membership intervals are half-open. If a replacement is effective at 2024-03-01T00:00:00Z, the deleted security's interval ends there and the addition's begins there.

Synthetic worked example

The shared fixture starts with four synthetic securities. It then records six membership sequences: an addition, a replacement, a membership-neutral rebalance, a corporate-action replacement, a reconstitution, and another addition. Revision 1 of the last event adds SEC:J; revision 2 later corrects it to SEC:K.

For effective 2024-08-15T00:00:00Z and knowledge 2024-08-15T12:00:00Z, revision 2 is not yet available. The roster is:

SEC:E, SEC:F, SEC:G, SEC:H, SEC:I, SEC:J

Keeping the effective instant fixed but moving knowledge to 2024-09-15T12:00:00Z selects revision 2:

SEC:E, SEC:F, SEC:G, SEC:H, SEC:I, SEC:K

These identities and dates are entirely synthetic. The comparison demonstrates revision selection; it is not a historical index claim.

Try the same sequence in the guided playground. Choose a scenario, use Step to advance one evidence transition, and compare the current roster with excluded later evidence.

A bounded historical illustration

Official S&P Dow Jones Indices releases provide a useful chronology without requiring an invented price or weight:

  1. On 2020-11-16, S&P DJI said Tesla would enter the S&P 500 before the 2020-12-21 open, but the replaced company would be named later.
  2. On 2020-11-30, it said Tesla would be added at full float-adjusted market-cap weight, while the replacement name was still pending.
  3. On 2020-12-11, it identified Apartment Investment and Management (AIV) as the replaced S&P 500 constituent.

The November 16 release, November 30 release, and December 11 release establish only that public announcement sequence. They do not prove this package's synthetic availableAt times, historical weights, security-master mappings, or any vendor's roster output.

This is exactly why a real-data implementation needs immutable notice artifacts, retrieval timestamps, stable identity mappings, revision links, effective boundaries, and licensing records. Without that evidence, mark the reconstruction incomplete.

Corrections and cancellations

Revisions are selected independently for each event identity. The highest revision available by knownAt wins. A cancellation consumes its sequence but performs no membership operation. This lets the audit explain that an event was known and withdrawn without creating a false ledger gap.

A later revision never mutates the earlier query. Re-running the earlier knownAt must reproduce the earlier roster.

Same-instant events and conflicts

Two changes can share an effective timestamp. effectiveOrder records an explicit order when the source establishes one. If different event identities target the same security at the same instant and order, the resolver returns ambiguous. It does not choose whichever row arrived first in a file.

Likewise, two event identities claiming the same sequence are competing evidence, not a tie to break alphabetically.

Implementation and tests

The Python implementation and TypeScript implementation share one fixture. Both validate the complete input before selection and leave the caller's input unchanged.

The parity suites test:

  • early and late knowledge cutoffs;
  • later corrections and cancellations;
  • half-open boundaries;
  • unavailable bases and sequence gaps;
  • overlapping snapshots and duplicate sequences;
  • same-instant conflicts;
  • add/delete state violations;
  • unsupported event types;
  • strict UTC timestamps and revision invariants.

Passing these tests establishes definition and implementation agreement. It does not prove that a proprietary historical constituent feed is complete or licensed.

Practical checklist

Before trusting a reconstructed roster, verify:

  1. Is the index identity stable and unambiguous?
  2. Does the base snapshot reflect a known ledger sequence?
  3. Is every security mapped to a point-in-time stable identity?
  4. Are effective, announcement, and local availability instants separate?
  5. Are revisions contiguous, immutable in identity, and availability-ordered?
  6. Are sequence gaps, overlaps, and cancellations explicit?
  7. Are weights, investability, tradability, and price continuity handled elsewhere?
  8. Can the exact query be replayed from retained evidence?

Summary

A defensible historical universe is not a dated copy of today's list. It is a bitemporal replay from an eligible base through a complete, versioned event ledger. When the evidence cannot define one roster, the correct result is incomplete, ambiguous, or unsupported—not a silent guess.

Next, Survivorship-Bias Guard shows how a research pipeline enforces this point-in-time universe at every observation.

Primary references

The complete source roles and limitations are recorded in REFERENCES.md. All calculations and interactive values are synthetic.

Bitemporal reconstruction flow

This diagram shows where the effective and knowledge clocks enter the resolver.

Rendering system map…

Takeaway: only evidence available by knownAt can reach replay, and only one complete ledger can emit a roster.

Resolution state lifecycle

This state diagram emphasizes that failure states are outputs, not exceptions to hide.

Rendering system map…

Takeaway: non-resolved evidence never produces a guessed membership list.

ReferencesPrimary sources and evidence notes

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

S01 — Tesla Set to Join S&P 500

  • Organization: S&P Dow Jones Indices / S&P Global
  • Source type: Official provider press release
  • Publication date: 2020-11-16
  • Version: Archived release
  • URL: https://press.spglobal.com/2020-11-16-Tesla-Set-to-Join-S-P-500
  • Accessed: 2026-07-22
  • Jurisdiction: United States / global index users
  • Supports: Tesla addition effective before the 2020-12-21 open; the replacement company was not yet named.
  • Limitations: Does not establish local ingestion time, final replacement, historical prices, or a complete constituent file.

S02 — S&P DJI Announces Implementation of Tesla’s Addition to S&P 500

  • Organization: S&P Dow Jones Indices / S&P Global
  • Source type: Official provider press release
  • Publication date: 2020-11-30
  • Version: Archived release
  • URL: https://press.spglobal.com/2020-11-30-S-P-Dow-Jones-Indices-Announces-Implementation-of-Teslas-Addition-to-S-P-500
  • Accessed: 2026-07-22
  • Jurisdiction: United States / global index users
  • Supports: Full float-adjusted-market-cap implementation before the 2020-12-21 open; replacement still to be announced after 2020-12-11 close.
  • Limitations: Weighting language is contextual only; this package does not calculate or reproduce the historical weight.

S03 — Tesla Set to Join S&P 500 & 100; Apartment Income REIT to Join S&P MidCap 400

S04 — FTSE Russell Corporate Actions and Events Guide

S05 — Subscribe to FTSE Russell index data

  • Organization: FTSE Russell / LSEG
  • Source type: Official data-product description
  • Publication date: Current page
  • Version: Accessed 2026-07-22
  • URL: https://www.lseg.com/en/ftse-russell/index-resources/indices-subscribe-data
  • Accessed: 2026-07-22
  • Jurisdiction: Global
  • Supports: Historical constituent-level datasets, completed and pending index change calendars, and corporate-action calendars are distinct data products.
  • Limitations: Product description is not redistribution permission and does not provide the package's synthetic records.

Evidence boundary

S01–S03 support only the named Tesla/AIV announcement chronology. S04–S05 support only general operational context. Every calculated roster, sequence, local availableAt, interval, and diagnostic in this package is synthetic. No private FMP result, API credential, historical vendor output, or inferred provider weight is published.

historical_constituent_reconstruction.ts
/** Bitemporal historical constituent reconstruction for D02-F04-A01. */
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
const SUPPORTED = new Set(["add", "delete", "replace", "rebalance", "reconstitution", "corporate_action"]);

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 instant = (value: unknown, name: string): number => {
  const valueText = textValue(value, name);
  if (!UTC.test(valueText)) throw new RangeError(`${name} must be a strict UTC ISO 8601 instant ending in Z`);
  const parsed = Date.parse(valueText);
  if (!Number.isFinite(parsed)) throw new RangeError(`${name} is not a calendar-valid instant`);
  const [, year, month, day, hour, minute, second] = valueText.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/)!.map(Number);
  const d = new Date(parsed);
  if (d.getUTCFullYear() !== year || d.getUTCMonth() + 1 !== month || d.getUTCDate() !== day || d.getUTCHours() !== hour || d.getUTCMinutes() !== minute || d.getUTCSeconds() !== second) {
    throw new RangeError(`${name} is not a calendar-valid instant`);
  }
  return parsed;
};
const positiveInt = (value: unknown, name: string): number => {
  if (!Number.isSafeInteger(value) || (value as number) < 1) throw new TypeError(`${name} must be a positive safe integer`);
  return value as number;
};
const nonnegativeInt = (value: unknown, name: string): number => {
  if (!Number.isSafeInteger(value) || (value as number) < 0) throw new TypeError(`${name} must be a non-negative safe integer`);
  return value as number;
};
const textList = (value: unknown, name: string, allowEmpty = false): string[] => {
  if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) throw new TypeError(`${name} must be ${allowEmpty ? "a" : "a non-empty"} list`);
  const checked = value.map((item) => textValue(item, `${name}[]`));
  if (new Set(checked).size !== checked.length) throw new RangeError(`${name} must not contain duplicates`);
  return checked;
};

type Change = { action: "add" | "delete"; securityId: string };
type Snapshot = { snapshotId: string; revision: number; effectiveAt: string; effectiveInstant: number; availableAt: string; availableInstant: number; lastSequence: number; status: "active" | "cancelled"; securityIds: string[]; sourceId: string };
type EventVersion = { eventId: string; revision: number; sequence: number; eventType: string; effectiveOrder: number; effectiveAt: string; effectiveInstant: number; announcedAt: string; announcedInstant: number; availableAt: string; availableInstant: number; status: "active" | "cancelled"; changes: Change[]; sourceId: string };

const emptyResult = (status: string, indexId: string, effectiveAt: string, knownAt: string, diagnostics: string[], extra: Record<string, unknown> = {}) => ({
  status, indexId, effectiveAt, knownAt, securityIds: [], membershipIntervals: [], appliedEventVersions: [], cancelledEventIds: [], diagnostics, ...extra,
});

function validateSnapshots(raw: unknown, indexId: string): Snapshot[] {
  if (!Array.isArray(raw) || raw.length === 0) throw new TypeError("snapshots must be a non-empty list");
  const snapshots = raw.map((item: any, i): Snapshot => {
    if (!item || typeof item !== "object" || Array.isArray(item)) throw new TypeError(`snapshots[${i}] must be an object`);
    const p = `snapshots[${i}]`;
    if (textValue(item.indexId, `${p}.indexId`) !== indexId) throw new RangeError(`${p}.indexId does not match indexId`);
    const status = textValue(item.status, `${p}.status`);
    if (status !== "active" && status !== "cancelled") throw new RangeError(`${p}.status must be active or cancelled`);
    const effectiveAt = textValue(item.effectiveAt, `${p}.effectiveAt`);
    const availableAt = textValue(item.availableAt, `${p}.availableAt`);
    return { snapshotId: textValue(item.snapshotId, `${p}.snapshotId`), revision: positiveInt(item.revision, `${p}.revision`), effectiveAt, effectiveInstant: instant(effectiveAt, `${p}.effectiveAt`), availableAt, availableInstant: instant(availableAt, `${p}.availableAt`), lastSequence: nonnegativeInt(item.lastSequence, `${p}.lastSequence`), status, securityIds: textList(item.securityIds, `${p}.securityIds`, true), sourceId: textValue(item.sourceId, `${p}.sourceId`) };
  });
  for (const snapshotId of [...new Set(snapshots.map((s) => s.snapshotId))]) {
    const versions = snapshots.filter((s) => s.snapshotId === snapshotId).sort((a, b) => a.revision - b.revision);
    if (versions.some((v, i) => v.revision !== i + 1)) throw new RangeError(`snapshot ${snapshotId} revisions must be contiguous from 1`);
    if (new Set(versions.map((v) => `${v.effectiveAt}|${v.lastSequence}`)).size !== 1) throw new RangeError(`snapshot ${snapshotId} changes immutable anchor fields`);
    if (versions.some((v, i) => i > 0 && v.availableInstant <= versions[i - 1].availableInstant)) throw new RangeError(`snapshot ${snapshotId} availability must increase with revision`);
  }
  return snapshots;
}

function validateEvents(raw: unknown, indexId: string): EventVersion[] {
  if (!Array.isArray(raw)) throw new TypeError("events must be a list");
  const events = raw.map((item: any, i): EventVersion => {
    if (!item || typeof item !== "object" || Array.isArray(item)) throw new TypeError(`events[${i}] must be an object`);
    const p = `events[${i}]`;
    if (textValue(item.indexId, `${p}.indexId`) !== indexId) throw new RangeError(`${p}.indexId does not match indexId`);
    const status = textValue(item.status, `${p}.status`);
    if (status !== "active" && status !== "cancelled") throw new RangeError(`${p}.status must be active or cancelled`);
    const announcedAt = textValue(item.announcedAt, `${p}.announcedAt`); const announcedInstant = instant(announcedAt, `${p}.announcedAt`);
    const availableAt = textValue(item.availableAt, `${p}.availableAt`); const availableInstant = instant(availableAt, `${p}.availableAt`);
    if (availableInstant < announcedInstant) throw new RangeError(`${p}.availableAt cannot precede announcedAt`);
    if (!Array.isArray(item.changes)) throw new TypeError(`${p}.changes must be a list`);
    const changes = item.changes.map((c: any, j): Change => {
      if (!c || typeof c !== "object" || Array.isArray(c)) throw new TypeError(`${p}.changes[${j}] must be an object`);
      const action = textValue(c.action, `${p}.changes[${j}].action`);
      if (action !== "add" && action !== "delete") throw new RangeError(`${p}.changes[${j}].action must be add or delete`);
      return { action, securityId: textValue(c.securityId, `${p}.changes[${j}].securityId`) };
    });
    if (new Set(changes.map((c) => c.securityId)).size !== changes.length) throw new RangeError(`${p}.changes may target each stable securityId only once`);
    if (status === "cancelled" && changes.length) throw new RangeError(`${p}.changes must be empty for a cancellation`);
    const effectiveAt = textValue(item.effectiveAt, `${p}.effectiveAt`);
    const eventType = textValue(item.eventType, `${p}.eventType`);
    if (status === "active" && SUPPORTED.has(eventType)) {
      const adds = changes.filter((c) => c.action === "add").length, deletes = changes.filter((c) => c.action === "delete").length;
      const validShape: Record<string, boolean> = { add: adds === 1 && deletes === 0, delete: adds === 0 && deletes === 1, replace: adds === 1 && deletes === 1, rebalance: adds === 0 && deletes === 0, reconstitution: adds + deletes > 0, corporate_action: adds + deletes > 0 };
      if (!validShape[eventType]) throw new RangeError(`${p}.changes does not match eventType ${eventType}`);
    }
    return { eventId: textValue(item.eventId, `${p}.eventId`), revision: positiveInt(item.revision, `${p}.revision`), sequence: positiveInt(item.sequence, `${p}.sequence`), eventType, effectiveOrder: nonnegativeInt(item.effectiveOrder, `${p}.effectiveOrder`), effectiveAt, effectiveInstant: instant(effectiveAt, `${p}.effectiveAt`), announcedAt, announcedInstant, availableAt, availableInstant, status, changes, sourceId: textValue(item.sourceId, `${p}.sourceId`) };
  });
  for (const eventId of [...new Set(events.map((e) => e.eventId))]) {
    const versions = events.filter((e) => e.eventId === eventId).sort((a, b) => a.revision - b.revision);
    if (versions.some((v, i) => v.revision !== i + 1)) throw new RangeError(`event ${eventId} revisions must be contiguous from 1`);
    if (new Set(versions.map((v) => `${v.sequence}|${v.eventType}|${v.effectiveAt}|${v.effectiveOrder}`)).size !== 1) throw new RangeError(`event ${eventId} changes immutable identity fields`);
    if (versions.some((v, i) => i > 0 && v.availableInstant <= versions[i - 1].availableInstant)) throw new RangeError(`event ${eventId} availability must increase with revision`);
  }
  return events;
}

export function calculate(data: any) {
  if (!data || typeof data !== "object" || Array.isArray(data)) throw new TypeError("data must be an object");
  const indexId = textValue(data.indexId, "indexId"); const effectiveAt = textValue(data.effectiveAt, "effectiveAt"); const knownAt = textValue(data.knownAt, "knownAt");
  const effective = instant(effectiveAt, "effectiveAt"); const known = instant(knownAt, "knownAt");
  const snapshots = validateSnapshots(data.snapshots, indexId); const events = validateEvents(data.events, indexId);
  const eligibleSnapshots: Snapshot[] = [];
  for (const snapshotId of [...new Set(snapshots.map((s) => s.snapshotId))].sort()) {
    const eligible = snapshots.filter((s) => s.snapshotId === snapshotId && s.availableInstant <= known).sort((a, b) => b.revision - a.revision);
    if (eligible.length && eligible[0].status === "active" && eligible[0].effectiveInstant <= effective) eligibleSnapshots.push(eligible[0]);
  }
  if (!eligibleSnapshots.length) return emptyResult("incomplete", indexId, effectiveAt, knownAt, ["no active base snapshot was available by knownAt and effective by effectiveAt"]);
  const chronologicalAnchors = [...eligibleSnapshots].sort((a, b) => a.effectiveInstant - b.effectiveInstant || a.snapshotId.localeCompare(b.snapshotId)); let highestSequence = -1;
  for (const candidate of chronologicalAnchors) { if (candidate.lastSequence < highestSequence) return emptyResult("ambiguous", indexId, effectiveAt, knownAt, [`base snapshot ${candidate.snapshotId} regresses lastSequence relative to an earlier eligible snapshot`]); highestSequence = Math.max(highestSequence, candidate.lastSequence); }
  const anchorTime = Math.max(...eligibleSnapshots.map((s) => s.effectiveInstant)); const anchors = eligibleSnapshots.filter((s) => s.effectiveInstant === anchorTime);
  if (anchors.length !== 1) return emptyResult("ambiguous", indexId, effectiveAt, knownAt, ["multiple active base snapshots overlap at the latest effective instant"]);
  const anchor = anchors[0]; const selected: EventVersion[] = []; const cancelled: string[] = [];
  for (const eventId of [...new Set(events.map((e) => e.eventId))].sort()) {
    const all = events.filter((e) => e.eventId === eventId);
    const eligible = all.filter((e) => e.availableInstant <= known).sort((a, b) => b.revision - a.revision);
    if (eligible.length && eligible[0].sequence > anchor.lastSequence && eligible[0].effectiveInstant <= effective) { selected.push(eligible[0]); if (eligible[0].status === "cancelled") cancelled.push(eventId); }
  }
  const anchorConflict = selected.filter((e) => e.effectiveInstant <= anchor.effectiveInstant).map((e) => e.eventId).sort()[0];
  if (anchorConflict) return { ...emptyResult("ambiguous", indexId, effectiveAt, knownAt, [`event ${anchorConflict} is effective at or before the base snapshot but claims a later sequence`]), baseSnapshotId: anchor.snapshotId, baseRevision: anchor.revision };
  const active = selected.filter((e) => e.status === "active"); const unsupported = active.filter((e) => !SUPPORTED.has(e.eventType)).map((e) => e.eventId).sort();
  if (unsupported.length) return { ...emptyResult("unsupported", indexId, effectiveAt, knownAt, ["unsupported event types are present"], { unsupportedEventIds: unsupported }), baseSnapshotId: anchor.snapshotId, baseRevision: anchor.revision };
  const bySequence = new Map<number, EventVersion[]>(); selected.forEach((e) => bySequence.set(e.sequence, [...(bySequence.get(e.sequence) ?? []), e]));
  const duplicate = [...bySequence].filter(([, values]) => values.length > 1).map(([sequence]) => sequence).sort((a, b) => a - b)[0];
  if (duplicate !== undefined) return { ...emptyResult("ambiguous", indexId, effectiveAt, knownAt, [`multiple event identities claim sequence ${duplicate}`]), baseSnapshotId: anchor.snapshotId, baseRevision: anchor.revision };
  const relevantSequences = [...bySequence.keys()].sort((a, b) => a - b);
  if (relevantSequences.length) { const missing: number[] = []; for (let n = anchor.lastSequence + 1; n <= relevantSequences.at(-1)!; n++) if (!bySequence.has(n)) missing.push(n); if (missing.length) return { ...emptyResult("incomplete", indexId, effectiveAt, knownAt, [`event sequence gap at ${missing.join(", ")}; no gap was filled`]), baseSnapshotId: anchor.snapshotId, baseRevision: anchor.revision }; }
  const slots = new Map<string, Set<string>>(); active.forEach((e) => e.changes.forEach((c) => { const key = `${e.effectiveAt}|${e.effectiveOrder}|${c.securityId}`; slots.set(key, new Set([...(slots.get(key) ?? []), e.eventId])); }));
  const conflict = [...slots].filter(([, ids]) => ids.size > 1).map(([key]) => key).sort()[0];
  if (conflict) { const [at, order, securityId] = conflict.split("|"); return { ...emptyResult("ambiguous", indexId, effectiveAt, knownAt, [`conflicting same-instant evidence for ${securityId} at ${at}, order ${order}`]), baseSnapshotId: anchor.snapshotId, baseRevision: anchor.revision }; }
  const open = new Map(anchor.securityIds.map((id) => [id, anchor.effectiveAt])); const closed: any[] = []; const applied: string[] = [];
  const ordered = active.sort((a, b) => a.effectiveInstant - b.effectiveInstant || a.effectiveOrder - b.effectiveOrder || a.sequence - b.sequence || a.eventId.localeCompare(b.eventId));
  for (const event of ordered) {
    for (const change of event.changes.filter((c) => c.action === "delete").sort((a, b) => a.securityId.localeCompare(b.securityId))) { if (!open.has(change.securityId)) return { ...emptyResult("ambiguous", indexId, effectiveAt, knownAt, [`event ${event.eventId} deletes absent security ${change.securityId}`]), baseSnapshotId: anchor.snapshotId, baseRevision: anchor.revision }; closed.push({ securityId: change.securityId, validFrom: open.get(change.securityId), validTo: event.effectiveAt }); open.delete(change.securityId); }
    for (const change of event.changes.filter((c) => c.action === "add").sort((a, b) => a.securityId.localeCompare(b.securityId))) { if (open.has(change.securityId)) return { ...emptyResult("ambiguous", indexId, effectiveAt, knownAt, [`event ${event.eventId} adds existing security ${change.securityId}`]), baseSnapshotId: anchor.snapshotId, baseRevision: anchor.revision }; open.set(change.securityId, event.effectiveAt); }
    applied.push(`${event.eventId}@${event.revision}`);
  }
  const intervals = [...closed, ...[...open].map(([securityId, validFrom]) => ({ securityId, validFrom, validTo: null }))].sort((a, b) => a.securityId.localeCompare(b.securityId) || a.validFrom.localeCompare(b.validFrom) || (a.validTo ?? "").localeCompare(b.validTo ?? ""));
  return { status: "resolved", indexId, effectiveAt, knownAt, baseSnapshotId: anchor.snapshotId, baseRevision: anchor.revision, securityIds: [...open.keys()].sort(), membershipIntervals: intervals, appliedEventVersions: applied, cancelledEventIds: cancelled.sort(), diagnostics: [] };
}
Full-height labplaygroundOpen full screen