14 детекторов фрода
Каждый детектор — независимый сигнал с весом в скоринге карты (0–100): жёсткие факты (например мёртвая карта или дубль транзакции) весят 30–40, поведенческие сигналы — 15–25, контекстные — 10–15. Скор карты — сумма активных сигналов за 7 дней. При 50 — жёлтый алерт в очередь на разбор, при 75 — красный алерт с автоматическим фризом карты по конфигу. Ниже — принцип работы, алгоритм по шагам и пороги каждого детектора; SQL-реализация — в свёрнутом блоке для инженеров.
Каталог детекторов
Принцип. У каждой карты есть свой обычный режим работы. Если карта, которая исторически не заправлялась ночью, вдруг начинает делать это регулярно — это выбивается из её профиля.
- Берём заправки карты за последние 30 дней.
- Переводим время каждой заправки в локальное время АЗС, а не сервера — иначе весь Владивосток выглядел бы «ночным».
- Считаем долю заправок в интервале 00:00–05:00.
- Если таких заправок минимум 3 и они составляют больше четверти всех заправок карты — сигнал.
Порог: ≥3 ночных заправки за 30 дней И доля ночных > 25% всех транзакций карты
Реализация (SQL)
WITH tx AS (
SELECT ft."CardId",
ft."CreateTime" + make_interval(hours => r."TimzeZone") AS local_time,
ft."FullSum"
FROM "FinanceTransaction" ft
JOIN "GasStations" gs ON gs."Id" = ft."GasStationId"
JOIN "Region" r ON r."Id" = gs."RegionId"
WHERE ft."CreateTime" >= NOW()::timestamp - INTERVAL '30 days'
AND ft."NoFuelGoodId" IS NULL
)
SELECT "CardId",
COUNT(*) FILTER (WHERE EXTRACT(HOUR FROM local_time) BETWEEN 0 AND 4) AS night_tx,
COUNT(*) AS total_tx,
SUM("FullSum") FILTER (WHERE EXTRACT(HOUR FROM local_time) BETWEEN 0 AND 4) AS night_sum
FROM tx
GROUP BY "CardId"
HAVING COUNT(*) FILTER (WHERE EXTRACT(HOUR FROM local_time) BETWEEN 0 AND 4) >= 3
AND COUNT(*) FILTER (WHERE EXTRACT(HOUR FROM local_time) BETWEEN 0 AND 4)::float / COUNT(*) > 0.25;
Примечание: TimzeZone хранится как смещение (проверить единицы на реальных данных перед калибровкой).
Принцип. У каждой карты есть свой типичный объём разовой заправки. Когда новая заправка резко превышает исторический максимум карты — или, если у карты вовсе нет истории, кратно превышает разумную норму, — отклонение стоит проверить.
- Строим персональный 99-й процентиль объёма заправки карты по истории за 180 дней (нужно минимум 20 заправок).
- Сравниваем каждую новую заправку с этим порогом.
- Если объём больше чем в 1,5 раза выше порога и превышает 100 литров — сигнал.
- Для карт без собственной истории — сравнение с нормой компании.
Порог: объём > 1.5×p99 личной истории карты (мин. 20 заправок за 180 дней) и > 100 л
Реализация (SQL)
WITH card_p99 AS (
SELECT "CardId",
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY "LitersSum") AS p99_liters,
COUNT(*) AS hist_n
FROM "FinanceTransaction"
WHERE "CreateTime" >= NOW()::timestamp - INTERVAL '180 days'
AND "NoFuelGoodId" IS NULL AND "LitersSum" > 0
GROUP BY "CardId" HAVING COUNT(*) >= 20
)
SELECT ft."Id", ft."CardId", ft."CreateTime", ft."LitersSum", ft."FullSum", p.p99_liters
FROM "FinanceTransaction" ft
JOIN card_p99 p USING ("CardId")
WHERE ft."CreateTime" >= NOW()::timestamp - INTERVAL '7 days'
AND ft."LitersSum" > GREATEST(p.p99_liters * 1.5, 100);
Принцип. Одна машина физически не может заправляться раз в несколько минут весь день. Если карта делает серию заправок с очень короткими паузами между ними — ей пользуются не так, как обычной картой одной машины.
- Берём все заправки карты за 7 дней, сортируем по времени.
- Считаем интервал между каждой соседней парой заправок.
- Если интервал меньше 30 минут — считаем пару «быстрой».
- Если таких «быстрых» заправок за сутки набирается 3 и больше — сигнал.
- Карты с флагом
Cards.IsVirtual(виртуальные карты перепродажи) из этого детектора исключаем или считаем по отдельным, более высоким порогам — легитимно обслуживают десятки разных конечных клиентов и сами по себе «мультирегиональны».
Порог: интервал <30 мин между заправками одной карты, серия ≥3 за 7 дней; не применяется к IsVirtual-картам без отдельной калибровки
Реализация (SQL)
WITH seq AS (
SELECT "CardId", "Id", "CreateTime", "FullSum", "GasStationId",
EXTRACT(EPOCH FROM ("CreateTime" - LAG("CreateTime") OVER w))/60 AS min_gap,
LAG("GasStationId") OVER w AS prev_station
FROM "FinanceTransaction"
WHERE "CreateTime" >= NOW()::timestamp - INTERVAL '7 days' AND "NoFuelGoodId" IS NULL
WINDOW w AS (PARTITION BY "CardId" ORDER BY "CreateTime")
)
SELECT "CardId", COUNT(*) AS rapid_refills,
COUNT(*) FILTER (WHERE "GasStationId" IS DISTINCT FROM prev_station) AS station_hops,
SUM("FullSum") AS rapid_sum
FROM seq WHERE min_gap < 30
GROUP BY "CardId" HAVING COUNT(*) >= 3;
Принцип. Карта не может физически быть на двух АЗС в 400 км друг от друга через 20 минут — считаем скорость перемещения между последовательными заправками.
- Берём заправки одной карты подряд по времени за 7 дней.
- Считаем расстояние между станциями по координатам — формула хаверсина (сферическое расстояние по широте и долготе).
- Делим расстояние на время между заправками — получаем требуемую скорость перемещения.
- Если скорость выше 120 км/ч (и между заправками прошло больше минуты) — сигнал.
- Карты с флагом
Cards.IsVirtual(виртуальные карты перепродажи) из этого детектора исключаем или считаем по отдельным, более высоким порогам — легитимно «заправляются» в десятках регионов силами разных конечных клиентов, физического перемещения там нет.
Порог: скорость между двумя заправками >120 км/ч, интервал между ними >60 сек; не применяется к IsVirtual-картам без отдельной калибровки
Реализация (SQL)
WITH mv AS (
SELECT ft."CardId", ft."CreateTime", ft."GasStationId",
NULLIF(regexp_replace(gs."CoordsX", '[^0-9.\-]', '', 'g'), '')::float AS lat,
NULLIF(regexp_replace(gs."CoordsY", '[^0-9.\-]', '', 'g'), '')::float AS lon,
LAG(ft."CreateTime") OVER w AS prev_t,
LAG(NULLIF(regexp_replace(gs."CoordsX", '[^0-9.\-]', '', 'g'), '')::float) OVER w AS prev_lat,
LAG(NULLIF(regexp_replace(gs."CoordsY", '[^0-9.\-]', '', 'g'), '')::float) OVER w AS prev_lon
FROM "FinanceTransaction" ft
JOIN "GasStations" gs ON gs."Id" = ft."GasStationId"
WHERE ft."CreateTime" >= NOW()::timestamp - INTERVAL '7 days'
WINDOW w AS (PARTITION BY ft."CardId" ORDER BY ft."CreateTime")
)
SELECT "CardId", "CreateTime", "GasStationId",
2*6371*asin(sqrt( sin(radians(lat-prev_lat)/2)^2
+ cos(radians(prev_lat))*cos(radians(lat))*sin(radians(lon-prev_lon)/2)^2 )) AS dist_km,
EXTRACT(EPOCH FROM ("CreateTime"-prev_t))/3600 AS hours_gap
FROM mv
WHERE prev_lat IS NOT NULL AND lat IS NOT NULL
AND EXTRACT(EPOCH FROM ("CreateTime"-prev_t)) > 60
AND 2*6371*asin(sqrt( sin(radians(lat-prev_lat)/2)^2
+ cos(radians(prev_lat))*cos(radians(lat))*sin(radians(lon-prev_lon)/2)^2 ))
/ (EXTRACT(EPOCH FROM ("CreateTime"-prev_t))/3600) > 120;
Принцип. Номер транзакции внутри одного поставщика топлива (ВИНК) должен быть уникальным. Если один и тот же номер встретился дважды — это либо сбой синхронизации, либо один и тот же чек «прокатали» повторно.
- Берём все транзакции за 30 дней.
- Группируем по паре «поставщик + номер транзакции».
- Если для одной пары нашлось больше одной записи — сигнал.
Порог: ≥2 транзакции с одинаковым (VinkType, TransactionNumber) за 30 дней
Реализация (SQL)
SELECT "VinkType", "TransactionNumber", COUNT(*) AS n,
ARRAY_AGG("Id" ORDER BY "CreateTime") AS tx_ids
FROM "FinanceTransaction"
WHERE "CreateTime" >= NOW()::timestamp - INTERVAL '30 days'
AND "TransactionNumber" IS NOT NULL AND "TransactionNumber" <> ''
GROUP BY "VinkType", "TransactionNumber"
HAVING COUNT(*) > 1;
Принцип. Живая заправка «до полного бака» почти всегда даёт не круглую, «рваную» сумму. Если карта стабильно платит одинаковые или идеально круглые суммы — это больше похоже на подготовленный слив, чем на реальную заправку.
- Берём транзакции карты за 30 дней (от 10 штук).
- Считаем число уникальных сумм и коэффициент вариации — разброс относительно среднего.
- Считаем долю сумм, кратных 100 ₽.
- Если уникальных сумм 3 или меньше, либо разброс аномально мал, либо больше 80% сумм круглые — сигнал.
Порог: ≥10 транзакций за 30 дней И (≤3 уникальные суммы, ИЛИ коэфф. вариации <0.05, ИЛИ >80% сумм кратны 100)
Реализация (SQL)
SELECT "CardId", COUNT(*) AS n, COUNT(DISTINCT "FullSum") AS uniq_sums,
STDDEV("FullSum") / NULLIF(AVG("FullSum"),0) AS cv,
COUNT(*) FILTER (WHERE "FullSum" = ROUND("FullSum", -2)) AS round_hundreds
FROM "FinanceTransaction"
WHERE "CreateTime" >= NOW()::timestamp - INTERVAL '30 days' AND "NoFuelGoodId" IS NULL
GROUP BY "CardId"
HAVING COUNT(*) >= 10
AND ( COUNT(DISTINCT "FullSum") <= 3
OR STDDEV("FullSum") / NULLIF(AVG("FullSum"),0) < 0.05
OR COUNT(*) FILTER (WHERE "FullSum" = ROUND("FullSum", -2))::float / COUNT(*) > 0.8 );
Принцип. Одна машина, как правило, ездит на одном виде топлива. Если карта регулярно чередует бензин и дизель — скорее всего ей заправляют разные, чужие машины.
- Берём заправки карты за 60 дней.
- Сравниваем тип топлива каждой заправки с предыдущей по той же карте.
- Считаем число переключений типа топлива.
- Если карта видела 2 и более разных типа топлива и переключений заметно больше нормы — сигнал.
Порог: ≥2 разных типа топлива на карте И переключений > max(3, транзакций/5) за 60 дней
Реализация (SQL)
WITH switches AS (
SELECT "CardId", "FuelTypeId",
LAG("FuelTypeId") OVER (PARTITION BY "CardId" ORDER BY "CreateTime") AS prev_ft
FROM "FinanceTransaction"
WHERE "CreateTime" >= NOW()::timestamp - INTERVAL '60 days' AND "FuelTypeId" IS NOT NULL
)
SELECT "CardId", COUNT(*) AS tx, COUNT(DISTINCT "FuelTypeId") AS fuel_types,
COUNT(*) FILTER (WHERE "FuelTypeId" IS DISTINCT FROM prev_ft AND prev_ft IS NOT NULL) AS switches
FROM switches
GROUP BY "CardId"
HAVING COUNT(DISTINCT "FuelTypeId") >= 2
AND COUNT(*) FILTER (WHERE "FuelTypeId" IS DISTINCT FROM prev_ft AND prev_ft IS NOT NULL) > GREATEST(3, COUNT(*)/5);
Принцип. Карта, которая раз за разом выбирает дневной лимит «под ноль», ведёт себя не так, как водитель, который заправляется по потребности — похоже, что кто-то специально выжимает из лимита максимум.
- Смотрим остаток дневного лимита карты.
- Считаем среднюю степень выработки лимита за период.
- Если в среднем выработка выше 90% — сигнал систематического «добора под потолок».
Порог: средняя выработка дневного лимита >90%
Реализация (SQL)
SELECT l."CardId", l."InDay",
AVG(1.0 - l."MaxInDayCurrent"::float / NULLIF(l."InDay",0)) AS avg_day_utilization
FROM "Limits" l
WHERE l."CardId" IS NOT NULL AND l."InDay" > 0
GROUP BY l."CardId", l."InDay"
HAVING AVG(1.0 - l."MaxInDayCurrent"::float / NULLIF(l."InDay",0)) > 0.9;
Полная версия — по дневным суммам FinanceTransaction против InDay (витрина), т.к. MaxInDayCurrent — моментальный срез.
Принцип. Если почти все заправки карты происходят на одной и той же станции — само по себе это может быть нормой для маршрутного транспорта, но в сочетании с другими сигналами намекает на сговор с конкретной точкой обслуживания.
- Берём заправки карты за 60 дней (от 15 штук).
- Группируем по АЗС, находим долю самой популярной станции.
- Если на одну АЗС приходится больше 85% заправок — сигнал концентрации (сам по себе слабый, весит немного).
Порог: ≥15 заправок за 60 дней И >85% на одной АЗС
Реализация (SQL)
WITH per_station AS (
SELECT "CardId", "GasStationId", COUNT(*) AS n, SUM("FullSum") AS s
FROM "FinanceTransaction"
WHERE "CreateTime" >= NOW()::timestamp - INTERVAL '60 days' AND "NoFuelGoodId" IS NULL
GROUP BY "CardId", "GasStationId"
)
SELECT "CardId", COUNT(*) AS stations, MAX(n)::float / SUM(n) AS concentration, SUM(s) AS total
FROM per_station
GROUP BY "CardId"
HAVING SUM(n) >= 15 AND MAX(n)::float / SUM(n) > 0.85;
Принцип. Топливная карта обычно закреплена за одной компанией. Если по ней проходят транзакции сразу нескольких разных юрлиц — карта либо передана «на сторону», либо это путаница в привязке.
- Берём транзакции карты за 90 дней.
- Считаем число разных компаний, к которым привязаны эти транзакции.
- Если их больше одной — сигнал.
Порог: карта проведена по ≥2 разным CompanyId за 90 дней
Реализация (SQL)
SELECT "CardId", COUNT(DISTINCT "CompanyId") AS companies,
MIN("CreateTime") AS first_tx, MAX("CreateTime") AS last_tx
FROM "FinanceTransaction"
WHERE "CreateTime" >= NOW()::timestamp - INTERVAL '90 days' AND "CompanyId" IS NOT NULL
GROUP BY "CardId"
HAVING COUNT(DISTINCT "CompanyId") > 1;
Принцип. У карты и у компании в целом есть привычный недельный оборот. Если он резко и статистически значимо вырастает по сравнению с историей — это ранний сигнал дрейфа поведения, даже если ни одна отдельная транзакция сама по себе не выглядит подозрительной.
- Считаем сумму по карте за каждую неделю.
- Строим скользящий базлайн — среднее и разброс по 12 предыдущим неделям.
- Считаем z-score текущей недели относительно этого базлайна.
- Если z-score 3 и больше (статистически значимый выброс) — сигнал.
Порог: z-score недельной суммы карты ≥3 против скользящего 12-недельного базлайна
Реализация (SQL)
WITH weekly AS (
SELECT "CardId", DATE_TRUNC('week', "CreateTime") AS wk, SUM("FullSum") AS s
FROM "FinanceTransaction"
WHERE "CreateTime" >= NOW()::timestamp - INTERVAL '180 days' AND "NoFuelGoodId" IS NULL
GROUP BY 1, 2
), base AS (
SELECT *, AVG(s) OVER hist AS mu, STDDEV(s) OVER hist AS sigma
FROM weekly
WINDOW hist AS (PARTITION BY "CardId" ORDER BY wk ROWS BETWEEN 12 PRECEDING AND 1 PRECEDING)
)
SELECT "CardId", wk, s, mu, (s - mu) / NULLIF(sigma, 0) AS z
FROM base
WHERE sigma > 0 AND (s - mu) / sigma >= 3;
Принцип. Заправка по карте, которая уже заблокирована, неактивна или просрочена, — это не вероятностный сигнал, а жёсткий факт: либо рассинхрон с поставщиком, либо прямое мошенничество.
- Берём транзакции за 7 дней.
- Проверяем статус карты на момент транзакции — активна ли, не заблокирована ли, не истёк ли срок действия.
- Если хотя бы один флаг блокировки или неактивности стоит — сигнал максимального веса.
Порог: IsActive=false, ИЛИ любой Blocked-флаг, ИЛИ IsHidden, ИЛИ Validity < времени транзакции
Реализация (SQL)
SELECT ft."Id", ft."CardId", ft."CreateTime", ft."FullSum",
c."IsActive", c."BlockStatus", c."IsAdminBlocked", c."IsHidden", c."Validity"
FROM "FinanceTransaction" ft
JOIN "Cards" c ON c."Id" = ft."CardId"
WHERE ft."CreateTime" >= NOW()::timestamp - INTERVAL '7 days'
AND ( c."IsActive" = false
OR c."IsAdminBlocked" OR c."IsManagerBlocked" OR c."IsSuperUserBlocked"
OR c."Validity" < ft."CreateTime" );
Принцип. Онлайн-транзакции проходят проверку лимитов в реальном времени. Offline и «ручные» операции — обходной путь, которым традиционно пользуются, чтобы провести операцию мимо контроля.
- Берём транзакции карты за 30 дней (от 5 штук).
- Считаем долю операций, помеченных как offline, ручной ввод или с отрицательным балансом.
- Если доля offline больше 30% — сигнал.
Порог: ≥5 транзакций за 30 дней И доля IsOffline >30%
Реализация (SQL)
SELECT "CardId", COUNT(*) AS tx,
COUNT(*) FILTER (WHERE "IsOffline") AS offline_tx,
COUNT(*) FILTER (WHERE "AutoInput") AS auto_tx,
COUNT(*) FILTER (WHERE "ReceiveWithNegativeBalance") AS neg_balance_tx,
SUM("FullSum") FILTER (WHERE "IsOffline") AS offline_sum
FROM "FinanceTransaction"
WHERE "CreateTime" >= NOW()::timestamp - INTERVAL '30 days'
GROUP BY "CardId"
HAVING COUNT(*) >= 5
AND COUNT(*) FILTER (WHERE "IsOffline")::float / COUNT(*) > 0.3;
Принцип. Массовое изменение или обнуление лимитов сразу у многих карт компании за короткое время — операционная аномалия сама по себе, вне зависимости от того, фрод это или сбой синхронизации с поставщиком. Паттерн, который уже наблюдался при реальных инцидентах массовой порчи лимитов в 06–07/2026.
- Смотрим изменения лимитов компании в скользящем окне в один час.
- Считаем общее число изменений и отдельно — число полных обнулений (когда сразу все виды лимита становятся нулевыми).
- Если изменений больше 200 в час, или обнулений больше 50 — операционный алерт, отдельно от скоринга карт.
Порог: за час: >200 изменённых лимитов компании, ИЛИ >50 занулённых (InDay=InWeek=InMonth=0)
Реализация (SQL)
SELECT "CompanyId", DATE_TRUNC('hour', "LastModifed") AS hr,
COUNT(*) AS limits_changed,
COUNT(*) FILTER (WHERE COALESCE("InDay",0)=0 AND COALESCE("InWeek",0)=0 AND COALESCE("InMonth",0)=0) AS zeroed
FROM "Limits"
WHERE "LastModifed" >= NOW()::timestamp - INTERVAL '24 hours'
GROUP BY 1, 2
HAVING COUNT(*) > 200
OR COUNT(*) FILTER (WHERE COALESCE("InDay",0)=0 AND COALESCE("InWeek",0)=0 AND COALESCE("InMonth",0)=0) > 50;
Схема снята против живой БД fuel_prod1 (oktane, PostgreSQL) 2026-07-24. Модель скоринга и веса — на странице «Антифрод».