Features reference
Ce contenu n’est pas encore disponible dans votre langue.
In short
Section titled “In short”Most money-laundering patterns aren’t visible in one transaction. They show up in totals over time: “six cash-ins in two days”, “twenty-five different senders this week”. A feature is one such number. A rule declares the features it needs, and SpeculaPro works them out, as of the moment of each transaction, from counters it keeps for every account, customer, counterparty and device.
"features": { "cash_in_sum_48h": { "entity": "account", "agg": "sum", "direction": "in", "type": "cash_in", "window": "48h" }},"expression": "features.cash_in_sum_48h >= params.threshold"Declaring a feature
Section titled “Declaring a feature”Each entry under features has a name (what the expression reads, as
features.<name>) and a spec:
| Field | Required | Values | Meaning |
|---|---|---|---|
entity |
Yes | account, customer, counterparty, device |
Whose activity to total (see below) |
agg |
Yes | count, sum, max, avg, distinct_counterparties, distinct_customers |
What to compute |
direction |
No | in, out, internal, any (default), event |
Which money movements count, or event for account events |
type |
No | A transaction type, such as cash_in, or with event an account event type, such as otp_failed |
Only these |
channel |
No | A channel, such as momo |
Only transactions on this channel |
window |
Yes | A duration from 1m to 4320h |
How far back to look |
round_minor |
No | 10000 or 100000 |
Only round amounts: multiples of 100.00 or 1,000.00 in your base currency. Needs direction in or out |
same_hour |
No | true |
Only transactions at the same hour of the day as the one being checked, in your workspace’s time zone. Needs direction any and a window of at least 24h |
all_hours |
No | true |
Every transaction the hour-of-day counters have seen, at any hour: the like-for-like total to compare a same_hour count with. Same conditions as same_hour; not together with it |
Rules for the spec:
- Names are lowercase letters, digits and underscores, starting with a letter:
cash_in_sum_48h, notCashIn48. - Filter by
typeor bychannel, not both. You can combine either one withdirection. - Distinct counts filter by direction only: no
typeorchannel. distinct_customersis for thedeviceandcounterpartyentities only.round_minor,same_hourandall_hourswork withcount,sum,maxandavg, without atypeorchannel, and only one of them per feature.- Windows are written in minutes or hours:
10m,30m,2h,24h,168h(7 days),720h(30 days),2160h(90 days),4320h(180 days, the maximum). The shortest is1m. Days (7d) are not understood; write hours. - An expression may only read features the rule declares. Anything else is rejected when you save.
What each aggregate returns
Section titled “What each aggregate returns”agg |
Returns | Example use |
|---|---|---|
count |
Number of matching transactions | Cash-ins in 48 hours |
sum |
Total amount, in base currency major units (cedis) | Inflows over 7 days |
max |
Largest single amount, in cedis | Biggest payment this month |
avg |
Average amount, in cedis (0 when there are none) | Typical outflow over 30 days |
distinct_counterparties |
How many different other parties | Senders to this wallet this week |
distinct_customers |
How many different customers (on a device), or of your accounts (on a counterparty) |
Customers who used this phone in 30 days |
Amounts are always in your base currency, after conversion, so a feature mixes GHS and USD transactions correctly.
The four entities
Section titled “The four entities”entity |
Counts the activity of | Other party for distinct counts |
|---|---|---|
account |
The account in this transaction, plus account events that name it | The counterparties it dealt with |
customer |
All of the customer’s accounts together, plus their account events | The counterparties they dealt with |
counterparty |
The other party, across all of your accounts that dealt with it | Your accounts |
device |
The phone or browser in this transaction (its device_fingerprint), across every customer who used it, plus account events from it |
The customers who used it |
The counterparty entity looks at the world from the other side. direction stays the same as on
your account (out means your account paid the counterparty), but the “other party” is your
account. So:
"payers_7d": { "entity": "counterparty", "agg": "distinct_counterparties", "direction": "out", "window": "168h" }means “how many of our accounts paid this counterparty in 7 days”. That’s how
core.many_payers_one_counterparty spots a scam collection wallet being paid by many of your
customers.
If a transaction has no counterparty, counterparty features read 0.
The device entity works the same way round: the “other party” is the customer. So
distinct_customers per device, over 30 days, is “how many of your customers used this phone”,
the signal behind the shared-device rule:
"device_customers_30d": { "entity": "device", "agg": "distinct_customers", "window": "720h" }A customer counts on a device from a transaction or an account event that carries its
fingerprint. If a transaction has no device_fingerprint, device features read 0.
Round amounts and hours of the day
Section titled “Round amounts and hours of the day”Two options narrow a count or total to a kind of transaction that structuring and account takeover give away:
"round1000_7d": { "entity": "account", "agg": "count", "direction": "in", "round_minor": 100000, "window": "168h" },"same_hour_90d": { "entity": "customer", "agg": "count", "same_hour": true, "window": "2160h" },"hour_counted_90d": { "entity": "customer", "agg": "count", "all_hours": true, "window": "2160h" }round_minorcounts only amounts that are exact multiples of 100.00 (10000minor units) or 1,000.00 (100000) in your base currency, after conversion. B4 uses it.same_hourcounts only transactions in the same clock hour (for example 02:00 to 02:59) as the transaction being checked, in every direction. Hours are those of your workspace’s time zone, the same hour rules see astxn.hour, so daylight saving doesn’t shift a customer’s usual hours. The total includes the transaction being checked, so “never before at this hour” is a count of 1. C6 uses it.all_hourscounts every transaction the hour-of-day counters have seen, whatever the hour. Compare asame_hourcount with this, not with a plain count over the same window.
Counting account events
Section titled “Counting account events”With "direction": "event", a feature counts account events
instead of money movements: logins, failed OTPs, SIM swaps and so on. Name the event in type:
"otp_failed_30m": { "entity": "customer", "agg": "count", "direction": "event", "type": "otp_failed", "window": "30m" }- Only
countworks with events, atypeis required, and there is nochannel. - Every event counts on its customer. It also counts on its account when it carries
account_external_id, and on its device when it carries adevice_fingerprint. So with theaccountentity you count only events about that account, and withcustomerevery event of the customer’s. - Events never mix into money counts.
"direction": "any"counts transactions only. - The current transaction is not an event, so it adds nothing to an event count.
For “has it happened recently?” rather than “how many times?”, you don’t need a feature. Read
customer.hours_since, which holds the hours since the
latest event of each type:
'sim_swap' in customer.hours_since && customer.hours_since.sim_swap < params.sim_change_hoursWhich period a window really covers
Section titled “Which period a window really covers”Counters are kept in time buckets, not transaction by transaction. That keeps them fast and small, but it means a window is rounded to whole buckets:
| Window | Bucket size | Covers |
|---|---|---|
Up to 15m |
Minutes | The current minute so far, plus the previous whole minutes |
16m to 2h |
5-minute blocks | The current 5-minute block so far, plus the previous whole blocks |
Over 2h, up to 48h |
Clock hours | The current hour so far, plus the previous whole hours |
Over 48h |
Calendar days (and 10-day blocks) | Today so far, plus the previous whole days |
Buckets follow UTC clock minutes, hours and calendar days. Ghana is on UTC, so for Ghanaian workspaces these are local hours and days.
Worked example
Section titled “Worked example”A transaction arrives at 14:35 on Tuesday.
| Window | Actually covers | Length |
|---|---|---|
10m |
14:26 → 14:35 | 9 min + |
30m |
14:10 → 14:35 | 25 min + |
24h |
Monday 15:00 → Tuesday 14:35 | 23 h 35 min |
48h |
Sunday 15:00 → Tuesday 14:35 | 47 h 35 min |
168h (7 days) |
Wednesday 00:00 of last week → Tuesday 14:35 | 6 days 14 h 35 min |
720h (30 days) |
29 whole days before today, plus today so far | 29 days + |
So a window is never longer than you asked for, but it can be up to one bucket shorter: up to
a minute for the shortest windows, 5 minutes up to 2h, an hour up to 48h, and a day for
longer ones.
Each transaction sees its own moment
Section titled “Each transaction sees its own moment”Features are worked out as of each transaction, in order:
- The current transaction is included. A
countof cash-ins on the third cash-in of the day reads3, not2. This holds in real-time decisions too: the payment being decided is counted. - Later transactions never leak in. If ten transactions arrive in one batch, the fifth sees exactly the first five, as if they had arrived one by one.
- Updates count once. If a transaction is delivered twice, the second delivery doesn’t change any counter.
- A decided transaction is counted once. A transaction decided in real time and then monitored post-event updates the counters once, and the post-event rules reuse the values the decision read wherever they declare the same features.
- Results are kept for replays. The values each transaction saw are stored for a short replay window (30 minutes by default), so if a batch is re-delivered after a crash or restart, the rules see the same numbers and produce the same hits as the first time. Your operator sets this window; see Replays and retries.
Two rules that declare the same spec under different names share one lookup, so declaring the features you need costs very little.
How long history lasts
Section titled “How long history lasts”| Bucket | Kept for about | Serves windows up to |
|---|---|---|
| Minutes | 20 minutes | 15m |
| 5-minute blocks | 2 hours 10 minutes | 2h |
| Hours | 50 hours | 48h |
| Days and 10-day blocks | 182 days | 4320h (180 days) |
That’s why windows stop at 180 days: anything longer would read buckets that have already expired.
Short windows start when a rule needs them
Section titled “Short windows start when a rule needs them”Hour and day buckets record every transaction. Minute and 5-minute buckets are expensive at high
volume, so they record a transaction counter only while some running rule (live or shadow,
real-time or post-event) reads that counter with a window of 2h or less. Account events always
go into minute buckets.
So when you switch on a rule with a short window that no other rule uses, its feature starts from
zero and fills up over one window length: a 10m count is complete after 10 minutes, a 2h
count after 2 hours. Longer windows are complete straight away.
Late and backdated data
Section titled “Late and backdated data”A bucket is thrown away a fixed time after it ends, not after the data arrived. A transaction or account event that reaches SpeculaPro late is therefore missing from the short buckets that have already gone:
| It arrives later than about | It doesn’t count toward windows of |
|---|---|
| 20 minutes after it happened | 15m or less |
| 2 hours 10 minutes | 2h or less |
| 50 hours | 48h or less |
It still counts in every longer window. For a transaction made now, data that old is outside the
short window anyway, so live traffic isn’t affected. It matters when you send a backlog: a
batch of an hour’s transactions sent an hour late is evaluated at each transaction’s own time,
and its short-window counts come out too low. A card-testing burst that arrives late in one batch
won’t trip a 10m rule. Send in real time where short-window rules matter.
Counters only include transactions SpeculaPro has received. When you start sending data, every feature starts at zero and builds up. A 30-day rule needs 30 days of history before its numbers mean what they say.