Skip to content

Risk-scoring algorithm

The composite risk score is a 0-100 number computed per AR from five normalised inputs, with weights tunable per principal firm. In the demo, the score is pre-baked into fixtures (see lib/fixtures.ts) and recomputed only when the AR-side surface adds a new MI return. In production, the score is recomputed nightly and on every event ingest that touches an input (breach filed, file review completed, MI return submitted).

The reference implementation is lib/risk-scoring.ts. Inputs are normalised to [0, 1]; weights sum to 1.0; the formula multiplies and scales to [0, 100].

Each input is a number in [0, 1].

  1. complaintsDensity. Uphold-weighted complaints over rolling 12 months, divided by new-business volume in the same window. Normalised by clamping at the 95th-percentile firm-wide rate.
  2. breachSeveritySum. Weighted sum of breach severities in rolling 12 months. Severity weights minor=1, moderate=3, material=8, significant=20. Customer-impact multiplier none=1.0, potential=1.2, actual-low=1.5, actual-high=2.0. Normalised by clamping at 60.
  3. fileReviewInverse. 1 - (mean review score / 100) over the rolling 12-month review history, weighting recent reviews twice. ARs with fewer than two reviews in the window get the firm-wide median imputed, with a flag.
  4. timeSinceLastReview. Months since lastAnnualReviewAt, capped and normalised against the firm’s review cadence policy (default 12 months, cap 24).
  5. miAnomalyScore. The most recent MI return’s anomalyScore, computed as a z-score against the AR’s own 8-quarter trailing distribution, squashed through tanh and rescaled to [0, 1].
lib/risk-scoring.ts
export const DEFAULT_WEIGHTS: RiskScoreWeights = {
complaints: 0.20,
breach: 0.30,
reviewInverse: 0.25,
timeSinceReview: 0.10,
miAnomaly: 0.15,
};

Weights sum to 1.0. The product default leans on breach weight and file-review weight because those are the two inputs the FCA most directly tests when reviewing supervisory adequacy under PS22/11.

composite = 100 * (
w.complaints * complaintsDensity
+ w.breach * breachSeveritySum
+ w.reviewInverse * fileReviewInverse
+ w.timeSinceReview * timeSinceLastReview
+ w.miAnomaly * miAnomalyScore
)

Reference implementation:

export interface RiskScoreInputs {
complaintsDensity: number;
breachSeveritySum: number;
fileReviewInverse: number;
timeSinceLastReview: number;
miAnomalyScore: number;
}
export interface RiskScoreWeights {
complaints: number;
breach: number;
reviewInverse: number;
timeSinceReview: number;
miAnomaly: number;
}
export function computeRiskScore(
inputs: RiskScoreInputs,
weights: RiskScoreWeights = DEFAULT_WEIGHTS,
): number {
return (
100 *
(weights.complaints * inputs.complaintsDensity +
weights.breach * inputs.breachSeveritySum +
weights.reviewInverse * inputs.fileReviewInverse +
weights.timeSinceReview * inputs.timeSinceLastReview +
weights.miAnomaly * inputs.miAnomalyScore)
);
}
export function bandFromScore(score: number): RiskBand {
if (score < 20) return "low";
if (score < 40) return "moderate";
if (score < 60) return "elevated";
if (score < 80) return "high";
return "critical";
}
BandRangeUI colour token
Low0 to 19.99--severity-low
Moderate20 to 39.99--severity-moderate
Elevated40 to 59.99--severity-elevated
High60 to 79.99--severity-high
Critical80 to 100--severity-critical

An AR at Heritage Mortgage Network. Reference date: 2026-05-08.

InputCalculationNormalised
complaintsDensity2 upheld complaints over £4.2m new business in 12 months. AR rate 2 / 4.2 = 0.476 per £m. Firm 95th-percentile rate 0.6 per £m. Clamp 0.476 / 0.60.79
breachSeveritySumOne material breach (8) at actual-low impact (x1.5) = 12. One minor breach (1). Sum 13. Clamp 13 / 600.22
fileReviewInverseRecent-weighted mean review score 70/100. 1 - (70 / 100)0.30
timeSinceLastReview14 months since last annual review against 12-month cadence, cap 24. min(14 / 24, 1)0.58
miAnomalyScoreMost recent MI return z-score squashed through tanh0.41
composite = 100 * (
0.20 * 0.79
+ 0.30 * 0.22
+ 0.25 * 0.30
+ 0.10 * 0.58
+ 0.15 * 0.41
)
= 100 * (0.158 + 0.066 + 0.075 + 0.058 + 0.0615)
= 41.85

Score 41.85 lands in the elevated band.

A tenant settings panel “Risk model” is restricted to principal-admin. Five sliders match the formula, each anchored to its component name and the firm-wide median value of that input.

Weights must sum to 1.0 at all times. Dragging one slider proportionally shrinks the other four. A “lock” pin per slider excludes it from rebalancing, so an admin can fix one weight and let the rest absorb the change.

flowchart LR
drag[Admin drags<br/>w.breach to 0.40] --> calc[Compute delta = +0.10]
calc --> excl{Locked sliders?}
excl -->|none| split[Split -0.10 across<br/>4 unlocked sliders<br/>weighted by current value]
excl -->|some| split2[Split -0.10 across<br/>unlocked sliders only]
split --> apply[Apply, sum = 1.0]
split2 --> apply

A 90-day backtest panel sits below the sliders. As the admin moves a slider, the panel re-runs computeRiskScore against historic snapshots and shows the resulting band-distribution shift as a stacked-bar diff (before / after) for the firm’s full AR set, alongside the count of ARs that would change band under the new weights.

Saving writes a RiskWeightChange audit event with oldWeights and newWeights vectors and triggers a full recompute job. Old snapshots are immutable; recomputed scores are written as new history rows so the AR’s trajectory chart shows a band shift at the change date with a tooltip linking to the audit event.

// Audit payload
{
action: "tenant.risk-weights.update",
subjectType: "tenant",
subjectId: tenantId,
meta: {
oldWeights: { complaints: 0.20, breach: 0.30, reviewInverse: 0.25, timeSinceReview: 0.10, miAnomaly: 0.15 },
newWeights: { complaints: 0.15, breach: 0.40, reviewInverse: 0.25, timeSinceReview: 0.10, miAnomaly: 0.10 },
affectedArCount: 23,
bandShifts: { up: 14, down: 9, unchanged: 78 },
},
}
  • Weights below 0.05 require a confirmation dialog (“This component will have a negligible effect on the score”).
  • Weights above 0.50 trigger a “single-factor risk” warning (“Score will be dominated by this component, consider rebalancing”).
  • Components cannot be removed; the formula always carries all five inputs.

Changing weights does not rewrite historic scores in place. The trajectory chart shows a visible step at the change date, with a tooltip pointing to the audit event. Auditors and the FCA can reconstruct what the score was under the prior model and what it became under the new one. Retro-rewriting would erase the firm’s own supervisory record of the change and break the audit chain’s narrative.

EventRecompute scope
Breach filed or status changedThe AR’s score
File review completedThe AR’s score
MI return submittedThe AR’s score
Annual review signed offThe AR’s score (resets timeSinceLastReview)
Risk-weights changeEvery AR in the tenant
NightlyEvery AR in every tenant
Manual trigger by adminTenant-wide or single AR

The nightly job is the canonical source. Event-triggered recomputes are an optimisation so the UI does not lag.