// fraud analytics · SQL · 2026
Rules-based fraud detection in SQL
Seven SQL rules, scored against 584,733 labelled bank transactions. Together they catch 224 of 230 fraud cases (97.4%) at 41 alerts a day, 35% of them true fraud.
The problem
Every fraud team runs the same trade-off. Loosen the rules and you catch more fraud, but analysts drown in false alerts. Tighten them and fraud gets through. This project builds a ruleset and then measures that trade-off directly.
The data
No public dataset matched NZ retail banking closely enough, so I wrote a simulator in Python. It generates 60 days of activity for 6,000 customers, with spending scaled to income, then injects four fraud typologies on top.
| Typology | What it looks like | Cases |
|---|---|---|
| Account takeover | Unknown device, new payee, account drained, often at night | 60 |
| Push-payment scam | Customer sends an unusually large payment to a brand-new payee, sometimes split | 80 |
| Card testing | Burst of tiny online purchases, then one large one | 60 |
| Money mule | Several unrelated credits arrive, then most of the money leaves within hours | 30 |
Normal behaviour includes the noise rules have to live with: a quarter of customers pay a legitimate new payee, and 5% switch to a new phone.
The rules
Every feature is built with SQL window functions that only look backwards, so no rule ever uses information it wouldn't have at the moment of payment. The fraud labels are used only for scoring. Here's the account-takeover rule:
SELECT tx_id, 'R1_new_device_new_payee_large' AS rule FROM features WHERE direction = 'debit' AND channel = 'transfer' AND first_on_device -- first time this customer used this device AND first_to_payee -- first payment to this payee AND amount > coalesce(max_prior_transfer, 0); -- bigger than any transfer in 60 days
| Rule | Logic | Alerts | Precision | Cases |
|---|---|---|---|---|
| R1 New device + new payee | First device use, first payment to payee, larger than anything sent in 60 days | 80 | 72.5% | 58 |
| R2 Night-time new payee | First payment to a payee, midnight to 5am, over $1,000 | 105 | 30.5% | 32 |
| R3 New payee, 2x baseline | First payment to a payee at least double the customer's largest prior transfer | 1,341 | 11.5% | 154 |
| R4 Card testing | 4+ online card purchases under $5 within 10 minutes | 260 | 100%* | 60 |
| R5 Shared device | Device already used by 2+ other customers | 486 | 100%* | 85 |
| R6 Mule pass-through | 3+ third-party credits in 12 hours, then a debit of 80%+ of them | 30 | 100%* | 30 |
| R7 Shared new payee | New payee that 2+ other customers paid in the last 7 days | 6 | 100%* | 6 |
*Perfect precision here reflects the simulator, not the real world. Real households share devices and real merchants run small test charges, so in production these rules need allow-lists and tuning.
What I changed along the way
A warm-up window fixed the worst rule
On the first run, R1 fired 1,519 times at 3.8% precision. Every customer's first-ever online payment looked like "new device plus new payee". Adding a 14-day baseline before alerting took R1 to 80 alerts at 72.5% precision, with no loss in cases caught. Banks face the same cold-start problem with every new customer.
Tuning the noisiest rule
R3 produces most of the alert volume, so I swept its threshold and scored the whole ruleset at each setting.

| R3 multiplier | Alerts per day | Precision | Cases caught |
|---|---|---|---|
| 1.0x | 89.7 | 16.1% | 225 |
| 1.5x | 56.1 | 25.8% | 225 |
| 2.0x (chosen) | 40.9 | 35.3% | 224 |
| 2.5x | 32.2 | 44.6% | 219 |
| 3.0x | 27.4 | 52.1% | 217 |
| 4.0x | 22.3 | 63.1% | 207 |
Going from 1x to 2x halves the alert load and loses one case. Past 2x, every step costs real fraud. Where to sit is a business decision about analyst capacity and risk appetite: a smaller team might choose 3x and catch 217 cases at 27 alerts a day.
What it missed, and why
Six cases got through. Each one points at a real weakness.
- Split payments. One scam victim's first payment to the scammer was under 2x their baseline; the second was 2.7x. R3 only checks the first payment to a payee. Fix: compare the 7-day running total to a new payee against the baseline.
- No history. One victim had no prior transfers, so R3 had nothing to compare against. Fix: a peer-group baseline for thin-history customers.
- Ordinary-looking scams. Three scam payments sat inside the customer's normal range. Rules alone won't catch these. They need Confirmation of Payee, scam warnings at the point of payment, or a model using more signals.
- Quiet takeover. One takeover happened mid-afternoon for a small amount. A new device plus a new payee is risky at any size, so a lower-priority alert tier would catch it.
Run it yourself
pip install duckdb pandas numpy matplotlib python simulate.py # writes ~585k labelled transactions python evaluate.py # runs rules.sql and scores every rule python tradeoff_chart.py # redraws the chart
Runs in under a minute on a laptop, with a fixed random seed so results reproduce exactly. Download the code (zip).