D46-F02-A02 / Complete engineering topic

Convertible Preferred Stock: The Two-Sided Dilution Test

Build the exact if-converted EPS candidate, handle timing correctly, and know when apparent dilution must be excluded.

D46 · EARNINGS AND PER-SHARE ANALYT…
D46-F02-A02Canonical / Tested / Open
D46 / D46-F02

A convertible preferred share is not “dilutive” merely because it can create ordinary shares. Conversion changes both sides of earnings per share: dividends previously withheld from ordinary shareholders return to the numerator, while new ordinary shares enter the denominator. The denominator can grow and EPS can still improve.

By the end of this article, you will be able to build the exact if-converted candidate for one fixed-share preferred series, classify it without rounding error, and handle mid-period issue or actual conversion without double counting.

Start with the counterfactual

Basic EPS describes the capital structure that actually existed. The if-converted method asks a controlled counterfactual:

What would continuing-operations EPS have been if this preferred series had converted at the beginning of the period, or on its issue date if later?

That counterfactual has two bridges:

  • Numerator: reverse the preferred dividend adjustment that Basic EPS deducted, then add separately evidenced pre-tax income/expense consequences and their tax effect.
  • Denominator: add the ordinary shares deliverable on conversion, weighted only for the period in which the instrument was a potential ordinary share.

This is the core treatment in IAS 33 and U.S. EPS guidance. It is also why the test must be performed as a ratio, not as a share-count checklist.

A bridge from Basic EPS to the if-converted candidate

The exact model

Let NBN_B and DBD_B be the continuing-operations Basic numerator and weighted-average denominator. Let PP be the period-aligned preferred dividend deducted from Basic EPS, OpreO_{pre} another signed pre-tax conversion adjustment, TOT_O its signed tax effect, QQ the fixed ordinary shares delivered on conversion, and tt the exact fraction of the reporting period for which the preferred instrument was a potential ordinary share.

ΔN=P+Opre+TO\Delta N=P+O_{pre}+T_O ΔD=Qt\Delta D=Qt EPSB=NBDBEPSC=NB+ΔNDB+ΔDEPS_B=\frac{N_B}{D_B} \qquad EPS_C=\frac{N_B+\Delta N}{D_B+\Delta D}

Under IAS 33, include the series only if:

EPSC<EPSBEPS_C<EPS_B

Notice the numerical comparison. In a loss period, moving from 1.00-1.00 to 0.71-0.71 is an increase, even though the absolute loss is smaller. The assumed conversion is therefore antidilutive.

U.S. GAAP adds a hard control: when continuing operations available to common stockholders is a loss, all potential common shares are excluded. IAS 33 retains the signed comparison. The two frameworks can therefore differ in the unusual case where other conversion consequences make the candidate loss per share more negative.

For a single series with positive incremental shares, an equivalent diagnostic is the incremental EPS:

EPSI=ΔNΔDEPS_I=\frac{\Delta N}{\Delta D}

If EPSI<EPSBEPS_I<EPS_B, the candidate is dilutive under the signed comparison. The reference implementation calculates this diagnostic, applies the framework loss control, and otherwise classifies from the complete candidate ratio.

Rendering system map…

A worked example you can audit in your head

Suppose a company reports:

  • USD 90 million of continuing-operations earnings available to ordinary holders;
  • 30 million weighted-average ordinary shares;
  • USD 6 million of preferred dividends deducted in arriving at that numerator;
  • 4 million ordinary shares on conversion;
  • full-year potential-share exposure.

Basic EPS is:

90/30=3.0090/30=3.00

The preferred dividend comes back, and the conversion shares enter:

(90+6)/(30+4)=96/34=48/172.82(90+6)/(30+4)=96/34=48/17\approx2.82

The candidate is below USD 3.00, so the series is dilutive. The incremental return is 6/4=1.506/4=1.50 per new share—below the existing USD 3.00 per share—so the new shares pull the average down.

Now change only the addback to USD 10 million and the conversion shares to 2 million:

(90+10)/(30+2)=100/32=3.125(90+10)/(30+2)=100/32=3.125

The instrument creates shares, but it improves EPS. It must be excluded.

Try all four scenarios in the interactive preference-dilution lab.

Timing: weight the shares, not the dividend twice

