14 детекторов фрода

Каждый детектор — независимый сигнал с весом в скоринге карты (0–100): жёсткие факты (например мёртвая карта или дубль транзакции) весят 30–40, поведенческие сигналы — 15–25, контекстные — 10–15. Скор карты — сумма активных сигналов за 7 дней. При 50 — жёлтый алерт в очередь на разбор, при 75 — красный алерт с автоматическим фризом карты по конфигу. Ниже — принцип работы, алгоритм по шагам и пороги каждого детектора; SQL-реализация — в свёрнутом блоке для инженеров.

Каталог детекторов

D-01 Ночная активность вне профиля карты вес 15–25 · поведенческий

Принцип. У каждой карты есть свой обычный режим работы. Если карта, которая исторически не заправлялась ночью, вдруг начинает делать это регулярно — это выбивается из её профиля.

  1. Берём заправки карты за последние 30 дней.
  2. Переводим время каждой заправки в локальное время АЗС, а не сервера — иначе весь Владивосток выглядел бы «ночным».
  3. Считаем долю заправок в интервале 00:00–05:00.
  4. Если таких заправок минимум 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 хранится как смещение (проверить единицы на реальных данных перед калибровкой).

D-02 Объём/сумма вне персональной нормы карты вес 15 ✓ подтверждён кейсом 07/2026

Принцип. У каждой карты есть свой типичный объём разовой заправки. Когда новая заправка резко превышает исторический максимум карты — или, если у карты вовсе нет истории, кратно превышает разумную норму, — отклонение стоит проверить.

  1. Строим персональный 99-й процентиль объёма заправки карты по истории за 180 дней (нужно минимум 20 заправок).
  2. Сравниваем каждую новую заправку с этим порогом.
  3. Если объём больше чем в 1,5 раза выше порога и превышает 100 литров — сигнал.
  4. Для карт без собственной истории — сравнение с нормой компании.

Порог: объём > 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);
D-03 Velocity: серия заправок за короткое окно вес 15–25 · поведенческий ✓ подтверждён кейсом 07/2026

Принцип. Одна машина физически не может заправляться раз в несколько минут весь день. Если карта делает серию заправок с очень короткими паузами между ними — ей пользуются не так, как обычной картой одной машины.

  1. Берём все заправки карты за 7 дней, сортируем по времени.
  2. Считаем интервал между каждой соседней парой заправок.
  3. Если интервал меньше 30 минут — считаем пару «быстрой».
  4. Если таких «быстрых» заправок за сутки набирается 3 и больше — сигнал.
  5. Карты с флагом 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;
D-04 Impossible travel: физически недостижимые АЗС вес 30–40 · жёсткий факт ✓ подтверждён кейсом 07/2026, главный

Принцип. Карта не может физически быть на двух АЗС в 400 км друг от друга через 20 минут — считаем скорость перемещения между последовательными заправками.

  1. Берём заправки одной карты подряд по времени за 7 дней.
  2. Считаем расстояние между станциями по координатам — формула хаверсина (сферическое расстояние по широте и долготе).
  3. Делим расстояние на время между заправками — получаем требуемую скорость перемещения.
  4. Если скорость выше 120 км/ч (и между заправками прошло больше минуты) — сигнал.
  5. Карты с флагом 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;
D-05 Дубли номеров транзакций ВИНК вес 30–40 · жёсткий факт

Принцип. Номер транзакции внутри одного поставщика топлива (ВИНК) должен быть уникальным. Если один и тот же номер встретился дважды — это либо сбой синхронизации, либо один и тот же чек «прокатали» повторно.

  1. Берём все транзакции за 30 дней.
  2. Группируем по паре «поставщик + номер транзакции».
  3. Если для одной пары нашлось больше одной записи — сигнал.

Порог: ≥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;
D-06 Подозрительно стабильные / круглые суммы вес 15–25 · поведенческий

Принцип. Живая заправка «до полного бака» почти всегда даёт не круглую, «рваную» сумму. Если карта стабильно платит одинаковые или идеально круглые суммы — это больше похоже на подготовленный слив, чем на реальную заправку.

  1. Берём транзакции карты за 30 дней (от 10 штук).
  2. Считаем число уникальных сумм и коэффициент вариации — разброс относительно среднего.
  3. Считаем долю сумм, кратных 100 ₽.
  4. Если уникальных сумм 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 );
