Library/Fundamental Analysis and Valuation/Integrated Equity Scoring/Distress-Model Ensemble

D18-F09-A09 / Complete engineering topic

Distress-Model Ensemble

Aggregate supplied model probabilities with explicit weights, dispersion, and disagreement diagnostics instead of hiding model conflict inside one number.

Distress-Model Ensemble maps point-in-time fundamentals to an auditable stock-scoring decisionD18 / D18-F09

Aggregate supplied model probabilities with explicit weights, dispersion, and disagreement diagnostics instead of hiding model conflict inside one number.

The decision this tutorial makes visible

Distress-Model Ensemble matters because an integrated stock screen is only useful when every input, peer, model variant, weight, and abstention reason can be audited at the same knowledge timestamp.

The precise question is: How can eligible distress-model probabilities be combined while preserving disagreement and calibration limits?

A practitioner needs to know what the diagnostic does and does not justify. A builder needs a contract that can be reproduced from the same point-in-time inputs in Python, TypeScript, a visual, and a browser lab.

Intuition before notation

A mean is easy to audit, while the disagreement range warns that a single headline can conceal materially different model views.

The result depends on the declared algorithm scope, input clocks, units, equality and rounding policies, and unsupported-state treatment. Change one of those and the output represents a different decision even when its field name is unchanged.

Scope and nearby methods

The canonical ensemble is a weighted arithmetic mean of eligible 0–1 distress probabilities. It reports weighted standard deviation and max–min disagreement but does not recalibrate the inputs.

VariantDefinitionBest useMain limitation
Weighted arithmetic meanTransparent mean with disagreementExplainable review ensembleNo recalibration
Logit poolingAverage logits then invertProbability aggregation researchDifferent behavior near 0/1
Stacked calibrated ensembleLearn weights and calibrationValidated out-of-sample researchNot this package

What is sourced, selected, synthetic, and derived

RoleMaterial claimEvidenceBoundary
Sourced factThe named accounting, statistical, or historical model context is limited to the cited source role.Altman (1968)The source does not validate the synthetic fixture or current calibration.
Author-derived calculationp_ens = Σw_i p_i / Σw_i; dispersion = max(p_i)-min(p_i); agreement=1-dispersioncanonical-input.json, expected-output.json, and independent arithmeticSynthetic teaching record under this package contract.
Implementation choiceWeights, caps, thresholds, peer rules, and abstention gates are explicit package choices.Frozen definition contract and data contractNot a universal rating, probability, or investment conclusion.

The authoritative sources support only the exact facts named in the claim ledger. They do not certify the synthetic numbers in this tutorial. The repository fixture is deliberately invented for auditability, and the displayed output is author-derived under the selected implementation choice.

Formula, symbols, and numerical policy

Plain text
p_ens = Σw_i p_i / Σw_i; dispersion = max(p_i)-min(p_i); agreement=1-dispersion
SymbolMeaningUnitPolicy
p_imodel probability0..1Supplied and eligible
w_imodel weightsharePositive package choice
p_ensensemble mean0..1No recalibration
  • Use full floating-point precision and round only for display.
  • Scores are bounded to 0–100 only where the input contract explicitly says so.
  • Reject missing, nonfinite, malformed, mixed-period, unsupported, and contradictory records rather than manufacturing defaults.
  • Keep the raw components, weights, thresholds, clock, and diagnostic state with every result.

Read the formula in the same order as the algorithm. Validate identity, ordering, units, and supported state first. Apply the selected equality and window rules second. Calculate with unrounded numeric values. Round only at the declared presentation boundary, and preserve null as a diagnostic rather than coercing it to zero.

Build the algorithm

  1. Filter model outputs by the applicability router.
  2. Validate each eligible probability and positive weight.
  3. Calculate weighted mean and weighted standard deviation.
  4. Report disagreement range and interpretation ceiling.

Production-minded operational checklist

  1. Freeze the knowledge cutoff and source ownership
  2. Resolve population and model applicability before calculating
  3. Retain components, weights, and evidence coverage beside the headline
  4. Abstain or route when the contract is not satisfied

Stop when a source clock, peer membership, model population, weight, or missing-data rule is unavailable; do not silently complete the score.

Worked synthetic example

The canonical fixture is synthetic teaching data, not an observed control event, filing fact, or portfolio decision. Its primary author-derived output, distress_probability, is three eligible models, distress_probability = 0.228, and range = 0.120. The complete input and output are in datasets/canonical-input.json and datasets/expected-output.json.

The eligible probabilities 0.18, 0.24, and 0.30 with weights 1, 1, and 0.5 produce p_ens = 0.228, with the disabled sector-reroute model excluded and disagreement retained.

Counterfactual checkpoint

One-driver integration stress. Move one declared input or rule boundary while holding the remaining synthetic record fixed. The output changes because the visible component or gate changes, not through an unexplained hidden adjustment.