The reporting period is inclusive. Internally, the implementation turns its final date into an exclusive boundary one day later. Potential-share exposure starts at the later of period start and issue date. It stops at the earliest of:

  • period end plus one day;
  • actual conversion date;
  • lapse, redemption, or extinguishment date.

An instrument issued on 1 July in a 365-day year contributes 184/365184/365 of its conversion shares. If it converts on 1 July, potential exposure runs from 1 January through 30 June: 181/365181/365. Actual shares from 1 July onward belong in the Basic weighted-average denominator upstream.

The preferred-dividend input is different. It is the amount actually deducted from the Basic numerator for the relevant pre-conversion or post-issue interval. It is already period-aligned. Multiplying it by 184/365184/365 again would double-weight it. It is also already on the Basic EPS tax basis. Do not apply a generic tax rate to it. If assumed conversion changes another income or expense item, provide that pre-tax effect and its evidenced tax consequence as separate inputs.

What the implementation refuses to guess

Real instruments contain traps: participating rights, variable conversion rates, make-whole provisions, induced conversion, contingencies, and retrospective share adjustments. A trustworthy calculator needs hard boundaries.

The implementation rejects:

  • participating preferred stock, because the two-class method may have to be compared with if-converted;
  • partial or induced conversion that has not been decomposed into affected and remaining tranches;
  • incomplete contingency analysis under the declared framework;
  • unresolved variable or multiple conversion rates;
  • missing legal-term sources or incomplete “holder-advantageous terms” selection;
  • incomplete Basic numerator, Basic denominator, dividend, or restatement inputs;
  • instruments with no potential-share exposure in the period.

This is not a partial implementation. It is a complete implementation of a declared canonical case, with explicit routes for materially different accounting algorithms. A silent approximation would be less complete because the output could not be defended.

Exact arithmetic is part of accounting correctness

The midyear fraction 184/365184/365 generally produces nonterminating decimal shares. Binary floating point adds another approximation. If classification is based on rounded EPS, an equality or near-equality case can flip.

Both reference implementations parse decimal strings into integers and powers of ten, reduce all calculations to rational numbers, compare cross-products exactly, and round only display fields. The output preserves values such as:

  • time_weight_exact: "184/365"
  • candidate_eps_exact: "48/17"
  • candidate_eps: "2.82"

The Python source is convertible_preference.py; the TypeScript source is convertiblePreference.ts. Both consume the same fixture file.

Tests that matter

The fixture suite does more than prove the happy path:

CaseExpected resultWhy it matters
Full-year canonicalDilutive, 48/1748/17Numerator/denominator bridge
Higher addback, fewer sharesAntidilutive, 25/825/8Shares alone do not prove dilution
Ordinary IFRS loss caseAntidilutive, 12/17-12/17Correct signed comparison
IFRS/U.S. GAAP loss edgeInclude under IAS 33; exclude under U.S. GAAPFramework control is explicit
Explicit tax-effect caseDilutive, 99/3499/34No hidden generic tax rate
Exact equalityExcluded, 3/13/1“No dilution” means antidilutive
1 July issueWeight 184/365184/365Inclusive calendar convention
1 July actual conversionWeight 181/365181/365Prevent denominator double count

Negative tests then force rejection of malformed decimals, impossible dates, missing fields, nonpositive share amounts, incomplete gates, unresolved terms, and participating securities. Python and TypeScript assert the same expected exact strings, making cross-language parity observable rather than assumed.

A filed example—and the boundary of what it proves

Celsius Holdings' Form 10-Q for the quarter ended 31 March 2026 says its preferred stock is participating and that diluted EPS uses the more dilutive of the two-class and if-converted methods. The filed EPS table reports tabular dollars and shares in thousands: 85,080 as net income attributable to common stockholders, 85,184 after assumed conversions, 257,012 basic weighted shares, and 259,764 diluted weighted shares. It also reports 33.4 million excluded potential shares, primarily preferred conversion shares with a remainder from performance share units.

Those are filed facts. They are not the synthetic 90 / 30 example above, and the filing does not isolate the preferred series' incremental shares from other dilutive securities. A one-series if-converted candidate cannot be reconstructed defensibly from this note alone. Its value here is historical method evidence and a practical lesson: stop when the filing does not expose the inputs required by the algorithm.

Where this result goes next

