Why Aggregates Matter
Consider a common fraud pattern: structuring (also called “smurfing”). A bad actor deliberately breaks a large transfer into multiple smaller ones to stay under reporting thresholds. Each individual transaction looks perfectly normal — it’s only the aggregate behavior that’s suspicious. A rule likewhen amount > 10000 would never catch this. The whole point of structuring is that each transaction is under the threshold.
But with aggregates, you can write:
The Anatomy of an Aggregate Call
Let’s break down a complete aggregate expression token by token:- Function name (
count,sum,avg,max,min) — What metric to compute. - Filter condition (
when destination == $current.destination) — Which historical transactions to include. Uses the same syntax as the rule’swhenclause, but applied to historical data. - Time window (
"PT24H") — How far back in time to look. - Comparison (
> 10) — The threshold to compare the computed metric against.
The when Filter Inside Aggregates
The filter condition inside an aggregate function determines which historical transactions are included in the computation. Without a filter, the aggregate would count or sum all transactions in the time window — which is rarely useful.
Using $current for Self-Referencing Filters
The $current variable is the key to making aggregates useful. It refers to the transaction currently being evaluated, allowing you to ask questions like “how many transactions from this same source” or “what’s the total amount to this same destination.”
$current.source is resolved to the actual value of the source field in the current transaction. If the current transaction has source: "acct_alice", then the filter becomes source == "acct_alice" and DuckDB queries:
Filter Field Selection
The filter condition always references a field in the historical transactions table. The most common filter fields are:Time Windows Explained
Every aggregate function requires a time window in ISO 8601 duration format. The time window defines the lookback period — how far into the past the aggregate should scan.Supported Formats
How the Parser Handles Durations
The interpreter uses a minimal ISO 8601 parser that supports:PT<n>H— hoursPT<n>M— minutesPT<n>S— secondsP<n>D— days (converted ton * 24 hours)
Choosing the Right Window Size
The time window you choose has a direct impact on both detection accuracy and performance:- Too small: You’ll miss patterns that span a longer period. A 1-hour window won’t catch structuring that happens over an entire day.
- Too large: You’ll get false positives from legitimate historical activity, and the query will be slower because DuckDB has to scan more data.
count() — Frequency Detection
count() returns the number of transactions that match the filter condition within the time window.
Signature
Return Value
An integer — the number of matching historical transactions.Use Cases
- Detecting an unusually high number of transactions (velocity abuse)
- Identifying rapid-fire micro-transactions (card testing)
- Counting failed attempts before a successful transaction
Example: High Frequency to Same Destination
Example: Rapid Small Burst
sum() — Volume Detection
sum() returns the total amount of all transactions that match the filter condition within the time window.
Signature
Return Value
A float — the sum of theamount field for all matching transactions.
Use Cases
- Detecting structuring (aggregate amount exceeds reporting threshold)
- Monitoring account draining (total outflow exceeds a limit)
- Enforcing daily/weekly transaction volume limits
Example: Source Account High Outflow
Example: Destination High Inflow
avg() — Behavioral Deviation
avg() returns the average amount of all transactions that match the filter condition within the time window.
Signature
Return Value
A float — the mean of theamount field for all matching transactions.
Use Cases
- Detecting transactions that are significantly larger than a user’s typical behavior
- Identifying sudden changes in spending patterns
- Baseline comparison for anomaly detection
Example: Transaction Far Exceeds Average
Note: This rule has two conditions connected byand. Theavg()aggregate computes the historical average, and theamount > 5000check ensures we only flag genuinely large transactions (not just any transaction from a low-activity account).
max() and min() — Detecting Extremes
max() returns the largest amount and min() returns the smallest amount among matching transactions.
Signatures
Use Cases for max()
- Detecting if the current transaction is a new peak for this account
- Identifying escalating transaction amounts (a common fraud progression pattern)
Use Cases for min()
- Detecting card testing alongside normal transactions (presence of micro-transactions)
- Identifying accounts that have recently started making unusually small transactions
Example: Escalating Transaction Amounts
Combining Aggregates with Simple Conditions
The most effective rules combine cheap simple checks with expensive aggregate checks. This follows the “gate and probe” pattern:- Gate: A cheap simple condition that filters out the vast majority of transactions.
- Probe: An expensive aggregate that deeply analyzes the remaining transactions.
The Gate-and-Probe Pattern
amount <= 100, the count() function is never called. In a system processing thousands of transactions per second, this can eliminate 80-90% of aggregate queries.
Combining Multiple Aggregates
You can use multiple aggregate functions in the same rule:count() and sum() to detect structuring: at least 3 transactions (frequency), totaling over 10,000 (sub-threshold).
Performance Considerations
Aggregate functions are the primary performance bottleneck in FinWatch because they execute SQL queries against DuckDB. Understanding how they work internally helps you write faster rules.How Aggregates Translate to SQL
Each aggregate function is compiled to a SQL query. The engine uses a mapping:
The full query template:
Batch Aggregate Context
FinWatch optimizes aggregate evaluation by pre-computing all aggregate values needed for a transaction in a single batch before rule evaluation begins. This is theBuildAggContext() function. It:
- Scans all active rules for aggregate conditions.
- Groups them by unique
(metric, time_window, filter_field, filter_value)tuples. - Executes one SQL query per unique tuple.
- Caches the results in a
map[string]float64.
count(when source == $current.source, "PT24H"), the SQL query only runs once.
Optimization Tips
- Use the smallest time window that works.
"PT1H"scans far less data than"P30D". - Gate aggregates with simple checks. Put
amount > Xbeforecount(...)in yourandchain. - Avoid redundant aggregates. If two rules check the same metric with the same filter and time window, the engine only queries once — but it’s still good practice to be aware of this.
- Monitor query times. FinWatch logs aggregate query execution times. Watch for queries that take more than 100ms — they may indicate that your time windows are too large or your transaction table is growing beyond DuckDB’s comfortable working set.
Next Steps
- Time-Based Rules — Combine aggregates with time functions for even more precise detection.
- Previous Transaction Lookups — Detect sequential patterns with
previous_transaction(). - The Rule Cookbook — Production-ready rule patterns that use aggregates extensively.
- DSL Reference — Complete function signatures and syntax.
- Production Deployment — Configure DuckDB memory limits and monitor aggregate performance.
.png?fit=max&auto=format&n=0JF6z69u57hmqsWm&q=85&s=531373acedba0eb783b669f6d558dfd8)