mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
standard, history, measure: citations and pure behavioural measures
standard models a citation to a published requirement: authority, document, locator, URL. A control that cannot name the standard it implements cannot be defended to an examiner, so the library rejects an incomplete citation. history offers one query — the subject's event window — so the tenant filter lives in one place and every measure becomes a pure function of the window. measure holds those functions. Structuring is the aggregation of sub-threshold transactions reaching the threshold, not a band check on one amount. Currency reporting aggregates a calendar day in the institution's zone, not a rolling 24 hours. Deviation uses the median and median absolute deviation because a monitoring baseline is built from data that already contains the outliers being looked for, and one past outlier inflates a standard deviation enough to hide the next one. 15 of 15 mutations to the measure guards are killed by the suite. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
// Package history retrieves a subject's past transactions.
|
||||
//
|
||||
// The package offers exactly one query: the window of events for a subject over
|
||||
// a lookback period. Every behavioural measure the rule library needs — counts,
|
||||
// sums, distinct counterparties, sub-threshold aggregation, dormancy, in-out
|
||||
// alternation, deviation from a baseline — is a pure function of that window,
|
||||
// computed in the measure package. One query keeps the tenant filter in one
|
||||
// place and turns the measures into ordinary testable functions.
|
||||
package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Subject kinds. A subject is whoever the aggregation is grouped by: the
|
||||
// customer, one of their accounts, a counterparty, or a device or address they
|
||||
// transacted from. Grouping by device and address is what surfaces several
|
||||
// nominally-unrelated customers acting as one.
|
||||
const (
|
||||
SubjectUser = "user"
|
||||
SubjectAccount = "account"
|
||||
SubjectCounterparty = "counterparty"
|
||||
SubjectDevice = "device"
|
||||
SubjectAddress = "address"
|
||||
)
|
||||
|
||||
// Directions. Direction is required for in-out and pass-through detection:
|
||||
// the pattern is defined by funds leaving shortly after they arrive, which is
|
||||
// invisible if the store records only magnitude.
|
||||
const (
|
||||
In = "in"
|
||||
Out = "out"
|
||||
)
|
||||
|
||||
// Subject identifies whose history to aggregate. OrgID is part of the identity,
|
||||
// not a filter applied afterwards, so a query cannot accidentally aggregate
|
||||
// across tenants.
|
||||
type Subject struct {
|
||||
OrgID string
|
||||
Kind string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Event is one past transaction, reduced to the fields the measures read.
|
||||
// USD holds the value converted at ingestion time, because a threshold measured
|
||||
// today over a window of historical events must not silently move when a rate
|
||||
// moves.
|
||||
type Event struct {
|
||||
ID string
|
||||
At time.Time
|
||||
USD float64
|
||||
Currency string
|
||||
Direction string
|
||||
Counterparty string
|
||||
Account string
|
||||
Device string
|
||||
Address string
|
||||
Jurisdiction string
|
||||
Symbol string
|
||||
}
|
||||
|
||||
// Store retrieves event windows.
|
||||
//
|
||||
// Window returns the subject's events with At in [now-lookback, now], most
|
||||
// recent first. Implementations must scope to Subject.OrgID and must return an
|
||||
// error rather than a partial window: a measure computed over a truncated
|
||||
// window silently under-reports, which is the failure mode a monitoring system
|
||||
// can least afford.
|
||||
type Store interface {
|
||||
Window(ctx context.Context, subj Subject, lookback time.Duration) ([]Event, error)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// Package measure computes behavioural measures over a window of events.
|
||||
//
|
||||
// Every function here is pure: it takes the events and returns a number or a
|
||||
// boolean. Nothing reaches a database, a clock, or a network. That is what makes
|
||||
// the detection logic testable at the level a reviewer cares about — "given
|
||||
// these five deposits, does structuring fire?" — instead of only through the
|
||||
// storage layer.
|
||||
package measure
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/aml/pkg/history"
|
||||
)
|
||||
|
||||
// Count is the number of events in the window.
|
||||
func Count(evs []history.Event) int { return len(evs) }
|
||||
|
||||
// Sum is the total USD value of the events in the window.
|
||||
func Sum(evs []history.Event) float64 {
|
||||
var t float64
|
||||
for _, e := range evs {
|
||||
t += e.USD
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Max is the largest single USD value in the window, or zero if empty.
|
||||
func Max(evs []history.Event) float64 {
|
||||
var m float64
|
||||
for _, e := range evs {
|
||||
if e.USD > m {
|
||||
m = e.USD
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Distinct counts the distinct non-empty values of a key over the window.
|
||||
// Passing a counterparty key answers "how many different parties", which
|
||||
// separates one large relationship from a fan-out across many.
|
||||
func Distinct(evs []history.Event, key func(history.Event) string) int {
|
||||
seen := make(map[string]struct{}, len(evs))
|
||||
for _, e := range evs {
|
||||
if v := key(e); v != "" {
|
||||
seen[v] = struct{}{}
|
||||
}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// Sum of the events in one direction.
|
||||
func SumDirection(evs []history.Event, dir string) float64 {
|
||||
var t float64
|
||||
for _, e := range evs {
|
||||
if e.Direction == dir {
|
||||
t += e.USD
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Structured reports whether the window holds a set of transactions each below
|
||||
// the reporting threshold that together reach or exceed it.
|
||||
//
|
||||
// This is the detectable signature of the conduct prohibited by 31 USC 5324 and
|
||||
// 31 CFR 1010.314: breaking an amount that would be reportable into several
|
||||
// amounts that individually are not. The predicate deliberately requires at
|
||||
// least two sub-threshold transactions — a single reportable transaction is a
|
||||
// filing obligation, not structuring — and ignores transactions at or above the
|
||||
// threshold, because those were reported and so were not evaded.
|
||||
//
|
||||
// min is the count of sub-threshold transactions required before the pattern is
|
||||
// reported, so an institution can tune away the noise of ordinary repeat
|
||||
// activity without changing the definition. Values below 2 have no effect: one
|
||||
// transaction below the threshold cannot by itself reach the threshold, so the
|
||||
// aggregate test already excludes it.
|
||||
func Structured(evs []history.Event, threshold float64, min int) bool {
|
||||
if threshold <= 0 {
|
||||
return false
|
||||
}
|
||||
var n int
|
||||
var total float64
|
||||
for _, e := range evs {
|
||||
if e.USD > 0 && e.USD < threshold {
|
||||
n++
|
||||
total += e.USD
|
||||
}
|
||||
}
|
||||
return n >= min && total >= threshold
|
||||
}
|
||||
|
||||
// Near reports whether a value sits in the band immediately below a threshold.
|
||||
// Amounts just under a reporting threshold are a published red-flag indicator
|
||||
// in their own right, independent of whether they aggregate over the threshold.
|
||||
// band is a fraction of the threshold: 0.1 covers 9,000-10,000 at 10,000. A band
|
||||
// wider than the whole threshold is rejected rather than clamped, because it
|
||||
// would silently turn the indicator into "any amount below the threshold".
|
||||
func Near(value, threshold, band float64) bool {
|
||||
if band <= 0 || band > 1 {
|
||||
return false
|
||||
}
|
||||
return value >= threshold*(1-band) && value < threshold
|
||||
}
|
||||
|
||||
// Day totals the events falling on the same calendar day as when, in loc.
|
||||
//
|
||||
// A rolling lookback and a calendar day are different questions, and a currency
|
||||
// reporting threshold asks the calendar one: 31 CFR 1010.311 aggregates multiple
|
||||
// currency transactions totalling more than the threshold in one business day. A
|
||||
// 24-hour rolling window spans two business days, so it both invents aggregates
|
||||
// that no single day contains and misses same-day activity that has already
|
||||
// fallen out of the window. loc is the institution's business day, which is not
|
||||
// necessarily the server's.
|
||||
func Day(evs []history.Event, loc *time.Location, when time.Time) float64 {
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
day := when.In(loc).Format(time.DateOnly)
|
||||
var t float64
|
||||
for _, e := range evs {
|
||||
if e.At.In(loc).Format(time.DateOnly) == day {
|
||||
t += e.USD
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// InOut reports whether value arrived and left again within the window, keeping
|
||||
// no more than residue behind.
|
||||
//
|
||||
// Funds passing straight through an account is the layering signature described
|
||||
// in FATF typology work: the account is a conduit, not a store of value. The
|
||||
// test is that both directions are present, that the smaller of the two sides
|
||||
// is at least min, and that the retained fraction is no greater than residue.
|
||||
func InOut(evs []history.Event, min, residue float64) bool {
|
||||
in := SumDirection(evs, history.In)
|
||||
out := SumDirection(evs, history.Out)
|
||||
if in < min || out < min {
|
||||
return false
|
||||
}
|
||||
kept := math.Abs(in-out) / math.Max(in, out)
|
||||
return kept <= residue
|
||||
}
|
||||
|
||||
// Dormancy is the time between the two most recent events in the window, which
|
||||
// is the gap the newest event broke. It returns zero when the window holds
|
||||
// fewer than two events, because a gap needs two ends.
|
||||
//
|
||||
// A long-idle account that suddenly transacts is a published indicator; the
|
||||
// measure reports the gap and leaves the threshold to the rule.
|
||||
func Dormancy(evs []history.Event) time.Duration {
|
||||
if len(evs) < 2 {
|
||||
return 0
|
||||
}
|
||||
at := make([]time.Time, 0, len(evs))
|
||||
for _, e := range evs {
|
||||
at = append(at, e.At)
|
||||
}
|
||||
sort.Slice(at, func(i, j int) bool { return at[i].After(at[j]) })
|
||||
return at[0].Sub(at[1])
|
||||
}
|
||||
|
||||
// Round reports the fraction of the window's events whose value is an exact
|
||||
// multiple of unit. Genuine commercial amounts carry odd cents and odd units;
|
||||
// a book of perfectly round amounts is a published indicator of invented
|
||||
// invoicing and of value moved for its own sake rather than for a trade.
|
||||
func Round(evs []history.Event, unit float64) float64 {
|
||||
if unit <= 0 || len(evs) == 0 {
|
||||
return 0
|
||||
}
|
||||
var n int
|
||||
for _, e := range evs {
|
||||
if e.USD > 0 && math.Mod(e.USD, unit) == 0 {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return float64(n) / float64(len(evs))
|
||||
}
|
||||
|
||||
// Deviation is the robust modified z-score of value against the window as
|
||||
// baseline: 0.6745 * (value - median) / medianAbsoluteDeviation.
|
||||
//
|
||||
// The median and the median absolute deviation are used rather than the mean and
|
||||
// the standard deviation because a monitoring baseline is built from data that
|
||||
// already contains the outliers being looked for. One large past transaction
|
||||
// inflates a standard deviation enough to hide the next one, whereas the median
|
||||
// absolute deviation is unmoved by up to half the sample. The 0.6745 constant
|
||||
// scales the median absolute deviation onto the standard-deviation scale for a
|
||||
// normal distribution, so a threshold of 3.5 carries its conventional meaning.
|
||||
//
|
||||
// Deviation returns zero when the window is too small to carry a baseline, or
|
||||
// when the baseline has no spread at all. Both cases mean "no opinion", and a
|
||||
// measure with no opinion must not manufacture one.
|
||||
func Deviation(evs []history.Event, value float64, min int) float64 {
|
||||
if min < 4 {
|
||||
min = 4
|
||||
}
|
||||
if len(evs) < min {
|
||||
return 0
|
||||
}
|
||||
vals := make([]float64, 0, len(evs))
|
||||
for _, e := range evs {
|
||||
vals = append(vals, e.USD)
|
||||
}
|
||||
med := median(vals)
|
||||
|
||||
dev := make([]float64, len(vals))
|
||||
for i, v := range vals {
|
||||
dev[i] = math.Abs(v - med)
|
||||
}
|
||||
mad := median(dev)
|
||||
if mad == 0 {
|
||||
return 0
|
||||
}
|
||||
return 0.6745 * (value - med) / mad
|
||||
}
|
||||
|
||||
// median returns the median of vals. It sorts a copy, because a measure must
|
||||
// not reorder its caller's slice.
|
||||
func median(vals []float64) float64 {
|
||||
if len(vals) == 0 {
|
||||
return 0
|
||||
}
|
||||
s := make([]float64, len(vals))
|
||||
copy(s, vals)
|
||||
sort.Float64s(s)
|
||||
mid := len(s) / 2
|
||||
if len(s)%2 == 1 {
|
||||
return s[mid]
|
||||
}
|
||||
return (s[mid-1] + s[mid]) / 2
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package measure
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/aml/pkg/history"
|
||||
)
|
||||
|
||||
// at builds an event n hours before a fixed reference instant.
|
||||
func at(hoursAgo int, usd float64, dir string) history.Event {
|
||||
ref := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC)
|
||||
return history.Event{
|
||||
At: ref.Add(-time.Duration(hoursAgo) * time.Hour),
|
||||
USD: usd,
|
||||
Direction: dir,
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredDetectsSplitDeposits(t *testing.T) {
|
||||
// Five deposits of 2,500 total 12,500 with no single one reportable.
|
||||
// This is the case a point-in-time threshold check cannot see.
|
||||
evs := []history.Event{
|
||||
at(1, 2500, history.In), at(3, 2500, history.In), at(5, 2500, history.In),
|
||||
at(7, 2500, history.In), at(9, 2500, history.In),
|
||||
}
|
||||
if !Structured(evs, 10000, 2) {
|
||||
t.Fatal("five sub-threshold deposits totalling 12,500 must report as structured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredIgnoresReportedTransaction(t *testing.T) {
|
||||
// A single 12,000 transaction is reportable and was reported. Aggregating it
|
||||
// into a structuring finding would accuse the customer of evading a
|
||||
// requirement the transaction actually triggered.
|
||||
evs := []history.Event{at(1, 12000, history.In)}
|
||||
if Structured(evs, 10000, 2) {
|
||||
t.Fatal("one above-threshold transaction is a filing obligation, not structuring")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredExcludesReportedAmountFromTheAggregate(t *testing.T) {
|
||||
// One reported 12,000 alongside two small deposits. The reported amount must
|
||||
// not be counted towards the evaded total: on its own the sub-threshold
|
||||
// activity is 2,000, nowhere near the threshold. Folding the reported 12,000
|
||||
// back in would manufacture a structuring finding out of a transaction that
|
||||
// was declared.
|
||||
evs := []history.Event{
|
||||
at(1, 12000, history.In), at(3, 1000, history.In), at(5, 1000, history.In),
|
||||
}
|
||||
if Structured(evs, 10000, 2) {
|
||||
t.Fatal("a declared transaction must not be counted towards an evaded aggregate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredRejectsUnconfiguredThreshold(t *testing.T) {
|
||||
// The realistic failure: an operator adds a structuring rule and leaves the
|
||||
// threshold and minimum count unset, so both arrive as zero. With no guard,
|
||||
// nothing is sub-threshold so the count is zero, the aggregate is zero, and
|
||||
// both "0 >= 0" tests pass — the rule reports every transaction ever seen,
|
||||
// including an empty window. An unconfigured control must detect nothing, not
|
||||
// everything.
|
||||
evs := []history.Event{at(1, 5000, history.In), at(2, 5000, history.In)}
|
||||
if Structured(evs, 0, 0) {
|
||||
t.Fatal("an unconfigured threshold must report nothing, not everything")
|
||||
}
|
||||
if Structured(nil, 0, 0) {
|
||||
t.Fatal("an unconfigured threshold over an empty window must report nothing")
|
||||
}
|
||||
if Structured(evs, -1, 2) {
|
||||
t.Fatal("a negative threshold must report nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredRequiresAggregateToReachThreshold(t *testing.T) {
|
||||
// Two small deposits that do not add up to the threshold are ordinary.
|
||||
evs := []history.Event{at(1, 500, history.In), at(2, 400, history.In)}
|
||||
if Structured(evs, 10000, 2) {
|
||||
t.Fatal("sub-threshold deposits below the threshold in aggregate must not report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredRespectsMinimumCount(t *testing.T) {
|
||||
evs := []history.Event{at(1, 9000, history.In), at(2, 9000, history.In)}
|
||||
if !Structured(evs, 10000, 2) {
|
||||
t.Fatal("two sub-threshold deposits over the threshold in aggregate must report at min=2")
|
||||
}
|
||||
if Structured(evs, 10000, 3) {
|
||||
t.Fatal("min=3 must suppress a two-transaction pattern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredCannotFireOnOneTransaction(t *testing.T) {
|
||||
// Even asking for min=1, one transaction below the threshold cannot reach the
|
||||
// threshold, so the aggregate test alone rules out single-transaction
|
||||
// structuring. This is why no separate floor on min is needed.
|
||||
evs := []history.Event{at(1, 9999, history.In)}
|
||||
if Structured(evs, 10000, 1) {
|
||||
t.Fatal("a single sub-threshold transaction can never aggregate to the threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearBand(t *testing.T) {
|
||||
if !Near(9500, 10000, 0.1) {
|
||||
t.Fatal("9,500 is inside the 10 percent band below 10,000")
|
||||
}
|
||||
if Near(8000, 10000, 0.1) {
|
||||
t.Fatal("8,000 is outside the 10 percent band below 10,000")
|
||||
}
|
||||
if Near(10000, 10000, 0.1) {
|
||||
t.Fatal("the threshold itself is reportable, not near")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearRejectsBandWiderThanTheThreshold(t *testing.T) {
|
||||
// A band above 1 puts the lower bound below zero, which would report every
|
||||
// amount under the threshold as sitting just beneath it — the indicator would
|
||||
// fire on a 1-dollar payment.
|
||||
if Near(1, 10000, 2) {
|
||||
t.Fatal("a band wider than the threshold must be rejected, not treated as unbounded")
|
||||
}
|
||||
if Near(9500, 10000, 1.0001) {
|
||||
t.Fatal("a band above 1 must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDayAggregatesCalendarNotRolling(t *testing.T) {
|
||||
// 6,000 at 23:00 on the 9th and 6,000 at 01:00 on the 10th are two hours
|
||||
// apart but fall in different calendar days. A rolling 24-hour sum reports
|
||||
// 12,000 and would file a currency report; the calendar-day total for the
|
||||
// 10th is 6,000 and no report is due.
|
||||
evs := []history.Event{
|
||||
{At: time.Date(2026, 3, 9, 23, 0, 0, 0, time.UTC), USD: 6000},
|
||||
{At: time.Date(2026, 3, 10, 1, 0, 0, 0, time.UTC), USD: 6000},
|
||||
}
|
||||
if got := Sum(evs); got != 12000 {
|
||||
t.Fatalf("rolling sum = %v, want 12000", got)
|
||||
}
|
||||
when := time.Date(2026, 3, 10, 1, 0, 0, 0, time.UTC)
|
||||
if got := Day(evs, time.UTC, when); got != 6000 {
|
||||
t.Fatalf("calendar day total = %v, want 6000 — the two deposits are on different days", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDayTotalsOnlyTheNamedDay(t *testing.T) {
|
||||
evs := []history.Event{
|
||||
{At: time.Date(2026, 3, 8, 10, 0, 0, 0, time.UTC), USD: 4000},
|
||||
{At: time.Date(2026, 3, 9, 10, 0, 0, 0, time.UTC), USD: 7000},
|
||||
{At: time.Date(2026, 3, 9, 14, 0, 0, 0, time.UTC), USD: 5000},
|
||||
}
|
||||
when := time.Date(2026, 3, 9, 14, 0, 0, 0, time.UTC)
|
||||
if got := Day(evs, time.UTC, when); got != 12000 {
|
||||
t.Fatalf("total for 9 March = %v, want 12000", got)
|
||||
}
|
||||
when = time.Date(2026, 3, 8, 10, 0, 0, 0, time.UTC)
|
||||
if got := Day(evs, time.UTC, when); got != 4000 {
|
||||
t.Fatalf("total for 8 March = %v, want 4000 — other days must not be counted", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDayHonoursLocation(t *testing.T) {
|
||||
// 22:00 and 02:00 UTC straddle midnight in UTC but are the same calendar day
|
||||
// in a UTC-5 zone. The business day belongs to the institution, not the
|
||||
// server, and getting this wrong moves a reporting obligation by a day.
|
||||
evs := []history.Event{
|
||||
{At: time.Date(2026, 3, 9, 22, 0, 0, 0, time.UTC), USD: 6000},
|
||||
{At: time.Date(2026, 3, 10, 2, 0, 0, 0, time.UTC), USD: 6000},
|
||||
}
|
||||
when := time.Date(2026, 3, 10, 2, 0, 0, 0, time.UTC)
|
||||
if got := Day(evs, time.UTC, when); got != 6000 {
|
||||
t.Fatalf("UTC day total = %v, want 6000", got)
|
||||
}
|
||||
west := time.FixedZone("UTC-5", -5*3600)
|
||||
if got := Day(evs, west, when); got != 12000 {
|
||||
t.Fatalf("UTC-5 day total = %v, want 12000 — both fall on 9 March locally", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDayDefaultsToUTC(t *testing.T) {
|
||||
evs := []history.Event{{At: time.Date(2026, 3, 9, 10, 0, 0, 0, time.UTC), USD: 5000}}
|
||||
when := time.Date(2026, 3, 9, 12, 0, 0, 0, time.UTC)
|
||||
if got := Day(evs, nil, when); got != 5000 {
|
||||
t.Fatalf("nil location must fall back to UTC, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInOutDetectsPassThrough(t *testing.T) {
|
||||
evs := []history.Event{at(2, 50000, history.In), at(1, 49000, history.Out)}
|
||||
if !InOut(evs, 10000, 0.05) {
|
||||
t.Fatal("50,000 in and 49,000 out retains 2 percent and must report as pass-through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInOutIgnoresAccumulation(t *testing.T) {
|
||||
// Money in with nothing going out is accumulation, not layering.
|
||||
evs := []history.Event{at(2, 50000, history.In), at(1, 40000, history.In)}
|
||||
if InOut(evs, 10000, 0.05) {
|
||||
t.Fatal("deposits with no withdrawal must not report as pass-through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInOutIgnoresRetainedFunds(t *testing.T) {
|
||||
// Half the money stayed. That is a customer using an account.
|
||||
evs := []history.Event{at(2, 50000, history.In), at(1, 25000, history.Out)}
|
||||
if InOut(evs, 10000, 0.05) {
|
||||
t.Fatal("retaining half the inflow must not report as pass-through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInOutIgnoresSmallAmounts(t *testing.T) {
|
||||
evs := []history.Event{at(2, 100, history.In), at(1, 99, history.Out)}
|
||||
if InOut(evs, 10000, 0.05) {
|
||||
t.Fatal("amounts below the minimum must not report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDormancyMeasuresTheGapBroken(t *testing.T) {
|
||||
evs := []history.Event{at(0, 40000, history.In), at(24*200, 100, history.In)}
|
||||
got := Dormancy(evs)
|
||||
if want := 200 * 24 * time.Hour; got != want {
|
||||
t.Fatalf("dormancy = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDormancyNeedsTwoEvents(t *testing.T) {
|
||||
if got := Dormancy([]history.Event{at(0, 100, history.In)}); got != 0 {
|
||||
t.Fatalf("dormancy with one event = %v, want 0 — a gap needs two ends", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDormancyDoesNotReorderCallerSlice(t *testing.T) {
|
||||
evs := []history.Event{at(0, 1, history.In), at(100, 2, history.In)}
|
||||
first := evs[0].USD
|
||||
Dormancy(evs)
|
||||
if evs[0].USD != first {
|
||||
t.Fatal("Dormancy must not reorder the caller's slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundFraction(t *testing.T) {
|
||||
evs := []history.Event{
|
||||
{USD: 10000}, {USD: 25000}, {USD: 5000}, {USD: 4237.19},
|
||||
}
|
||||
if got := Round(evs, 1000); got != 0.75 {
|
||||
t.Fatalf("round fraction = %v, want 0.75", got)
|
||||
}
|
||||
if got := Round(evs, 0); got != 0 {
|
||||
t.Fatalf("a non-positive unit must return 0, got %v", got)
|
||||
}
|
||||
if got := Round(nil, 1000); got != 0 {
|
||||
t.Fatalf("an empty window must return 0, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviationIsUnmovedByOneOutlier(t *testing.T) {
|
||||
// A customer whose ordinary transaction is about 100, with one past
|
||||
// 1,000,000. A mean-and-standard-deviation baseline is destroyed by that
|
||||
// outlier and scores the next 50,000 as unremarkable. The median absolute
|
||||
// deviation is not.
|
||||
evs := []history.Event{
|
||||
{USD: 100}, {USD: 105}, {USD: 95}, {USD: 102}, {USD: 98},
|
||||
{USD: 1_000_000},
|
||||
}
|
||||
z := Deviation(evs, 50000, 4)
|
||||
if z < 3.5 {
|
||||
t.Fatalf("robust deviation of 50,000 = %v, want >= 3.5 despite the outlier in the baseline", z)
|
||||
}
|
||||
|
||||
// Show the classical statistic fails on the same data, which is why it is
|
||||
// not the one used.
|
||||
if classical := zMeanStdDev(evs, 50000); math.Abs(classical) >= 3.5 {
|
||||
t.Fatalf("mean/stddev z = %v; the test premise requires it to miss (< 3.5)", classical)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviationHasNoOpinionOnSmallWindows(t *testing.T) {
|
||||
// The baseline has genuine spread, so the score would be enormous if it were
|
||||
// computed at all. Three events is still too thin a baseline to accuse anyone
|
||||
// on, and the measure must decline rather than produce a confident number
|
||||
// from almost no data.
|
||||
evs := []history.Event{{USD: 100}, {USD: 105}, {USD: 95}}
|
||||
if z := Deviation(evs, 999999, 4); z != 0 {
|
||||
t.Fatalf("deviation over 3 events with min 4 = %v, want 0 — too little baseline to judge", z)
|
||||
}
|
||||
// Adding a fourth event of the same shape reaches the minimum and the measure
|
||||
// then does have an opinion, which proves the gate is the window size and not
|
||||
// something else about the data.
|
||||
evs = append(evs, history.Event{USD: 102})
|
||||
if z := Deviation(evs, 999999, 4); z == 0 {
|
||||
t.Fatal("at the minimum window the measure must produce a score")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviationHasNoOpinionWithoutSpread(t *testing.T) {
|
||||
// Every past amount identical: the median absolute deviation is zero and the
|
||||
// score would divide by zero. No opinion is the honest answer.
|
||||
evs := []history.Event{{USD: 100}, {USD: 100}, {USD: 100}, {USD: 100}, {USD: 100}}
|
||||
if z := Deviation(evs, 100000, 4); z != 0 {
|
||||
t.Fatalf("deviation with zero spread = %v, want 0", z)
|
||||
}
|
||||
}
|
||||
|
||||
// zMeanStdDev is the classical z-score, present only so the test above can
|
||||
// demonstrate the failure it avoids.
|
||||
func zMeanStdDev(evs []history.Event, value float64) float64 {
|
||||
if len(evs) < 2 {
|
||||
return 0
|
||||
}
|
||||
var mean float64
|
||||
for _, e := range evs {
|
||||
mean += e.USD
|
||||
}
|
||||
mean /= float64(len(evs))
|
||||
var ss float64
|
||||
for _, e := range evs {
|
||||
ss += (e.USD - mean) * (e.USD - mean)
|
||||
}
|
||||
sd := math.Sqrt(ss / float64(len(evs)-1))
|
||||
if sd == 0 {
|
||||
return 0
|
||||
}
|
||||
return (value - mean) / sd
|
||||
}
|
||||
|
||||
func TestDistinctCountsParties(t *testing.T) {
|
||||
evs := []history.Event{
|
||||
{Counterparty: "a"}, {Counterparty: "b"}, {Counterparty: "a"}, {Counterparty: ""},
|
||||
}
|
||||
if got := Distinct(evs, func(e history.Event) string { return e.Counterparty }); got != 2 {
|
||||
t.Fatalf("distinct counterparties = %d, want 2 (empty values are not a party)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumAndCountAndMax(t *testing.T) {
|
||||
evs := []history.Event{{USD: 10}, {USD: 30}, {USD: 20}}
|
||||
if got := Sum(evs); got != 60 {
|
||||
t.Fatalf("sum = %v, want 60", got)
|
||||
}
|
||||
if got := Count(evs); got != 3 {
|
||||
t.Fatalf("count = %d, want 3", got)
|
||||
}
|
||||
if got := Max(evs); got != 30 {
|
||||
t.Fatalf("max = %v, want 30", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Package standard models a citation to a published regulatory standard.
|
||||
//
|
||||
// Every rule in the detection library carries at least one citation. The
|
||||
// citation is the reason the rule exists: it names the authority, the document,
|
||||
// and the locator within that document that requires or describes the control.
|
||||
// An examiner tests the institution against the standard, so a control that
|
||||
// cannot name its standard cannot be defended.
|
||||
package standard
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Authority is a standard-setting or supervisory body.
|
||||
type Authority string
|
||||
|
||||
const (
|
||||
// FATF is the Financial Action Task Force, which issues the 40
|
||||
// Recommendations, the assessment Methodology, and typology reports.
|
||||
FATF Authority = "FATF"
|
||||
// FinCEN is the US Financial Crimes Enforcement Network, which
|
||||
// administers the Bank Secrecy Act regulations at 31 CFR Chapter X
|
||||
// and issues advisories carrying red-flag indicators.
|
||||
FinCEN Authority = "FinCEN"
|
||||
// OFAC is the US Office of Foreign Assets Control, which publishes the
|
||||
// SDN and consolidated sanctions lists and their data specifications.
|
||||
OFAC Authority = "OFAC"
|
||||
// UN is the United Nations Security Council, whose committees maintain
|
||||
// the consolidated sanctions list.
|
||||
UN Authority = "UN"
|
||||
// EU is the European Union: the anti-money-laundering directives and
|
||||
// regulations, and the consolidated CFSP financial sanctions list.
|
||||
EU Authority = "EU"
|
||||
// UK covers UK statute and statutory instruments, and the HM Treasury
|
||||
// and OFSI financial sanctions regime.
|
||||
UK Authority = "UK"
|
||||
// Wolfsberg is the Wolfsberg Group, an association of international
|
||||
// banks publishing industry standards for financial crime compliance.
|
||||
Wolfsberg Authority = "Wolfsberg"
|
||||
// FFIEC is the US Federal Financial Institutions Examination Council,
|
||||
// whose BSA/AML examination manual states what examiners test.
|
||||
FFIEC Authority = "FFIEC"
|
||||
// BIS is the US Bureau of Industry and Security, which issues joint
|
||||
// export-control evasion alerts with FinCEN.
|
||||
BIS Authority = "BIS"
|
||||
)
|
||||
|
||||
// Citation locates a requirement or indicator within a published document.
|
||||
//
|
||||
// Locator is the smallest addressable unit the document offers: a
|
||||
// Recommendation number, an interpretive-note paragraph, a CFR section, an
|
||||
// article, a regulation number, or a named indicator group. It is what a
|
||||
// reviewer types into the document to land on the text.
|
||||
type Citation struct {
|
||||
Authority Authority `json:"authority"`
|
||||
Document string `json:"document"`
|
||||
Locator string `json:"locator"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// String renders the citation for a narrative or an audit export.
|
||||
func (c Citation) String() string {
|
||||
return fmt.Sprintf("%s, %s, %s", c.Authority, c.Document, c.Locator)
|
||||
}
|
||||
|
||||
// Valid reports whether the citation is complete enough to defend a control.
|
||||
// A citation missing any field cannot be checked by a reviewer, so the
|
||||
// library rejects it rather than shipping a claim nobody can verify.
|
||||
func (c Citation) Valid() bool {
|
||||
return c.Authority != "" && c.Document != "" && c.Locator != "" && c.URL != ""
|
||||
}
|
||||
Reference in New Issue
Block a user