This topic produces a standalone candidate, not the final diluted EPS for an entity with many instruments. Each series is first tested separately. Dilutive series are then ordered from most to least dilutive and accumulated without allowing a later instrument to improve the running EPS. Participating securities take a separate two-class path before method selection.

That separation keeps responsibilities clean:

  • this algorithm answers “what is this preferred series’ exact candidate?”;
  • the two-class algorithm resolves participating allocation;
  • the ordering algorithm combines multiple eligible instruments;
  • the final reconciliation explains Basic-to-diluted movement.

Summary

Convertible-preference dilution is a two-sided, framework-controlled counterfactual. Reverse the exact Basic-basis preferred dividend adjustment, preserve separate evidence for other pre-tax and tax effects, add only the correctly weighted conversion shares, and apply the declared continuing-operations control. That discipline survives profit, loss, timing, precision, and provenance edge cases.

If-converted preference decision flow

Rendering system map…

The branches deliberately place policy and data completeness before computation. Neither rounding nor an increase in share count is a classification rule.

References4 primary sources and evidence notes

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

Primary accounting guidance governs the formulas and timing rules. Filed-company material is included only as evidence of current application, not as authority.

R01 — IAS 33 Earnings per Share

  • Organization: IFRS Foundation / International Accounting Standards Board
  • Source type: issued accounting standard
  • Version: issued standards HTML, 2023 compilation
  • URL: https://www.ifrs.org/content/dam/ifrs/publications/html-standards/english/2023/issued/ias33.html
  • Accessed: 2026-07-23
  • Jurisdiction: IFRS
  • Supports: paragraphs 31–36 (numerator adjustment and assumed conversion); 38–44 (time weighting, holder-advantageous terms, antidilution and sequencing); 49–51 (convertible instruments, convertible preference tests, partial conversion).
  • Limitation: local endorsement and later amendments must be checked for a live filing.

R02 — FASB Statement No. 128, Earnings per Share

  • Organization: Financial Accounting Standards Board
  • Source type: accounting standard PDF
  • Publication date: February 1997
  • URL: https://storage.fasb.org/aop_fas128.pdf
  • Accessed: 2026-07-23
  • Jurisdiction: United States
  • Supports: paragraphs 13–16 (antidilution and continuing-operations control); paragraphs 26–28 (preferred-dividend addback and assumed-conversion timing).
  • Limitation: later Codification amendments supersede presentation in the original statement.

R03 — ASU 2020-06, Debt—Debt with Conversion and Other Options and Derivatives and Hedging

  • Organization: Financial Accounting Standards Board
  • Source type: Accounting Standards Update
  • Publication date: August 2020
  • URL: https://storage.fasb.org/ASU_2020-06.pdf
  • Accessed: 2026-07-23
  • Jurisdiction: United States
  • Supports: current amendments to ASC 260-10-45-40 and the if-converted treatment of convertible instruments, including convertible preferred-stock dividend addbacks.
  • Limitation: the FASB Accounting Standards Codification remains the authoritative US GAAP source.

R04 — Celsius Holdings, Inc. 2026 first-quarter EPS note

  • Organization: Celsius Holdings, Inc.; filed with the U.S. Securities and Exchange Commission
  • Source type: Inline XBRL filing exhibit
  • Reporting period: three months ended 2026-03-31
  • Filed: 2026-05-07
  • URL: https://www.sec.gov/Archives/edgar/data/1341766/000134176626000039/R12.htm
  • Accessed: 2026-07-23
  • Jurisdiction: US SEC filer
  • Supports: an issuer example in which participating convertible preferred stock is evaluated using the more dilutive of the two-class and if-converted methods; filed table values, in thousands, of 85,080 / 85,184 for the common-stockholder and assumed-conversion numerators, 257,012 / 259,764 basic/diluted weighted shares, and 33.4 million excluded potential shares described as primarily preferred conversion shares with a remainder from performance share units.
  • Limitation: entity-specific disclosure, not normative accounting guidance. It does not isolate the one preferred series' incremental shares or every numerator bridge, so it cannot support a reconstructed one-series candidate.

Research reconciliation

IFRS and U.S. GAAP agree on the central mechanics: add back the Basic-basis convertible-preference dividend adjustment, reflect evidenced income/expense and tax consequences, add conversion shares from the beginning of the period or issue date if later, and test antidilution using continuing operations. They are not identical in every edge case. This package makes the U.S. GAAP continuing-operations loss exclusion explicit, while IAS 33 applies its signed dilution definition. Participating-security method selection, contingencies, partial conversions, variable terms, and sequencing remain separate policy or aggregation decisions rather than hidden assumptions.

