shay.ramthol

// 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.

97.4%of fraud cases caught
35.3%of alerts are real fraud
40.9alerts per day to work
0.16%of transactions are 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.

TypologyWhat it looks likeCases
Account takeoverUnknown device, new payee, account drained, often at night60
Push-payment scamCustomer sends an unusually large payment to a brand-new payee, sometimes split80
Card testingBurst of tiny online purchases, then one large one60
Money muleSeveral unrelated credits arrive, then most of the money leaves within hours30

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
RuleLogicAlertsPrecisionCases
R1 New device + new payeeFirst device use, first payment to payee, larger than anything sent in 60 days8072.5%58
R2 Night-time new payeeFirst payment to a payee, midnight to 5am, over $1,00010530.5%32
R3 New payee, 2x baselineFirst payment to a payee at least double the customer's largest prior transfer1,34111.5%154
R4 Card testing4+ online card purchases under $5 within 10 minutes260100%*60
R5 Shared deviceDevice already used by 2+ other customers486100%*85
R6 Mule pass-through3+ third-party credits in 12 hours, then a debit of 80%+ of them30100%*30
R7 Shared new payeeNew payee that 2+ other customers paid in the last 7 days6100%*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.

Line chart: fraud cases caught rises from 207 at 22 alerts a day to 225 at 90 alerts a day, flattening after the 2x threshold
Each point is the full 7-rule set with R3 at a different multiplier. 46-day scoring window.
R3 multiplierAlerts per dayPrecisionCases caught
1.0x89.716.1%225
1.5x56.125.8%225
2.0x (chosen)40.935.3%224
2.5x32.244.6%219
3.0x27.452.1%217
4.0x22.363.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).