The structured result retains state and diagnostics in addition to the primary number. That makes the calculation independently reviewable and prevents a incomplete, rejected, or abstained score state from being mistaken for an unqualified value.

Boundary and counterexample workbook

The playground computes every scenario at 61 deterministic parameter states. The table uses the declared focus step and states whether that focus reproduces the canonical fixture. The full state ledger and compressed transition segments are in datasets/scenario-results.json.

ScenarioReview focusPurposeStatePrimary outputDiagnosticDecision segments
Canonical ensembleStep 0 · canonical fixtureDriver: altman.distress_probability. Move one model probability. Predict first: Will a higher eligible distress probability raise the ensemble?calculated22.8% distressrange 12.0%1
Model disagreementStep 30 · comparison focusDriver: ohlson.distress_probability. Separate one model from the others. Predict first: Will disagreement widen the range?calculated36.0% distressrange 39.0%1
Weight shiftStep 30 · comparison focusDriver: zmijewski.weight. Increase the third model weight. Predict first: How does weight change the mean?calculated25.2% distressrange 12.0%1
Rerouted modelStep 30 · canonical fixtureDriver: sector-reroute.eligible. Keep a high-probability model disabled. Predict first: Can an ineligible model contribute?calculated22.8% distressrange 12.0%1
Low-risk agreementStep 30 · comparison focusDriver: all probabilities. Bring eligible probabilities together. Predict first: What does close agreement look like?calculated23.9% distressrange 6.0%1
High-review bandStep 30 · comparison focusDriver: ensemble probability. Move the mean across the review boundaries. Predict first: When does the review band change?calculated36.2% distressrange 27.5%1
Single-model fallbackStep 30 · comparison focusDriver: eligibility flags. Disable all but one model. Predict first: What does one eligible model imply?calculated18.0% distressrange 0.0%1

These rows are not backtest observations. They are controlled counterexamples that expose how one driver changes the state, output, or reason code while the rest of the contract stays fixed.

Visualize the boundary

Distress-Model Ensemble annotated teaching map

Open this SVG at full size, or use the guided playground to compare the seven topic-specific canonical, boundary, policy, and failure scenarios.

The Mermaid flow answers where the selected calculation sits in the processing sequence. The SVG keeps the formula, output, decision boundary, and invariant visible together. The lab lets the reader step through the same structured states without changing the underlying definition.

Implementation walkthrough

The Python and TypeScript references begin with the same validation contract, reject malformed and unsupported state before calculation, preserve declared ordering and rounding policies, and return structured diagnostics rather than one context-free number.

The main implementation branches are:

  • model eligible — include probability, because router approved the variant.
  • model rerouted/unsupported — exclude and record, because no silent substitution.
  • disagreement_range large — raise review flag, because headline hides model conflict.

Neither reference silently fetches data, mutates caller-owned inputs outside the declared engine behavior, guesses hidden state, or substitutes a provider default. Shared JSON fixtures make value, null, state, and reason-code drift visible across languages.

Testing and validation

Definition tests compare every canonical field, reject malformed state, and exercise the material boundary. Family validation recomputes every playground state from the reference function. Independent arithmetic is recorded beside the fixture rather than inferred only from implementation output.

The audit must preserve these invariants:

  • Only eligible models enter p_ens.
  • p_ens lies between the minimum and maximum eligible probabilities.
  • A disabled or rerouted model cannot contribute weight.
  • Retain the method identifier, source clock, intermediate components, and final diagnostic beside the headline.

Passing the tests proves the frozen assembly, routing, arithmetic, and parity contract. It does not validate live-market performance or a current issuer decision.

Failure modes and misuse

  • A transparent composite remains dependent on the source models, population, weights, peer set, and accounting mapping.
  • A high or low score is a research-screen state, not a rating, audit conclusion, fraud finding, default forecast, or investment recommendation.
  • Implementation fidelity does not establish current-population calibration, causality, predictive accuracy, or profitability.

Debugging order

When a result looks surprising, inspect the state in this order:

  1. Confirm the as-of and revision clock.
  2. Confirm the eligible population and selected model variant.
  3. Confirm units, signs, peer direction, weights, caps, and thresholds.
  4. Reconcile every component and abstention reason before interpreting the headline.

Evidence and historical boundary

Historical decision: not useful. A named issuer case is not useful for the canonical orchestration arithmetic without a reproducible point-in-time filing bundle, peer-membership snapshot, model-population eligibility decision, adjustment basis, and redistribution permission. The family therefore uses clearly labeled synthetic records and cites the original model papers for definition history.

The primary sources are Altman (1968), Piotroski (2000), Beneish (1999), Dechow et al. (2011), SEC statements guide, IFRS framework. They support the source roles listed in the research ledger, not a redistributable historical observation, a private participant decision, production conformance certification, execution-quality result, profitability claim, or prediction claim.

