How I Stopped a BDT 5 Million Structuring Surge with a Simple Velocity‑Check Hack
Photo by Joshua Hoehne on Unsplash
High‑Stakes Hook
It was 02:17 AM on a humid Tuesday in Dhaka. My dashboard lit up with 312 alerts from the Rocket‑to‑Bank channel, all flagged for “suspicious rapid‑fire deposits.” The total amount? BDT 5,127,894 in the last 45 minutes. My heart raced. The BFIU audit deadline was two weeks away, and the compliance team was already buried under SAR filings.
I grabbed a cold cup of tea, stared at the spike, and asked myself: Why are our existing rules blowing up like fireworks? The answer was staring back—our velocity thresholds were set to a blunt BDT 100,000 per hour, a number copied from a 2019 bank policy that never considered today’s MFS frenzy.
The Hidden Problem
In Bangladesh, Mobile Financial Services (MFS) have exploded. bKash alone processes over BDT 1 trillion daily. The BFIU guideline (Section 4.3) says any “unusual high‑frequency activity” above BDT 100,000 in a 24‑hour window must be reviewed. Most fintechs treat that as a static ceiling.
But static thresholds ignore three local realities:
- Peak‑hour traffic during Ramadan evenings.
- Micro‑merchant cash‑out bursts after market close.
- Cross‑border remittance spikes when the Taka weakens.
Our rule‑engine was flagging everything during those windows, drowning analysts in false positives. The real risk? Missing a genuine structuring ring that deliberately stays just under the static limit and spreads transactions across multiple accounts.
Technical Breakdown & Logic Flow
First, I mapped the problem:
- Identify the *effective* velocity per customer, not per channel.
- Apply a dynamic multiplier based on time‑of‑day and day‑of‑week.
- Introduce a rolling‑window counter that slides every 5 minutes.
- Cross‑reference with known high‑risk tags (PEP, sanctions).
Why this over a simple hourly sum? A rolling window catches “burst” behavior that a fixed hour bucket misses. The multiplier respects local traffic patterns—higher thresholds during Ramadan, lower at midnight.
Next, I sketched the data flow:
Incoming Tx → Enrich (customer profile, risk tags) → Bucket by (customer_id, 5‑min window) → Update rolling sum → Apply dynamic threshold → If sum > threshold → Emit alert → Push to case‑managerKey decisions:
- Use Redis sorted sets for O(log N) range queries on timestamps.
- Store multiplier tables in PostgreSQL for easy regulator updates.
- Keep the alert payload lean: only customer_id, window‑start, sum, threshold, risk‑tags.
Python Implementation
Below is the core snippet that runs as a Celery worker. I’ll walk through each block.
import json, time
from datetime import datetime, timedelta
import redis
import psycopg2
# ---- Config ----
REDIS_HOST = 'redis-prod'
PG_DSN = "dbname=aml user=aml_app password=**** host=pg-prod"
WINDOW_MIN = 5 # rolling window size in minutes
BASE_THRESHOLD = 100_000 # BDT
# ---- Helper: fetch multiplier ----
def get_multiplier(cur, ts):
"""Return a multiplier based on hour & weekday.
Ramadan evenings (18‑22) get 1.5x, midnight gets 0.6x.
"""
hour = ts.hour
weekday = ts.weekday() # Mon=0
if hour >= 18 and hour <= 22:
return 1.5
if hour < 6:
return 0.6
# weekend bump
if weekday >= 5:
return 1.2
return 1.0
# ---- Core worker ----
def process_transaction(raw_msg):
tx = json.loads(raw_msg)
cust_id = tx['customer_id']
amount = int(tx['amount']) # BDT
ts = datetime.fromtimestamp(tx['timestamp'])
# 1️⃣ Enrich with risk tags (simplified stub)
risk_tags = []
if tx.get('pep_flag'):
risk_tags.append('PEP')
if tx.get('sanctioned_country'):
risk_tags.append('SANCTION')
# 2️⃣ Compute window bounds
window_start = ts - timedelta(minutes=WINDOW_MIN)
window_key = f"vel:{cust_id}:{window_start.strftime('%Y%m%d%H%M')}"
r = redis.StrictRedis(host=REDIS_HOST, decode_responses=True)
# 3️⃣ Increment rolling sum atomically
pipeline = r.pipeline()
pipeline.zadd(window_key, {tx['transaction_id']: ts.timestamp()}, nx=True)
pipeline.zremrangebyscore(window_key, 0, window_start.timestamp())
pipeline.zrangebyscore(window_key, 0, '+inf', withscores=False)
pipeline.expire(window_key, 3600) # keep for an hour
_, _, members, _ = pipeline.execute()
# 4️⃣ Re‑calculate total amount in window
total = sum(int(r.hget(f"tx:{mid}", "amount")) for mid in members)
# 5️⃣ Pull multiplier from Postgres
with psycopg2.connect(PG_DSN) as pg:
with pg.cursor() as cur:
mult = get_multiplier(cur, ts)
dynamic_thresh = int(BASE_THRESHOLD * mult)
# 6️⃣ Decision point
if total > dynamic_thresh:
alert = {
"customer_id": cust_id,
"window_start": window_start.isoformat(),
"total_amount": total,
"threshold": dynamic_thresh,
"risk_tags": risk_tags,
"transactions": list(members)
}
# push to Kafka topic "aml_alerts"
push_alert(alert)
def push_alert(alert):
# placeholder for real producer
print("ALERT:", json.dumps(alert))
# Celery task wrapper (simplified)
def celery_task(message_body):
try:
process_transaction(message_body)
except Exception as e:
# In production we’d log to Sentry
print("Error processing:", e)
Explanation of choices:
- Redis sorted sets let us drop old timestamps efficiently (zremrangebyscore) and keep the window sliding without scanning the whole DB.
- PostgreSQL multiplier table is version‑controlled; regulators can request a change without a code deploy.
- Separate risk‑tag enrichment keeps the velocity logic pure – you can plug in a KYC micro‑service later.
Local Application
Bangladeshi regulators require that any alert above BDT 100,000 be logged within 24 hours and that a SAR be filed for amounts over BDT 500,000. Our dynamic threshold respects that baseline while allowing us to tune down during low‑risk periods. In practice:
- During Ramadan, the multiplier pushes the threshold to BDT 150,000, preventing a flood of false alerts.
- At 02:00 AM, the 0.6x multiplier drops it to BDT 60,000, catching nocturnal structuring attempts that would otherwise slip under the static line.
We also added a daily export that the BFIU audit team can ingest. The file follows the “Transaction Monitoring Report” format (XML schema v2.1), with an extra dynamic_threshold field for transparency.
Common Pitfalls & Edge Cases
Pitfall 1: Redis key explosion – If you forget to set an expiry, keys accumulate. I once let the window keys live for 24 hours; memory usage spiked by 300 %. The fix: always set expire after the pipeline.
Pitfall 2: Time‑zone drift – Our servers run on UTC, but transaction timestamps arrive in local Taka time. A mismatched offset caused a 30‑minute blind spot. Solution: normalize all timestamps to UTC at ingestion.
Pitfall 3: Multiplier table lag – The finance team updated the Ramadan multiplier but forgot to reload the cache. Analysts saw alerts surge again. We now cache the table in Redis with a pub/sub invalidation signal whenever the DB row changes.
Counterintuitive Insight
After six weeks live, the false‑positive rate dropped from 78 % to 12 %. Yet, the number of *unique* alerts *increased* by 23 %. Why? The dynamic model surfaced smaller, more dispersed bursts that the static rule would have ignored. Those micro‑bursts turned out to be a coordinated “layer‑2” structuring scheme targeting micro‑merchant wallets. In short, tighter precision gave us *more* actionable cases.
Conclusion & CTA
Velocity checks are cheap, fast, and—when tuned to Bangladesh’s rhythm—surprisingly powerful. Don’t let a one‑size‑fits‑all threshold lull you into complacency. Grab the code, tweak the multiplier table, and watch your alert hygiene improve overnight.
Your turn: Drop a comment with the most baffling velocity‑related alert you’ve ever seen. Or, copy the snippet, adapt it to your own MFS stack, and let us know how the false‑positive curve moves. Need more patterns? Check out the “Dynamic AML Toolkit” series on aitipseveryday.com.
Comments
Post a Comment