CBAP® · CBDA · DipFPS (CII R01-R06) · Professional Scrum Master (PSM)
I turn complex insurance regulations, actuarial risk concepts, and customer data into robust business requirements, automated analytics controls, and measurable outcomes.
Data notice, read this before quoting anything from my project repositories. Every dataset in the insurance projects below is synthetic. Three of the four ship the generator alongside the code (
data/generate_dataset.py,data/generate_duty_data.py,src/seed_and_run.py); the P&C reserving repository commits a 30 row triangle CSV directly. The datasets exist to exercise the pipelines, the reserving arithmetic and the reporting logic. No number in them is evidence about a real book of business, a real firm, or a real model's skill. The project READMEs do not currently carry this notice themselves, which is why it is stated here, at the top, where a reader arrives first. Nothing on this profile is validated against an externally published benchmark. Every check named below is a self-consistency or structural check, and I say so at each one rather than letting the word "tested" do work it has not earned.
acturtle/cashflower is an open-source Python framework for actuarial cash flow models.
PR #538, "Improve the error message
when a variable returns multiple values." Filed against
issue #493, where a user hit
ValueError: setting an array element with a sequence. and could not tell which
variable caused it. I traced it past the reported symptom: the discount function was
fine, and the real fault was that a no-parameter @variable() builds a
ConstantVariable whose calculate() calls numpy.ndarray.fill(), which cannot accept
an array. While confirming that, I found a second, quieter instance of the same class of
failure on the t-parameterised path. The PR raises a named, diagnosable error at both
sites and uses the reporter's own minimal example as the regression test.
Status: open, targeting develop, awaiting maintainer review. Not merged. This is
the only item on this profile that has been reviewed by anyone other than me, which is
exactly why it is at the top rather than buried.
- 🛡️ Insurance & Wealth Management (6+ Years UK & India): Property & Casualty (P&C), Life & Pensions, Underwriting Scorecards, Claims Analytics & Reserving, Loss Ratio Analysis.
- 📜 Regulatory Governance & Compliance: FCA Consumer Duty (Fair Value Assessment, Customer Outcomes MI), Solvency II Reporting, Model Risk Governance for AI/ML in Insurance.
- 🔬 Doctoral Research (AI in Insurance): ML-model governance, automated compliance monitoring, and auditability across the policy lifecycle (University of Greater Manchester).
- 📋 Business Analysis Artefacts: Business Requirements Documents (BRD), Functional Specifications (FSD), BPMN 2.0 Process Flow Modeling, User Story Mapping (Gherkin format), Target Operating Models (TOM), UAT & RAID Management.
The loss ratio, written out properly:
Loss Ratio = Incurred Claims / Earned Premium
Incurred Claims = Paid Claims + Outstanding Reserves
Incurred claims already contain the paid element, so paid and incurred are never added together. Doing that double counts every pound already settled. The second line is the decomposition, not a second term: over a period it is equivalent to paid claims plus the movement in case and IBNR reserves. The denominator is earned premium, not written premium, because the numerator only covers exposure that has actually run off.
sql/01_loss_ratio_by_lob.sql
computes the numerator as SUM(c.paid_amount + c.incurred_reserve) and divides by
earned premium. That column is named incurred_reserve but it holds the outstanding
case reserve, so the sum really is incurred claims and the arithmetic is correct. The
name is the trap, not the maths: read literally it invites exactly the double count
described above, and renaming it is on my list for that repository.
tests/test_readme.py in this repository parses the formula out of this README,
substitutes a worked example (paid claims 15,500, outstanding reserves 7,500, earned
premium 33,000), and fails if the arithmetic as written does not come out at 69.70%.
The unparenthesised version returns 15500.23, which is how the error above was caught.
That check is local to this file. A second suite,
tests/test_published_claims.py, fetches the sibling repositories over the network and
fails if what they publish stops matching what this page says about them.
All four run on synthetic data, disclosed at the top of each repository's own README. Each carries a requirements traceability matrix and a behavioural test suite whose assertions are checked against hand-computed or published oracles.
Python · pandas · chain ladder · Bornhuetter-Ferguson · Mack (1993)
Paid loss development triangles, volume-weighted chain ladder factors, and Bornhuetter-Ferguson ultimates across a Commercial Property and a Private Motor portfolio, with the reserve decomposed into case reserves and IBNR rather than reported as one figure.
The reserve variability is validated against an external benchmark. The Mack (1993) standard-error implementation reproduces the published per-accident-year standard errors for the Taylor & Ashe (1983) triangle exactly, to the pound, for all ten accident years. That is an independent oracle, not a self-consistency check. The tail factor is derived from the oldest accident year's open case reserve (1.013333 for Commercial Property; 1.000000 for Private Motor, which is fully run off) rather than assumed.
Read the reserve figures as an implementation exercise. The triangle is 30 synthetic rows and deliberately smooth, so coefficients of variation land at 1% to 8% where a real book would produce materially wider ranges. The method is right; the data is too well behaved to exercise it. No discounting and no risk margin, so this is an input to a Solvency II technical provision, not a substitute for one. BRD.
Python · scikit-learn · BPMN 2.0
A claims fraud scoring pipeline and a BPMN 2.0 Special Investigation Unit triage workflow, with a cost-derived referral threshold and auditable per-claim reason codes.
Read the model scores as a pipeline exercise, not as detection skill. In
data/generate_dataset.py the is_fraud label is a percentile cut on a logit that is an
exact linear function of the committed feature columns, with no noise term, so the target
is perfectly recoverable. The AUCs measure planted-rule recovery. That caveat now opens
that repository's own README.
The transferable parts are the triage design and two findings that fell out of doing the evaluation honestly. Against a named cost model, the documented 0.60 referral threshold costs roughly double the cost-minimising 0.11, because a missed fraud runs about 19x an investigation. And the middle triage lane governs 2 of 300 claims, so the flagship three-lane workflow is currently a two-lane workflow. Feature attribution is reported three ways, including the true generating weights, which disagree with both tree-based measures. BRD.
SQL · CTEs · window functions · SQLite · Python
An analytical SQL suite executed against a seeded SQLite database of 10,528 policies, 1,307 claims and 4,814 dated payment transactions across two lines of business and accident years 2020 to 2024: loss ratio by line of business on the definition above, cumulative development triangles, a chain ladder computed in pure SQL, an FCA vulnerable customer SLA report, and a Kaplan-Meier retention curve with explicit censoring at the valuation date.
The chain ladder is cross-validated against an independent pandas implementation, and the two must agree to eight decimal places for the test suite to pass. Age-to-age factors are computed only over accident years where both development periods are observed, and every factor is asserted to be at or above 1.0, because cumulative paid claims cannot fall. Development stage labels are read from the triangle rather than inferred.
There is no reinsurance in the schema, so the ratio is reported as gross rather than net. Database and seed data are synthetic. BRD.
Python · pandas · regulatory MI
A regulatory MI view over the FCA's Consumer Duty (FG22/5) Four Outcomes: claims acceptance and complaints per thousand policies scored to RAG bands, plus settlement days split between standard and vulnerable policyholders.
The RAG bands are internal review triggers, not regulatory thresholds. The FCA does not publish numeric pass/fail levels for these measures, so the 85% acceptance and 3.0 complaints cuts are mine, and the repository says so.
Book-level measures are weighted by exposure rather than averaged across product lines, which matters: the unweighted mean understates book acceptance by 2.05 percentage points by treating a 15,000-policy line as equal to a 45,000-policy one. The published table is generated from the output CSV and a test fails if the two ever drift apart. The dataset is four product-level rows, which is a worked example and not a sample: it cannot support inference of any kind, and there are no confidence intervals because there is no underlying variation to interval. BRD.
| Project | Core Skills & Stack | What it is |
|---|---|---|
| 📈 Stock-Index Signal Backtesting & Risk Model | Python · pandas · scikit-learn | Cost-aware backtesting across 7 US/UK indices with look-ahead leakage controls, framed for ALM and investment risk governance. |
| 📊 Tableau Dashboard Portfolio | Tableau Public · Data Viz | 7 dashboards, each built around a different hero visualisation technique, packaged as self-contained .twbx workbooks. |
| 🎓 UK School Performance & Rating Scorecard | Tableau · Python · GOV.UK open data | Multi-attribute composite scoring with k-NN imputation. Real public data, and structurally the same shape as a territorial risk scorecard. |
| 🗄️ Polyglot Database Architecture | SQLite (3NF) · MongoDB · Python | Relational core with foreign key enforcement plus a NoSQL document store, parameterised SQL, and booking analytics (MSc group project). |
| 🤖 Job Application Co-pilot | Python · Playwright · Claude API | Scrape, score, tailor, verify, human review. Built around an anti-fabrication audit gate that drops any resume claim not traceable to the master document. |