Summary and next topic

You can now calculate, audit, and bound Distress-Model Ensemble before continuing to Cross-Model Conflict and Double-Counting Resolver. The learning flow is: Balance-Sheet Resilience Score → Distress-Model Ensemble → Cross-Model Conflict and Double-Counting Resolver. Carry the result forward only with its scope, clock, state, and evidence label.

Level 2 learning check

This additive check keeps the original calculation and example unchanged. Use the studio in four passes:

PassLearner questionEvidence to inspect
PredictHow can eligible distress-model probabilities be combined while preserving disagreement and calibration limits?The active scenario prompt and driver
InspectWhat changed first?eligible probabilities, weights, mean, and spread
ReconcileCan the visible intermediate explain the headline?The component, route, peer, confidence, or migration ledger
BoundIs the result safe to interpret?eligibility filter and 0–1 probability contract and the evidence clock

The output is a research-screen state, not a rating, audit conclusion, default forecast, fraud finding, or investment recommendation. Continue to Cross-Model Conflict and Double-Counting Resolver only after retaining the scope, clock, state, and evidence label.

Integration visual atlas

Integration pipeline anatomy

Gate and interpretation ceiling

Point-in-time evidence clock

Variant and misuse boundaries

Use the atlas to follow Assemble → Resolve → Normalize → Explain, then inspect the evidence clock and boundary map before interpreting the headline.

Use the integration studio

  1. Read the prediction prompt and name the expected direction before moving the driver.
  2. Compare the scenario base with the current state and changed-input summary.
  3. Reconcile the visible intermediate (eligible probabilities, weights, mean, and spread) to the headline.
  4. Apply the boundary and evidence clock: eligibility filter and 0–1 probability contract.

Distress-Model Ensemble calculation flow

This flow identifies the selected calculation stages and the structured output.

Rendering system map…

Takeaway: The mean is only one row in the ensemble ledger; spread is decision-relevant evidence.

ReferencesPrimary sources and evidence notes

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

S1 — Financial Ratios, Discriminant Analysis and the Prediction of Corporate Bankruptcy

  • Organization or authors: Edward I. Altman
  • Source type: Original peer-reviewed paper
  • Publication or effective date: 1968
  • Version: Source edition or current web page
  • URL or DOI: https://doi.org/10.1111/j.1540-6261.1968.tb00843.x
  • Accessed: 2026-08-09
  • Jurisdiction: U.S. publicly traded manufacturers
  • Supports: Defines the original public-manufacturer five-factor screen reused only as a routed diagnostic input.
  • Limitations: Original population and calibration do not transfer automatically to a current universe.

S2 — Value Investing: The Use of Historical Financial Statement Information to Separate Winners from Losers

  • Organization or authors: Joseph D. Piotroski
  • Source type: Original peer-reviewed paper
  • Publication or effective date: 2000
  • Version: Source edition or current web page
  • URL or DOI: https://doi.org/10.2307/2672906
  • Accessed: 2026-08-09
  • Jurisdiction: U.S. high-book-to-market firms
  • Supports: Defines the original nine-signal F-Score research design.
  • Limitations: The paper does not validate this family's composite weights, current calibration, or ranking usefulness.

S3 — The Detection of Earnings Manipulation

  • Organization or authors: Messod D. Beneish
  • Source type: Original peer-reviewed paper
  • Publication or effective date: 1999
  • Version: Source edition or current web page
  • URL or DOI: https://doi.org/10.2469/faj.v55.n5.2296
  • Accessed: 2026-08-09
  • Jurisdiction: U.S. research sample
  • Supports: Defines the eight-index manipulation screen that can inform a bounded quality component.
  • Limitations: A screen is not a fraud finding and does not supply this family's composite calibration.

S4 — Predicting Material Accounting Misstatements

  • Organization or authors: Patricia M. Dechow, Weili Ge, Chad R. Larson, and Richard G. Sloan
  • Source type: Original peer-reviewed paper
  • Publication or effective date: 2011
  • Version: Source edition or current web page
  • URL or DOI: https://doi.org/10.1111/j.1911-3846.2010.01041.x
  • Accessed: 2026-08-09
  • Jurisdiction: U.S. public firms and SEC AAER research sample
  • Supports: Defines a financial-statement misstatement-risk screen and its research boundary.
  • Limitations: The screen is not an allegation or an issuer conclusion; current populations require separate validation.

S5 — Beginners' Guide to Financial Statements

  • Organization or authors: U.S. Securities and Exchange Commission
  • Source type: Official regulator publication
  • Publication or effective date: 2014-01-12
  • Version: Source edition or current web page
  • URL or DOI: https://www.sec.gov/about/reports-publications/beginners-guide-financial-statements
  • Accessed: 2026-08-09
  • Jurisdiction: United States public-company reporting
  • Supports: Balance sheets show stocks at a fixed point while income and cash-flow statements describe periods; the statements must be read together.
  • Limitations: It does not prescribe this family's scores, weights, peer rules, or current-company conclusion.

