Skip to content

Features reference

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"

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, not CashIn48.
  • Filter by type or by channel, not both. You can combine either one with direction.
  • Distinct counts filter by direction only: no type or channel.
  • distinct_customers is for the device and counterparty entities only.
  • round_minor, same_hour and all_hours work with count, sum, max and avg, without a type or channel, 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 is 1m. Days (7d) are not understood; write hours.
  • An expression may only read features the rule declares. Anything else is rejected when you save.
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.

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.

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_minor counts only amounts that are exact multiples of 100.00 (10000 minor units) or 1,000.00 (100000) in your base currency, after conversion. B4 uses it.
  • same_hour counts 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 as txn.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_hours counts every transaction the hour-of-day counters have seen, whatever the hour. Compare a same_hour count with this, not with a plain count over the same window.

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 count works with events, a type is required, and there is no channel.
  • 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 a device_fingerprint. So with the account entity you count only events about that account, and with customer every 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_hours

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.

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.

Features are worked out as of each transaction, in order:

  • The current transaction is included. A count of cash-ins on the third cash-in of the day reads 3, not 2. 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.

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.

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.