D-07 Смена типа топлива на карте вес 10–15 · контекстный

Принцип. Одна машина, как правило, ездит на одном виде топлива. Если карта регулярно чередует бензин и дизель — скорее всего ей заправляют разные, чужие машины.

  1. Берём заправки карты за 60 дней.
  2. Сравниваем тип топлива каждой заправки с предыдущей по той же карте.
  3. Считаем число переключений типа топлива.
  4. Если карта видела 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);
D-08 Систематический добор лимита под потолок вес 15

Принцип. Карта, которая раз за разом выбирает дневной лимит «под ноль», ведёт себя не так, как водитель, который заправляется по потребности — похоже, что кто-то специально выжимает из лимита максимум.

  1. Смотрим остаток дневного лимита карты.
  2. Считаем среднюю степень выработки лимита за период.
  3. Если в среднем выработка выше 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 — моментальный срез.

D-09 Концентрация карты на одной АЗС вес 10–15 · контекстный

Принцип. Если почти все заправки карты происходят на одной и той же станции — само по себе это может быть нормой для маршрутного транспорта, но в сочетании с другими сигналами намекает на сговор с конкретной точкой обслуживания.

  1. Берём заправки карты за 60 дней (от 15 штук).
  2. Группируем по АЗС, находим долю самой популярной станции.
  3. Если на одну АЗС приходится больше 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;
D-10 Карта, «гуляющая» между компаниями вес 10–15 · контекстный

Принцип. Топливная карта обычно закреплена за одной компанией. Если по ней проходят транзакции сразу нескольких разных юрлиц — карта либо передана «на сторону», либо это путаница в привязке.

  1. Берём транзакции карты за 90 дней.
  2. Считаем число разных компаний, к которым привязаны эти транзакции.
  3. Если их больше одной — сигнал.

Порог: карта проведена по ≥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;
D-11 Дрейф поведения (карта и компания) вес 10–15 · контекстный ✓ подтверждён кейсом 07/2026

Принцип. У карты и у компании в целом есть привычный недельный оборот. Если он резко и статистически значимо вырастает по сравнению с историей — это ранний сигнал дрейфа поведения, даже если ни одна отдельная транзакция сама по себе не выглядит подозрительной.

  1. Считаем сумму по карте за каждую неделю.
  2. Строим скользящий базлайн — среднее и разброс по 12 предыдущим неделям.
  3. Считаем z-score текущей недели относительно этого базлайна.
  4. Если 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;
D-12 Транзакции по мёртвой карте вес 30–40 · жёсткий факт

Принцип. Заправка по карте, которая уже заблокирована, неактивна или просрочена, — это не вероятностный сигнал, а жёсткий факт: либо рассинхрон с поставщиком, либо прямое мошенничество.

  1. Берём транзакции за 7 дней.
  2. Проверяем статус карты на момент транзакции — активна ли, не заблокирована ли, не истёк ли срок действия.
  3. Если хотя бы один флаг блокировки или неактивности стоит — сигнал максимального веса.

Порог: 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" );
D-13 Доля offline/ручных операций вес 15–25 · поведенческий

Принцип. Онлайн-транзакции проходят проверку лимитов в реальном времени. Offline и «ручные» операции — обходной путь, которым традиционно пользуются, чтобы провести операцию мимо контроля.

  1. Берём транзакции карты за 30 дней (от 5 штук).
  2. Считаем долю операций, помеченных как offline, ручной ввод или с отрицательным балансом.
  3. Если доля 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;
D-14 Массовая порча лимитов компании операционный алерт

Принцип. Массовое изменение или обнуление лимитов сразу у многих карт компании за короткое время — операционная аномалия сама по себе, вне зависимости от того, фрод это или сбой синхронизации с поставщиком. Паттерн, который уже наблюдался при реальных инцидентах массовой порчи лимитов в 06–07/2026.

  1. Смотрим изменения лимитов компании в скользящем окне в один час.
  2. Считаем общее число изменений и отдельно — число полных обнулений (когда сразу все виды лимита становятся нулевыми).
  3. Если изменений больше 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. Модель скоринга и веса — на странице «Антифрод».