convertiblePreference.ts
/** Exact if-converted dilution test for one convertible-preference series. */

export type AccountingFramework = "IFRS" | "US_GAAP" | "OTHER_DECLARED";
export type RoundingMode = "half_even" | "half_up";

export interface ConvertiblePreferenceInput {
  entity_id: string;
  instrument_id: string;
  period_start: string;
  period_end: string;
  as_of: string;
  accounting_framework: AccountingFramework;
  loss_control_rule: "candidate_comparison" | "exclude_all_when_control_loss";
  currency: string;
  basic_control_numerator: string;
  basic_weighted_average_shares: string;
  earnings_scale: string;
  share_scale: string;
  preferred_dividend_adjustment_deducted_from_basic: string;
  other_pre_tax_income_adjustment_if_converted: string;
  income_tax_effect_of_other_adjustment_if_converted: string;
  fixed_ordinary_shares_on_conversion: string;
  issue_date: string;
  actual_conversion_date: string | null;
  lapse_redemption_or_extinguishment_date: string | null;
  numerator_policy: string;
  tax_effect_policy: string;
  denominator_policy: string;
  framework_policy_source: string;
  day_count_policy: "ACTUAL_DAYS_IN_PERIOD";
  basic_components_complete: boolean;
  continuing_operations_control_complete: boolean;
  basic_preference_adjustment_complete: boolean;
  share_basis_restatement_complete: boolean;
  dividend_adjustment_complete: boolean;
  dividend_period_aligned: boolean;
  conversion_terms_are_fixed: boolean;
  conversion_scope: "entire_series";
  holder_advantageous_terms_selected: boolean;
  conversion_terms_source: string;
  terms_restatement_complete: boolean;
  conversion_is_contingent: boolean;
  contingency_assessment_complete: boolean;
  contingency_includable_at_period_end: boolean;
  contingency_policy: string;
  is_participating: boolean;
  instrument_is_final: boolean;
  is_final: boolean;
  eps_decimal_places: number;
  share_decimal_places: number;
  rounding_mode: RoundingMode;
}

export interface ConvertiblePreferenceResult {
  metric: "if_converted_convertible_preference_dilution";
  entity_id: string;
  instrument_id: string;
  period_start: string;
  period_end: string;
  accounting_framework: AccountingFramework;
  loss_control_rule: "candidate_comparison" | "exclude_all_when_control_loss";
  currency: string;
  basic_control_numerator_base: string;
  basic_weighted_average_shares_base: string;
  preferred_dividend_addback_base: string;
  other_pre_tax_income_adjustment_base: string;
  income_tax_effect_of_other_adjustment_base: string;
  net_numerator_adjustment_base: string;
  fixed_conversion_shares_base: string;
  exposure_start: string;
  exposure_end_exclusive: string;
  exposure_days: number;
  period_days: number;
  time_weight_exact: string;
  weighted_incremental_shares_exact: string;
  weighted_incremental_shares: string;
  basic_eps_exact: string;
  basic_eps: string;
  incremental_eps_exact: string;
  incremental_eps: string;
  candidate_eps_exact: string;
  candidate_eps: string;
  classification: "dilutive" | "antidilutive";
  include_in_standalone_candidate: boolean;
  reason_code: "candidate_below_control" | "candidate_not_below_control" |
    "framework_loss_control_exclusion";
  eps_decimal_places: number;
  share_decimal_places: number;
  rounding_mode: RoundingMode;
  rounding_adjusted: boolean;
  status: "final" | "provisional";
  is_final: boolean;
}

interface Rational { n: bigint; d: bigint }

const DECIMAL_RE = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const RFC3339_RE =
  /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
