Approach: for each transaction, compute the mean and stddev of that user's transactions in the prior 365 days (including the current row), then flag amount > mean + 3*stddev. Exclude nulls and handle low-sample users by requiring at least 30 historical samples (otherwise mark as "insufficient_data"). Assume timestamps are stored in UTC; if per-user timezone is required, normalize occurred_at to user_timezone before windowing.sql
WITH clean AS (
SELECT
transaction_id,
user_id,
amount,
occurred_at
FROM transactions
WHERE amount IS NOT NULL
AND occurred_at IS NOT NULL
),
stats AS (
SELECT
transaction_id,
user_id,
amount,
occurred_at,
COUNT(amount) OVER (
PARTITION BY user_id
ORDER BY occurred_at
RANGE BETWEEN INTERVAL 365 DAY PRECEDING AND CURRENT ROW
) AS sample_count,
AVG(amount) OVER (
PARTITION BY user_id
ORDER BY occurred_at
RANGE BETWEEN INTERVAL 365 DAY PRECEDING AND CURRENT ROW
) AS mean_365,
STDDEV_POP(amount) OVER (
PARTITION BY user_id
ORDER BY occurred_at
RANGE BETWEEN INTERVAL 365 DAY PRECEDING AND CURRENT ROW
) AS stddev_365
FROM clean
)
SELECT
transaction_id,
user_id,
amount,
occurred_at,
sample_count,
mean_365,
stddev_365,
CASE
WHEN sample_count < 30 THEN 'insufficient_data'
WHEN stddev_365 IS NULL THEN 'insufficient_variance'
WHEN amount > mean_365 + 3 * stddev_365 THEN 'outlier'
ELSE 'normal'
END AS outlier_label
FROM stats;
Key points and assumptions:- Exclude null amounts/occurred_at earlier to avoid skew.- Using RANGE BETWEEN INTERVAL 365 DAY PRECEDING ensures a rolling 365-day window per user ordered by timestamp.- For users with <30 samples we mark as 'insufficient_data' (alternatives: lower threshold, use robust metric like median+6*MAD, or expand lookback).- Timezone: query treats occurred_at as UTC; if users have local timezones, join a user_timezones table and convert occurred_at AT TIME ZONE before windowing.- stddev_pop used; if population vs sample distinction matters, use STDDEV_SAMP.- Performance: ensure partition pruning (cluster/partition table by user_id/date) and consider pre-aggregating daily stats if table is huge.