S6 — Conceptual Framework for Financial Reporting

Evidence boundary

Primary sources establish statement context, statistical conventions, or the historical source-model boundary. They do not certify the repository-authored weights, synthetic universe, current calibration, or issuer conclusion.

d18f09-core.ts
/** Canonical TypeScript parity implementation for D18-F09. */

type RecordLike = Record<string, any>;
const finite = (value: any, name: string): number => {
  if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be a finite number`);
  return value;
};
const positive = (data: RecordLike, name: string): number => {
  const value = finite(data[name], name);
  if (value <= 0) throw new RangeError(`${name} must be positive`);
  return value;
};
const nonnegative = (data: RecordLike, name: string): number => {
  const value = finite(data[name], name);
  if (value < 0) throw new RangeError(`${name} must be nonnegative`);
  return value;
};
const isoDate = (value: any, name: string): string => {
  if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new TypeError(`${name} must be YYYY-MM-DD`);
  return value;
};
const clamp = (value: number, low = 0, high = 100): number => Math.max(low, Math.min(high, value));
const score = (data: RecordLike, name: string): number => {
  const value = finite(data[name], name);
  if (value < 0 || value > 100) throw new RangeError(`${name} must be between 0 and 100`);
  return value;
};
const band = (value: number): string => value >= 75 ? "strong" : value >= 50 ? "watch" : "weak";

function assemble(data: RecordLike): RecordLike {
  const cutoff = isoDate(data.knowledge_cutoff, "knowledge_cutoff");
  const periodEnd = isoDate(data.period_end, "period_end");
  if (typeof data.currency !== "string" || data.currency.length === 0) throw new TypeError("currency must be nonempty text");
  if (typeof data.scale !== "string" || data.scale.length === 0) throw new TypeError("scale must be nonempty text");
  if (!Array.isArray(data.required_fields) || data.required_fields.length === 0 || data.required_fields.some((x: any) => typeof x !== "string" || x.length === 0)) throw new TypeError("required_fields must be a nonempty string list");
  if (!Array.isArray(data.facts)) throw new TypeError("facts must be a list");
  const accepted: RecordLike = {};
  const rejected: RecordLike[] = [];
  for (const field of data.required_fields as string[]) {
    const candidates = (data.facts as any[]).filter((fact: any) => fact && fact.field === field && typeof fact.value === "number" && Number.isFinite(fact.value) && typeof fact.available_at === "string" && typeof fact.period_end === "string" && fact.available_at <= cutoff && fact.period_end === periodEnd && fact.currency === data.currency && fact.scale === data.scale && fact.revision === "original");
    if (candidates.length) {
      candidates.sort((a: any, b: any) => a.available_at.localeCompare(b.available_at));
      accepted[field] = candidates[candidates.length - 1].value;
    } else rejected.push({ field, reason: "no original, aligned fact available by the knowledge cutoff" });
  }
  const completeness = Object.keys(accepted).length / data.required_fields.length;
  return { state: completeness === 1 ? "ready" : "incomplete", method: "point-in-time-filing-fact-assembly", as_of: data.knowledge_cutoff, accepted_fields: Object.keys(accepted).sort(), selected_values: accepted, rejected_fields: rejected, completeness, ready: completeness === 1, clock_policy: "availability_at <= knowledge_cutoff; original revision; exact period/currency/scale" };
}

function cohort(data: RecordLike): RecordLike {
  const asOf = isoDate(data.as_of, "as_of");
  if (typeof data.target_id !== "string" || typeof data.target_sector !== "string" || typeof data.target_country !== "string") throw new TypeError("target identifiers and cohort dimensions must be text");
  const minCap = nonnegative(data, "min_market_cap");
  const minPeers = Math.trunc(positive(data, "min_peers"));
  if (!Array.isArray(data.universe) || data.universe.length === 0) throw new TypeError("universe must be a nonempty list");
  const target = (data.universe as any[]).find((row: any) => row && row.id === data.target_id);
  if (!target) throw new RangeError("target_id must occur in universe");
  const targetCap = positive(target, "market_cap");
  const eligible: any[] = [];
  const exclusions: RecordLike[] = [];
  for (const row of data.universe as any[]) {
    if (!row || row.id === data.target_id) continue;
    let reason: string | null = null;
    let cap = 0;
    try { cap = positive(row, "market_cap"); isoDate(row.available_at, "universe.available_at"); } catch { reason = "invalid market-cap or availability fact"; }
    if (!reason && row.available_at > asOf) reason = "not available at as-of date";
    else if (!reason && row.listed !== true) reason = "not listed under the selected scope";
    else if (!reason && row.sector !== data.target_sector) reason = "sector mismatch";
    else if (!reason && row.country !== data.target_country) reason = "country mismatch";
    else if (!reason && cap < minCap) reason = "below market-cap floor";
    if (reason) exclusions.push({ id: String(row?.id ?? "?"), reason }); else eligible.push(row);
  }
  eligible.sort((a, b) => Math.abs(a.market_cap - targetCap) - Math.abs(b.market_cap - targetCap) || String(a.id).localeCompare(String(b.id)));
  const ids = eligible.map(row => String(row.id));
  return { state: ids.length >= minPeers ? "resolved" : "abstain", method: "point-in-time-sector-country-market-cap-cohort", as_of: data.as_of, eligible_ids: ids, cohort_count: ids.length, minimum_required: minPeers, coverage: ids.length / minPeers, exclusions, tie_break: "absolute market-cap distance, then stable entity id" };
}

function normalize(data: RecordLike): RecordLike {
  if (!Array.isArray(data.metrics) || data.metrics.length === 0) throw new TypeError("metrics must be a nonempty list");
  const minPeers = Math.trunc(positive(data, "min_peers"));
  const results: RecordLike[] = [];
  for (const metric of data.metrics as any[]) {
    if (!metric || typeof metric.name !== "string" || !Array.isArray(metric.peers)) throw new TypeError("each metric needs name and peers");
    const target = finite(metric.target, "target");
    const peers = metric.peers.filter((value: any) => typeof value === "number" && Number.isFinite(value)) as number[];
    if (peers.length < minPeers) throw new RangeError(`${metric.name} has fewer than min_peers observations`);
    const equal = peers.filter(value => Math.abs(value - target) <= 1e-12).length;
    const less = peers.filter(value => value < target - 1e-12).length;
    const percentile = 100 * (less + 0.5 * equal) / peers.length;
    if (typeof metric.higher_is_better !== "boolean") throw new TypeError(`${metric.name}.higher_is_better must be boolean`);
    const normalized = metric.higher_is_better ? percentile : 100 - percentile;
    results.push({ name: metric.name, target, peer_count: peers.length, percentile, higher_is_better: metric.higher_is_better, normalized_score: normalized });
  }
  const aggregate = results.reduce((sum, row) => sum + row.normalized_score, 0) / results.length;
  return { state: "calculated", method: "midrank-empirical-percentile-with-direction", metrics: results, aggregate_score: aggregate, metric_count: results.length, invariant: "higher-is-better reverses the percentile only; peer values are not z-scored" };
}

const modelRules: Record<string, { population: string; eligible: (target: RecordLike) => boolean; variant: string }> = {
  altman_z_original: { population: "public industrial manufacturer", eligible: t => t.is_public === true && t.sector === "industrial" && Number(t.market_cap) > 0, variant: "Use D18-F04-A01 original public-manufacturer coefficients" },
  piotroski_f: { population: "non-financial issuer with two annual periods", eligible: t => !["bank", "insurance", "reit", "utility"].includes(t.sector) && Number(t.annual_periods) >= 2, variant: "Use D18-F04-A02 nine-signal contract" },
  beneish_m: { population: "non-financial issuer with two annual periods", eligible: t => !["bank", "insurance", "reit", "utility"].includes(t.sector) && Number(t.annual_periods) >= 2, variant: "Use D18-F04-A03 eight-index contract" },
  ohlson_o: { population: "industrial public research screen", eligible: t => t.is_public === true && Number(t.annual_periods) >= 2, variant: "Use D18-F04-A05 Model 1 convention" },
  dividend_safety: { population: "issuer with declared dividend and cash-flow facts", eligible: t => t.dividends_known === true && Number(t.annual_periods) >= 1, variant: "Use D18-F09-A07 payout contract" },
  balance_sheet_resilience: { population: "issuer with aligned balance-sheet and coverage facts", eligible: t => Number(t.total_assets) > 0 && ["US-GAAP", "IFRS"].includes(t.framework), variant: "Use D18-F09-A08 resilience contract" },
};

function route(data: RecordLike): RecordLike {
  if (!data.target || typeof data.target !== "object" || !Array.isArray(data.requested_models) || data.requested_models.length === 0) throw new TypeError("target and requested_models are required");
  const routes: RecordLike[] = [];
  for (const model of data.requested_models as any[]) {
    if (typeof model !== "string") throw new TypeError("requested model names must be text");
    const rule = modelRules[model];
    if (!rule) { routes.push({ model, status: "unsupported", variant: null, reason: "no frozen rule for this model label" }); continue; }
    const eligible = rule.eligible(data.target);
    routes.push({ model, status: eligible ? "eligible" : "reroute", variant: eligible ? rule.variant : null, population: rule.population, reason: eligible ? "all required scope facts pass" : `target does not meet ${rule.population} contract` });
  }
  const eligibleCount = routes.filter(row => row.status === "eligible").length;
  return { state: eligibleCount ? "routed" : "abstain", method: "explicit-model-applicability-router", routes, eligible_count: eligibleCount, requested_count: routes.length, coverage: eligibleCount / routes.length };
}

function weighted(data: RecordLike, names: string[], weights: number[], output: string, method: string): RecordLike {
  const values = names.map(name => score(data, name));
  const components: RecordLike = {}, weightMap: RecordLike = {}, contributions: RecordLike = {};
  names.forEach((name, i) => { components[name] = values[i]; weightMap[name] = weights[i]; contributions[name] = values[i] * weights[i]; });
  const total = Object.values(contributions).reduce((sum: number, value: any) => sum + value, 0);
  return { state: "calculated", method, components, weights: weightMap, contributions, [output]: total, band: band(total), coverage: 1 };
}
const health = (data: RecordLike): RecordLike => weighted(data, ["profitability_score", "cash_flow_score", "liquidity_score", "leverage_score"], [0.3, 0.3, 0.2, 0.2], "financial_health_score", "accounting-financial-health-weighted-composite");
const earnings = (data: RecordLike): RecordLike => weighted(data, ["accrual_quality_score", "cash_conversion_score", "revenue_quality_score", "manipulation_safety_score"], [0.3, 0.25, 0.25, 0.2], "earnings_quality_score", "earnings-quality-weighted-composite");

function dividend(data: RecordLike): RecordLike {
  const dividends = positive(data, "dividends_paid");
  const fcf = finite(data.free_cash_flow, "free_cash_flow");
  const netIncome = finite(data.net_income, "net_income");
  const interestCoverage = nonnegative(data, "interest_coverage");
  const cash = nonnegative(data, "cash_and_equivalents");
  const components = { free_cash_flow_coverage: clamp(fcf / dividends / 2 * 100), earnings_coverage: clamp(netIncome / dividends / 2 * 100), interest_coverage: clamp(interestCoverage / 10 * 100), cash_buffer: clamp(cash / dividends / 4 * 100) };
  const weights = { free_cash_flow_coverage: 0.35, earnings_coverage: 0.25, interest_coverage: 0.2, cash_buffer: 0.2 };
  const contributions: RecordLike = {}; Object.keys(components).forEach(key => { contributions[key] = (components as any)[key] * (weights as any)[key]; });
  const value = Object.values(contributions).reduce((sum: number, item: any) => sum + item, 0);
  return { state: "calculated", method: "coverage-and-liquidity-dividend-safety", components, weights, contributions, dividend_safety_score: value, band: band(value), coverage_policy: "coverage is capped at two times and cash buffer at four times" };
}

function resilience(data: RecordLike): RecordLike {
  const currentAssets = positive(data, "current_assets"), currentLiabilities = positive(data, "current_liabilities"), totalDebt = positive(data, "total_debt"), ebitda = positive(data, "ebitda"), interest = positive(data, "interest_expense"), cash = nonnegative(data, "cash_and_equivalents"), due = nonnegative(data, "debt_due_12m");
  if (due > totalDebt) throw new RangeError("debt_due_12m cannot exceed total_debt");
  const components = { liquidity: clamp(currentAssets / currentLiabilities / 2 * 100), net_leverage: clamp((1 - (totalDebt - cash) / (4 * ebitda)) * 100), interest_coverage: clamp(ebitda / interest / 10 * 100), maturity_headroom: clamp((1 - due / totalDebt) * 100) };
  const weights = { liquidity: 0.3, net_leverage: 0.3, interest_coverage: 0.25, maturity_headroom: 0.15 };
  const contributions: RecordLike = {}; Object.keys(components).forEach(key => { contributions[key] = (components as any)[key] * (weights as any)[key]; });
  const value = Object.values(contributions).reduce((sum: number, item: any) => sum + item, 0);
  return { state: "calculated", method: "liquidity-leverage-coverage-maturity-resilience", components, weights, contributions, balance_sheet_resilience_score: value, band: band(value) };
}

function ensemble(data: RecordLike): RecordLike {
  if (!Array.isArray(data.models) || data.models.length === 0) throw new TypeError("models must be a nonempty list");
  const eligible: [string, number, number][] = [];
  for (const model of data.models as any[]) {
    if (!model || typeof model !== "object") throw new TypeError("each model must be an object");
    if (model.eligible !== true) continue;
    const probability = finite(model.distress_probability, "distress_probability"), weight = positive(model, "weight");
    if (probability < 0 || probability > 1) throw new RangeError("distress_probability must be between zero and one");
    eligible.push([String(model.name ?? "model"), probability, weight]);
  }
  if (!eligible.length) throw new RangeError("at least one eligible model is required");
  const weightSum = eligible.reduce((sum, row) => sum + row[2], 0);
  const probability = eligible.reduce((sum, row) => sum + row[1] * row[2], 0) / weightSum;
  const variance = eligible.reduce((sum, row) => sum + row[2] * (row[1] - probability) ** 2, 0) / weightSum;
  const values = eligible.map(row => row[1]), dispersion = Math.max(...values) - Math.min(...values);
  return { state: "calculated", method: "weighted-distress-probability-ensemble", eligible_models: eligible.map(row => ({ name: row[0], probability: row[1], weight: row[2] })), model_count: eligible.length, distress_probability: probability, weighted_stddev: Math.sqrt(variance), disagreement_range: dispersion, agreement: 1 - dispersion, band: probability >= 0.66 ? "high-review" : probability >= 0.33 ? "watch" : "lower-review", calibration_boundary: "weighted aggregation preserves supplied probabilities; it does not recalibrate them" };
}

function conflict(data: RecordLike): RecordLike {
  if (!Array.isArray(data.components) || data.components.length === 0) throw new TypeError("components must be a nonempty list");
  const groupCap = finite(data.group_cap, "group_cap"), threshold = finite(data.conflict_threshold, "conflict_threshold");
  if (groupCap <= 0 || groupCap > 1 || threshold <= 0 || threshold > 100) throw new RangeError("group_cap must be in (0,1] and conflict_threshold in (0,100]");
  const groups = new Map<string, RecordLike[]>(); let rawWeight = 0, rawNumerator = 0;
  for (const item of data.components as any[]) {
    if (!item || typeof item.evidence_group !== "string") throw new TypeError("each component needs an evidence_group");
    const value = score(item, "score"), weight = positive(item, "weight"), group = item.evidence_group;
    if (!groups.has(group)) groups.set(group, []);
    groups.get(group)!.push({ name: String(item.name ?? "component"), score: value, weight }); rawWeight += weight; rawNumerator += value * weight;
  }
  const adjusted: RecordLike[] = [], conflicts: string[] = []; let denominator = 0, numerator = 0;
  [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0])).forEach(([group, items]) => {
    const weight = items.reduce((sum, item) => sum + item.weight, 0), mean = items.reduce((sum, item) => sum + item.score * item.weight, 0) / weight, values = items.map(item => item.score), range = Math.max(...values) - Math.min(...values), isConflict = range >= threshold, capped = Math.min(weight, groupCap);
    if (isConflict) conflicts.push(group);
    adjusted.push({ group, raw_weight: weight, capped_weight: capped, mean_score: mean, range, conflict: isConflict }); denominator += capped; numerator += capped * mean;
  });
  const resolved = numerator / denominator, raw = rawNumerator / rawWeight;
  return { state: conflicts.length ? "conflict-detected" : "resolved", method: "evidence-group-cap-and-conflict-resolver", raw_score: raw, resolved_score: resolved, double_counting_adjustment: resolved - raw, groups: adjusted, conflict_groups: conflicts, conflict_count: conflicts.length, group_cap: groupCap, conflict_threshold: threshold };
}

const overall = (data: RecordLike): RecordLike => weighted(data, ["financial_health_score", "earnings_quality_score", "dividend_safety_score", "balance_sheet_resilience_score", "distress_safety_score", "valuation_score"], [0.24, 0.18, 0.14, 0.18, 0.16, 0.10], "overall_stock_score", "explainable-six-pillar-stock-score");

function confidence(data: RecordLike): RecordLike {
  const baseScore = score(data, "base_score"), required = Math.trunc(positive(data, "required_components")), available = Math.trunc(nonnegative(data, "available_components")), maxConflicts = Math.trunc(positive(data, "max_conflicts")), conflicts = Math.trunc(nonnegative(data, "conflict_count")), minimum = Math.trunc(positive(data, "minimum_components")), staleDays = nonnegative(data, "stale_days");
  if (available > required || conflicts > maxConflicts) throw new RangeError("available components or conflicts exceed their declared maxima");
  const coverage = available / required, missingPenalty = 1 - coverage, stalenessPenalty = Math.min(staleDays / 365, 1) * 0.2, conflictPenalty = Math.min(conflicts / maxConflicts, 1) * 0.2, confidenceValue = coverage * (1 - stalenessPenalty) * (1 - conflictPenalty), adjusted = clamp(baseScore - 15 * missingPenalty - 10 * conflictPenalty), abstain = available < minimum || confidenceValue < 0.6;
  return { state: abstain ? "abstain" : "usable-with-confidence", method: "coverage-staleness-conflict-confidence-gate", base_score: baseScore, coverage, missing_penalty: missingPenalty, staleness_penalty: stalenessPenalty, conflict_penalty: conflictPenalty, confidence: confidenceValue, adjusted_score: adjusted, abstain, reason: abstain ? "minimum component or confidence gate failed" : "coverage and confidence gates passed" };
}

function screen(data: RecordLike): RecordLike {
  if (!Array.isArray(data.universe) || data.universe.length === 0) throw new TypeError("universe must be a nonempty list");
  const floor = score(data, "screen_floor"), minConfidence = finite(data.min_confidence, "min_confidence"), minLiquidity = nonnegative(data, "min_liquidity");
  if (minConfidence < 0 || minConfidence > 1) throw new RangeError("min_confidence must be between zero and one");
  const selected: RecordLike[] = [], excluded: RecordLike[] = [];
  for (const row of data.universe as any[]) {
    if (!row || typeof row.id !== "string") throw new TypeError("each universe row needs an id");
    const rowScore = score(row, "score"), confidenceValue = finite(row.confidence, "confidence"), liquidity = nonnegative(row, "liquidity");
    if (confidenceValue < 0 || confidenceValue > 1) throw new RangeError("universe confidence must be between zero and one");
    const reasons: string[] = []; if (row.eligible !== true) reasons.push("not eligible"); if (rowScore < floor) reasons.push("below score floor"); if (confidenceValue < minConfidence) reasons.push("below confidence floor"); if (liquidity < minLiquidity) reasons.push("below liquidity floor");
    const candidate: RecordLike = { id: row.id, score: rowScore, confidence: confidenceValue, liquidity, sector: row.sector ?? "unspecified" };
    if (reasons.length) excluded.push({ ...candidate, reasons }); else selected.push(candidate);
  }
  selected.sort((a, b) => b.score - a.score || b.confidence - a.confidence || a.id.localeCompare(b.id));
  const ranked = selected.map((row, index) => ({ rank: index + 1, ...row }));
  return { state: selected.length ? "ranked" : "abstain", method: "eligibility-confidence-score-floor-ranking", ranked, screened_count: ranked.length, universe_count: data.universe.length, coverage: ranked.length / data.universe.length, score_floor: floor, confidence_floor: minConfidence, liquidity_floor: minLiquidity, excluded, tie_break: "score descending, confidence descending, stable id ascending" };
}

function history(data: RecordLike): RecordLike {
  if (!Array.isArray(data.history) || data.history.length < 2) throw new RangeError("history needs at least two points");
  const rows = (data.history as any[]).map(row => { if (!row || typeof row.components !== "object") throw new TypeError("each history row needs components"); return { date: isoDate(row.as_of, "history.as_of"), row }; });
  for (let index = 0; index + 1 < rows.length; index += 1) if (rows[index].date >= rows[index + 1].date) throw new RangeError("history must be strictly chronological");
  const previous = rows[rows.length - 2].row, latest = rows[rows.length - 1].row, previousScore = score(previous, "overall_score"), latestScore = score(latest, "overall_score");
  const keys = [...new Set([...Object.keys(previous.components), ...Object.keys(latest.components)])].sort();
  const deltas: RecordLike = {}; for (const key of keys) deltas[key] = score(latest.components, key) - score(previous.components, key);
  const topDriver = keys.reduce((best, key) => Math.abs(deltas[key]) > Math.abs(deltas[best]) || (Math.abs(deltas[key]) === Math.abs(deltas[best]) && key > best) ? key : best, keys[0]);
  const delta = latestScore - previousScore, priorBand = String(previous.band), latestBand = String(latest.band), migration = priorBand === latestBand ? "unchanged" : `${priorBand} -> ${latestBand}`, order: Record<string, number> = { abstain: 0, watch: 1, eligible: 2, strong: 3 };
  return { state: delta > 1e-12 ? "improved" : delta < -1e-12 ? "deteriorated" : "unchanged", method: "point-in-time-score-history-change-attribution", previous_as_of: previous.as_of, latest_as_of: latest.as_of, previous_score: previousScore, latest_score: latestScore, score_change: delta, prior_band: priorBand, latest_band: latestBand, migration, migration_direction: (order[latestBand] ?? 0) - (order[priorBand] ?? 0), component_deltas: deltas, top_driver: topDriver, clock_policy: "only knowledge-available snapshots are comparable" };
}

export function calculate(topicId: string, data: RecordLike): RecordLike {
  const functions: Record<string, (input: RecordLike) => RecordLike> = { "D18-F09-A01": assemble, "D18-F09-A02": cohort, "D18-F09-A03": normalize, "D18-F09-A04": route, "D18-F09-A05": health, "D18-F09-A06": earnings, "D18-F09-A07": dividend, "D18-F09-A08": resilience, "D18-F09-A09": ensemble, "D18-F09-A10": conflict, "D18-F09-A11": overall, "D18-F09-A12": confidence, "D18-F09-A13": screen, "D18-F09-A14": history };
  if (!functions[topicId]) throw new RangeError(`unsupported topic id: ${topicId}`);
  return functions[topicId](data);
}
Full-height labplaygroundOpen full screen