const REQUIRED_FIELDS: (keyof ConvertiblePreferenceInput)[] = [
  "entity_id", "instrument_id", "period_start", "period_end", "as_of",
  "accounting_framework", "currency", "basic_control_numerator",
  "basic_weighted_average_shares", "earnings_scale", "share_scale",
  "preferred_dividend_adjustment_deducted_from_basic",
  "other_pre_tax_income_adjustment_if_converted",
  "income_tax_effect_of_other_adjustment_if_converted",
  "fixed_ordinary_shares_on_conversion", "issue_date",
  "actual_conversion_date", "lapse_redemption_or_extinguishment_date",
  "numerator_policy", "tax_effect_policy", "denominator_policy",
  "framework_policy_source", "loss_control_rule", "day_count_policy",
  "basic_components_complete", "continuing_operations_control_complete",
  "basic_preference_adjustment_complete",
  "share_basis_restatement_complete", "dividend_adjustment_complete",
  "dividend_period_aligned", "conversion_terms_are_fixed", "conversion_scope",
  "holder_advantageous_terms_selected", "conversion_terms_source",
  "terms_restatement_complete", "conversion_is_contingent",
  "contingency_assessment_complete", "contingency_includable_at_period_end",
  "contingency_policy", "is_participating", "instrument_is_final",
  "is_final", "eps_decimal_places", "share_decimal_places", "rounding_mode",
];

export class ConvertiblePreferenceValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ConvertiblePreferenceValidationError";
  }
}

function abs(value: bigint): bigint { return value < 0n ? -value : value; }
function gcd(a: bigint, b: bigint): bigint {
  a = abs(a); b = abs(b);
  while (b !== 0n) [a, b] = [b, a % b];
  return a;
}
function rational(n: bigint, d = 1n): Rational {
  if (d === 0n) throw new ConvertiblePreferenceValidationError("division by zero");
  if (d < 0n) { n = -n; d = -d; }
  const g = gcd(n, d);
  return {n: n / g, d: d / g};
}
function add(a: Rational, b: Rational): Rational {
  return rational(a.n * b.d + b.n * a.d, a.d * b.d);
}
function mul(a: Rational, b: Rational): Rational {
  return rational(a.n * b.n, a.d * b.d);
}
function div(a: Rational, b: Rational): Rational {
  return rational(a.n * b.d, a.d * b.n);
}
function less(a: Rational, b: Rational): boolean {
  return a.n * b.d < b.n * a.d;
}
function fractionString(value: Rational): string {
  return `${value.n}/${value.d}`;
}
function decimal(value: unknown, field: string): Rational {
  if (typeof value !== "string" || !DECIMAL_RE.test(value)) {
    throw new ConvertiblePreferenceValidationError(
      `${field} must be a canonical decimal string without exponent notation`,
    );
  }
  const unsigned = value.startsWith("-") ? value.slice(1) : value;
  const [whole, fraction = ""] = unsigned.split(".");
  if (whole.length + fraction.length > 30 || fraction.length > 12) {
    throw new ConvertiblePreferenceValidationError(
      `${field} exceeds 30 digits or 12 fraction digits`,
    );
  }
  return rational(BigInt(value.replace(".", "")), 10n ** BigInt(fraction.length));
}
function normalDecimal(value: Rational): string {
  let denominator = value.d;
  let twos = 0, fives = 0;
  while (denominator % 2n === 0n) { denominator /= 2n; twos++; }
  while (denominator % 5n === 0n) { denominator /= 5n; fives++; }
  if (denominator !== 1n) return fractionString(value);
  const places = Math.max(twos, fives);
  let scaled = value.n *
    2n ** BigInt(places - twos) *
    5n ** BigInt(places - fives);
  const negative = scaled < 0n;
  if (negative) scaled = -scaled;
  let digits = scaled.toString().padStart(places + 1, "0");
  if (places > 0) {
    const split = digits.length - places;
    digits = `${digits.slice(0, split)}.${digits.slice(split)}`
      .replace(/0+$/, "").replace(/\.$/, "");
  }
  return negative && digits !== "0" ? `-${digits}` : digits;
}
function round(value: Rational, places: number, mode: RoundingMode): [string, boolean] {
  const negative = value.n < 0n;
  const scaled = abs(value.n) * 10n ** BigInt(places);
  let quotient = scaled / value.d;
  const remainder = scaled % value.d;
  const comparison = remainder * 2n - value.d;
  if (comparison > 0n ||
      (comparison === 0n &&
       (mode === "half_up" || (mode === "half_even" && quotient % 2n === 1n)))) {
    quotient++;
  }
  let digits = quotient.toString().padStart(places + 1, "0");
  if (places > 0) {
    const split = digits.length - places;
    digits = `${digits.slice(0, split)}.${digits.slice(split)}`;
  }
  if (negative && quotient !== 0n) digits = `-${digits}`;
  return [digits, remainder !== 0n];
}
function text(value: unknown, field: string): string {
  if (typeof value !== "string" || value.trim() === "") {
    throw new ConvertiblePreferenceValidationError(`${field} must be non-empty text`);
  }
  return value;
}
function validDate(value: unknown, field: string, nullable = false): string | null {
  if (nullable && value === null) return null;
  if (typeof value !== "string" || !DATE_RE.test(value)) {
    throw new ConvertiblePreferenceValidationError(
      `${field} must use YYYY-MM-DD${nullable ? " or null" : ""}`,
    );
  }
  if (Number(value.slice(0, 4)) < 1) {
    throw new ConvertiblePreferenceValidationError(`${field} must use a year from 0001 to 9999`);
  }
  const parsed = new Date(`${value}T00:00:00Z`);
  if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value) {
    throw new ConvertiblePreferenceValidationError(`${field} must be a real date`);
  }
  return value;
}
function dayNumber(value: string): number {
  return Math.floor(Date.parse(`${value}T00:00:00Z`) / 86400000);
}
function dateFromDay(value: number): string {
  return new Date(value * 86400000).toISOString().slice(0, 10);
}

