Velocity Checks for Wire-Transfer Anomaly Detection
A commercial beneficiary that received two wires in the last quarter takes in eleven between 08:00 and 10:00 on a Tuesday, totaling $1.9M. No single wire is remarkable; the rate is. Velocity anomaly is the signal that catches account-takeover cash-out, mule-account fan-in, and vendor-impersonation bursts that every per-transaction rule waves through, and it is the second signal in the fused scorer described under fraud-pattern detection signals, part of the broader exception routing and fraud-pattern detection practice. This guide builds the detector behind it: time-decayed sliding-window counters of both count and Decimal value, keyed per beneficiary and per originator, that flag a transfer when its window exceeds a calibrated baseline.
Velocity is a stateful signal, which makes it harder than the deterministic duplicate-debit check. It has to remember recent history, decay it so a burst three weeks ago does not haunt a normal Tuesday, and do so per key without letting the state grow unbounded across millions of beneficiaries. Fedwire and CHIPS give you the identifiers to key on — the beneficiary from the {4200} field of a Fedwire message or CdtrAcct in an ISO 20022 pacs.008, and the originator from {5000}/DbtrAcct — and the IMAD timestamp for ordering. The engineering is in the counter: a decayed sum that updates in constant time and compares against a baseline that means something for this corridor.
Concept Spec: Time-Decayed Sliding Windows
A raw fixed-window counter (wires in the last hour) is cheap but brittle: it resets on a boundary, so an attacker who straddles the reset doubles their effective allowance. A time-decayed counter avoids the boundary problem by weighting each event by how old it is. With an exponential decay constant and events at times before the current time , the decayed count is:
The elegance is that this sum need never be recomputed over stored events. Because , the running value decays by a single multiply since the last update and then adds the new event's weight. Each update is therefore time and each key holds state — just a scalar and a timestamp — so tracking active keys is memory with no per-event history retained. That constant-space property is what makes velocity tractable across an institution's entire beneficiary population; a naive design that stores every event per key is memory and collapses under a large corridor.
The half-life is the intuitive tuning unit: it is the time for a past event's contribution to fall to half. You calibrate the half-life to the corridor's natural rhythm and the threshold to its baseline volume, then flag when the current decayed value exceeds a multiple of that baseline.
Full Annotated Implementation
The tracker below keeps, per key, a decayed count and a decayed Decimal value sum, updating both in constant time. Amounts are Decimal throughout — a velocity sum that drifts on float rounding would move the flag boundary nondeterministically. The decay is applied lazily on each touch, so idle keys cost nothing until they are read.
from __future__ import annotations
import math
from dataclasses import dataclass, field
from decimal import Decimal
@dataclass(slots=True)
class VelocityState:
"""Constant-size decayed counters for one key."""
count: float = 0.0 # decayed event count (weights are fractional)
value: Decimal = Decimal("0")
last_ts: float = 0.0 # epoch seconds of last update
@dataclass(frozen=True, slots=True)
class VelocityVerdict:
key: str
decayed_count: float
decayed_value: Decimal
flagged: bool
reason: str
class VelocityTracker:
"""Time-decayed sliding-window velocity per beneficiary or originator.
`half_life_s` sets how fast history fades; `count_limit` and
`value_limit` are the calibrated per-corridor baselines above which a
transfer is flagged. Update and query are both O(1) per key.
"""
def __init__(
self,
half_life_s: float,
count_limit: float,
value_limit: Decimal,
) -> None:
self._lambda = math.log(2) / half_life_s
self._count_limit = count_limit
self._value_limit = value_limit
self._state: dict[str, VelocityState] = {}
def _decay_factor(self, st: VelocityState, now: float) -> float:
elapsed = max(0.0, now - st.last_ts)
return math.exp(-self._lambda * elapsed)
def observe(self, key: str, amount: Decimal, now: float) -> VelocityVerdict:
"""Record a wire on `key` at epoch `now`, then return the verdict.
The decayed value BEFORE this event is what we threshold against, so
a single legitimate large wire does not flag itself; it is the
accumulated recent velocity that trips the limit.
"""
st = self._state.get(key) or VelocityState(last_ts=now)
decay = self._decay_factor(st, now)
prior_count = st.count * decay
prior_value = st.value * Decimal(repr(decay))
flagged = prior_count >= self._count_limit or prior_value >= self._value_limit
reason = "ok"
if prior_count >= self._count_limit:
reason = f"count {prior_count:.2f} >= {self._count_limit}"
elif prior_value >= self._value_limit:
reason = f"value {prior_value} >= {self._value_limit}"
# Fold this event in and persist the decayed-forward state.
st.count = prior_count + 1.0
st.value = prior_value + amount
st.last_ts = now
self._state[key] = st
return VelocityVerdict(key, st.count, st.value, flagged, reason)
Calibration per Corridor
A single global limit is the classic velocity mistake: it is loose enough for the busiest payroll originator, which makes it useless for a dormant consumer account. Calibrate the half-life and both limits per corridor, keyed by the same (rail, direction, customer-segment) tuple your routing already uses:
- Retail beneficiary, inbound. Short half-life (hours) and a low
count_limit(e.g. 3 in the decayed window). A consumer account receiving five wires in an hour is anomalous by definition; the value limit matters less than the count because fan-in mule activity is about many transfers, not one large one. - Commercial payroll originator, outbound. Long half-life (a day or more) and a high
count_limit, because a payroll run legitimately fires hundreds of transfers in a burst. Here thevalue_limiton aggregate outbound value is the real guardrail, and it should be set from the customer's own historical peak run, not a global figure. - Cross-border corridor. Widen the half-life to absorb time-zone-clustered settlement and set the count limit from the corridor's baseline throughput. A corridor that normally clears twenty wires a day tolerates a higher decayed count than one that clears two.
Set each limit as a multiple of the key's own observed baseline rather than an absolute number wherever you have the history — a beneficiary that averages a decayed count of 1.0 and suddenly reads 8.0 is anomalous even if 8 would be normal elsewhere.
Before and After
Trace one commercial beneficiary BOFAUS3N/0114700129 with a 3600-second half-life, count_limit = 4.0, and value_limit = Decimal("700000"), receiving five wires:
| IMAD time | amount | decayed count (before) | decayed value (before) | flag |
|---|---|---|---|---|
| 08:00:00 | $180,000 | 0.00 | $0 | no |
| 08:12:00 | $220,000 | 0.87 | $156,699 | no |
| 08:40:00 | $310,000 | 1.35 | $272,593 | no |
| 09:05:00 | $450,000 | 1.76 | $436,451 | no |
| 09:20:00 | $540,000 | 2.32 | $745,414 | value ≥ $700K → flag |
The decayed count never reaches 4, but by 09:20 the decayed value of recent inbound wires crosses the $700,000 limit and the fifth transfer is flagged for review while the earlier ones cleared. Note that decay works against the raw arithmetic sum — the five amounts total $1.7M, yet the decayed prior value at the fifth wire is only ~$745K, because the 08:00 wire has already faded to roughly a third of its weight. A fixed-window count rule set at "5 per hour" would have posted all five, because none individually breaks it and the fifth arrives right at the count boundary. The decayed value counter is what surfaces the fan-in that a count-only rule misses.
Failure Modes & Guardrails
- Cold-start beneficiaries. A brand-new beneficiary has no baseline, so any absolute limit either flags its first legitimate wire or lets a mule account run freely until history accrues. Do not let velocity auto-decide on a cold key: for a first-seen beneficiary, hand the decision to the fused scorer where the beneficiary-novelty signal already carries weight, and require a short observation period before the velocity baseline is trusted.
- Payroll and settlement bursts. A legitimate payroll run or an end-of-day settlement sweep is, by design, a velocity spike. Without a corridor-specific
count_limitthese flood the review queue every pay cycle. Calibrate the originator's limit from its own historical peak run and, where the schedule is known, suppress the signal during the expected window rather than pushing hundreds of predictable alerts to analysts. - Timezone and clock skew. Decay depends on elapsed time, so a wrong
nowcorrupts the counter. Use the settlement timestamp (IMAD/OMAD orIntrBkSttlmDt) consistently in UTC, never the local ingestion clock, or a file processed late will decay history too far and a burst processed early will not decay enough. A single mixed-timezone feed can silently disable the signal.
Frequently Asked Questions
Why time-decay instead of a fixed hourly window?
A fixed window resets on its boundary, so activity that straddles the reset is split across two windows and never trips the limit — an attacker simply times the burst around the boundary. Exponential decay has no boundary: every past event contributes a smoothly shrinking weight, so a burst is detected regardless of where it falls relative to any clock tick, and the running value updates in constant time without storing individual events.
Should I key on the beneficiary or the originator?
Both, as separate trackers. Beneficiary velocity catches fan-in — many originators paying one mule account — while originator velocity catches fan-out and account-takeover cash-out from one compromised account. They detect different typologies, so run them in parallel and let the fused scorer weigh whichever fires.
Why keep the value sum as Decimal when the count is a float?
Because the value sum is money and the count is not. A decayed count is inherently fractional — a weight of 0.81 is meaningful — so a float is appropriate. The value sum is compared against a currency limit and reported into audit, so it must be exact; a float value sum would drift the flag boundary nondeterministically at volume, exactly the artifact Decimal exists to remove.
How do I set the limits without flooding the desk?
Calibrate per corridor from real baselines, not a global constant, and prefer a multiple of each key's own historical velocity over an absolute number. A retail account and a payroll originator have baselines orders of magnitude apart; one shared limit is guaranteed to be simultaneously too tight for one and too loose for the other. Then feed the flag into the fused scorer so it is weighed alongside novelty and structuring rather than auto-holding on velocity alone.
Related
- Fraud-Pattern Detection Signals in Payment Reconciliation — the parent guide where this velocity signal fuses with duplicate, structuring, and Benford checks.
- Detecting Duplicate ACH Debits with Idempotency Hashing — the deterministic sibling signal that catches exact repeats rather than bursts.
- Applying Benford's Law to Payment-Amount Anomaly Detection — a batch-level amount signal that complements per-key velocity.
- Sliding Window Date Reconciliation — the matching-side use of windows, upstream of where velocity anomalies surface as breaks.