export function calculateConvertiblePreferenceDilution(
  input: ConvertiblePreferenceInput,
): ConvertiblePreferenceResult {
  if (input === null || typeof input !== "object" || Array.isArray(input)) {
    throw new ConvertiblePreferenceValidationError("input must be an object");
  }
  for (const field of REQUIRED_FIELDS) {
    if (!(field in input)) {
      throw new ConvertiblePreferenceValidationError(`missing required field: ${field}`);
    }
  }
  const entityId = text(input.entity_id, "entity_id");
  const instrumentId = text(input.instrument_id, "instrument_id");
  const start = validDate(input.period_start, "period_start")!;
  const end = validDate(input.period_end, "period_end")!;
  const issue = validDate(input.issue_date, "issue_date")!;
  const conversion = validDate(input.actual_conversion_date, "actual_conversion_date", true);
  const lapse = validDate(
    input.lapse_redemption_or_extinguishment_date,
    "lapse_redemption_or_extinguishment_date",
    true,
  );
  if (start > end) {
    throw new ConvertiblePreferenceValidationError("period_start must not be after period_end");
  }
  if (end === "9999-12-31") {
    throw new ConvertiblePreferenceValidationError(
      "period_end must allow an exclusive next-day boundary",
    );
  }
  if (issue > end) {
    throw new ConvertiblePreferenceValidationError("issue_date must not be after period_end");
  }
  for (const [event, field] of [
    [conversion, "actual_conversion_date"],
    [lapse, "lapse_redemption_or_extinguishment_date"],
  ] as const) {
    if (event !== null && event < issue) {
      throw new ConvertiblePreferenceValidationError(`${field} must not be before issue_date`);
    }
  }
  if (!RFC3339_RE.test(input.as_of) || Number.isNaN(Date.parse(input.as_of))) {
    throw new ConvertiblePreferenceValidationError(
      "as_of must be a real RFC 3339 timestamp with an explicit offset",
    );
  }
  if (!["IFRS", "US_GAAP", "OTHER_DECLARED"].includes(input.accounting_framework)) {
    throw new ConvertiblePreferenceValidationError("accounting_framework is not supported");
  }
  if (!/^[A-Z]{3}$/.test(input.currency)) {
    throw new ConvertiblePreferenceValidationError(
      "currency must be a three-letter uppercase code",
    );
  }
  text(input.numerator_policy, "numerator_policy");
  text(input.tax_effect_policy, "tax_effect_policy");
  text(input.denominator_policy, "denominator_policy");
  text(input.framework_policy_source, "framework_policy_source");
  text(input.contingency_policy, "contingency_policy");
  text(input.conversion_terms_source, "conversion_terms_source");
  if (input.day_count_policy !== "ACTUAL_DAYS_IN_PERIOD") {
    throw new ConvertiblePreferenceValidationError(
      "day_count_policy must be ACTUAL_DAYS_IN_PERIOD",
    );
  }
  const trueFlags = [
    "basic_components_complete", "continuing_operations_control_complete",
    "basic_preference_adjustment_complete",
    "share_basis_restatement_complete", "dividend_adjustment_complete",
    "dividend_period_aligned", "conversion_terms_are_fixed",
    "holder_advantageous_terms_selected", "terms_restatement_complete",
    "contingency_assessment_complete", "contingency_includable_at_period_end",
  ] as const;
  for (const field of [
    ...trueFlags, "conversion_is_contingent", "is_participating",
    "instrument_is_final", "is_final",
  ] as const) {
    if (typeof input[field] !== "boolean") {
      throw new ConvertiblePreferenceValidationError(`${field} must be boolean`);
    }
  }
  for (const field of trueFlags) {
    if (!input[field]) {
      throw new ConvertiblePreferenceValidationError(`${field} must be true`);
    }
  }
  if (input.is_participating) {
    throw new ConvertiblePreferenceValidationError(
      "participating preferred stock requires D46-F02-A05 two-class method resolution",
    );
  }
  if (input.conversion_scope !== "entire_series") {
    throw new ConvertiblePreferenceValidationError(
      "conversion_scope must be entire_series; split partial conversions into tranches",
    );
  }
  if (!["candidate_comparison", "exclude_all_when_control_loss"].includes(
    input.loss_control_rule,
  )) {
    throw new ConvertiblePreferenceValidationError("loss_control_rule is not supported");
  }
  const expectedLossRule = input.accounting_framework === "IFRS"
    ? "candidate_comparison"
    : input.accounting_framework === "US_GAAP"
      ? "exclude_all_when_control_loss"
      : null;
  if (expectedLossRule !== null && input.loss_control_rule !== expectedLossRule) {
    throw new ConvertiblePreferenceValidationError(
      "loss_control_rule conflicts with accounting_framework",
    );
  }
  if (!Number.isInteger(input.eps_decimal_places) ||
      input.eps_decimal_places < 0 || input.eps_decimal_places > 12) {
    throw new ConvertiblePreferenceValidationError(
      "eps_decimal_places must be an integer from 0 to 12",
    );
  }
  if (!Number.isInteger(input.share_decimal_places) ||
      input.share_decimal_places < 0 || input.share_decimal_places > 12) {
    throw new ConvertiblePreferenceValidationError(
      "share_decimal_places must be an integer from 0 to 12",
    );
  }
  if (!["half_even", "half_up"].includes(input.rounding_mode)) {
    throw new ConvertiblePreferenceValidationError(
      "rounding_mode must be half_even or half_up",
    );
  }

  const basicReported = decimal(input.basic_control_numerator, "basic_control_numerator");
  const basicSharesReported = decimal(
    input.basic_weighted_average_shares, "basic_weighted_average_shares",
  );
  const earningsScale = decimal(input.earnings_scale, "earnings_scale");
  const shareScale = decimal(input.share_scale, "share_scale");
  const dividendReported = decimal(
    input.preferred_dividend_adjustment_deducted_from_basic,
    "preferred_dividend_adjustment_deducted_from_basic",
  );
  const otherPreTaxReported = decimal(
    input.other_pre_tax_income_adjustment_if_converted,
    "other_pre_tax_income_adjustment_if_converted",
  );
  const otherTaxEffectReported = decimal(
    input.income_tax_effect_of_other_adjustment_if_converted,
    "income_tax_effect_of_other_adjustment_if_converted",
  );
  const conversionSharesReported = decimal(
    input.fixed_ordinary_shares_on_conversion,
    "fixed_ordinary_shares_on_conversion",
  );
  if (basicSharesReported.n <= 0n || earningsScale.n <= 0n || shareScale.n <= 0n) {
    throw new ConvertiblePreferenceValidationError(
      "basic shares and both unit scales must be positive",
    );
  }
  if (dividendReported.n < 0n) {
    throw new ConvertiblePreferenceValidationError(
      "preferred_dividend_adjustment_deducted_from_basic must be non-negative",
    );
  }
  if (conversionSharesReported.n <= 0n) {
    throw new ConvertiblePreferenceValidationError(
      "fixed_ordinary_shares_on_conversion must be positive",
    );
  }

  const startDay = dayNumber(start);
  const endExclusiveDay = dayNumber(end) + 1;
  const exposureStartDay = Math.max(startDay, dayNumber(issue));
  const endCandidates = [endExclusiveDay];
  if (conversion !== null) endCandidates.push(dayNumber(conversion));
  if (lapse !== null) endCandidates.push(dayNumber(lapse));
  const exposureEndDay = Math.min(...endCandidates);
  const exposureDays = exposureEndDay - exposureStartDay;
  const periodDays = endExclusiveDay - startDay;
  if (exposureDays <= 0) {
    throw new ConvertiblePreferenceValidationError(
      "instrument has no potential-share exposure in the period",
    );
  }

  const basicNumerator = mul(basicReported, earningsScale);
  const basicShares = mul(basicSharesReported, shareScale);
  const dividend = mul(dividendReported, earningsScale);
  const otherPreTax = mul(otherPreTaxReported, earningsScale);
  const otherTaxEffect = mul(otherTaxEffectReported, earningsScale);
  const netAdjustment = add(add(dividend, otherPreTax), otherTaxEffect);
  const fixedShares = mul(conversionSharesReported, shareScale);
  const weight = rational(BigInt(exposureDays), BigInt(periodDays));
  const incrementalShares = mul(fixedShares, weight);
  const basicEps = div(basicNumerator, basicShares);
  const incrementalEps = div(netAdjustment, incrementalShares);
  const candidateEps = div(
    add(basicNumerator, netAdjustment),
    add(basicShares, incrementalShares),
  );
  const lossControlExclusion =
    input.loss_control_rule === "exclude_all_when_control_loss" &&
    basicNumerator.n < 0n;
  const dilutive = !lossControlExclusion && less(candidateEps, basicEps);
  const [basicDisplay, basicAdjusted] = round(
    basicEps, input.eps_decimal_places, input.rounding_mode,
  );
  const [incrementalDisplay, incrementalAdjusted] = round(
    incrementalEps, input.eps_decimal_places, input.rounding_mode,
  );
  const [candidateDisplay, candidateAdjusted] = round(
    candidateEps, input.eps_decimal_places, input.rounding_mode,
  );
  const [weightedDisplay, weightedAdjusted] = round(
    incrementalShares, input.share_decimal_places, input.rounding_mode,
  );
  const isFinal = input.is_final && input.instrument_is_final;

  return {
    metric: "if_converted_convertible_preference_dilution",
    entity_id: entityId,
    instrument_id: instrumentId,
    period_start: start,
    period_end: end,
    accounting_framework: input.accounting_framework,
    loss_control_rule: input.loss_control_rule,
    currency: input.currency,
    basic_control_numerator_base: normalDecimal(basicNumerator),
    basic_weighted_average_shares_base: normalDecimal(basicShares),
    preferred_dividend_addback_base: normalDecimal(dividend),
    other_pre_tax_income_adjustment_base: normalDecimal(otherPreTax),
    income_tax_effect_of_other_adjustment_base: normalDecimal(otherTaxEffect),
    net_numerator_adjustment_base: normalDecimal(netAdjustment),
    fixed_conversion_shares_base: normalDecimal(fixedShares),
    exposure_start: dateFromDay(exposureStartDay),
    exposure_end_exclusive: dateFromDay(exposureEndDay),
    exposure_days: exposureDays,
    period_days: periodDays,
    time_weight_exact: fractionString(weight),
    weighted_incremental_shares_exact: fractionString(incrementalShares),
    weighted_incremental_shares: weightedDisplay,
    basic_eps_exact: fractionString(basicEps),
    basic_eps: basicDisplay,
    incremental_eps_exact: fractionString(incrementalEps),
    incremental_eps: incrementalDisplay,
    candidate_eps_exact: fractionString(candidateEps),
    candidate_eps: candidateDisplay,
    classification: dilutive ? "dilutive" : "antidilutive",
    include_in_standalone_candidate: dilutive,
    reason_code: lossControlExclusion
      ? "framework_loss_control_exclusion"
      : dilutive
        ? "candidate_below_control"
        : "candidate_not_below_control",
    eps_decimal_places: input.eps_decimal_places,
    share_decimal_places: input.share_decimal_places,
    rounding_mode: input.rounding_mode,
    rounding_adjusted:
      basicAdjusted || incrementalAdjusted || candidateAdjusted || weightedAdjusted,
    status: isFinal ? "final" : "provisional",
    is_final: isFinal,
  };
}
Full-height labpreference dilution labOpen full screen