Как сделать Currency Conversion в SQL

Проверь себя · 1/3разбор после ответа
В выводе EXPLAIN вы видите оценку cost=0.00..431.00. Какой вывод аналитик может сделать безопасно?

Зачем нужна конвертация валют

Как только у бизнеса появляются продажи в нескольких странах, выручка приходит в разных валютах — рубли, доллары, евро, фунты. Чтобы посмотреть на бизнес целиком, всё это нужно свести к одной валюте отчётности (обычно USD или EUR). Это и есть currency conversion.

Задача кажется тривиальной — «умножить на курс», — но именно на ней легко ошибиться. Неверный курс тянет за собой неверную выручку, а за ней — неверные решения о том, какие рынки растут, а какие проседают. Отдельная ловушка в том, что «правильного» курса не существует: курс меняется каждый день, а решение, брать курс на дату транзакции или на сегодня, полностью меняет цифру. Ниже — готовые запросы под PostgreSQL для типичных случаев.

Базовый расчёт

Самый простой вариант — единый фиксированный курс. Годится только для черновых прикидок:

SELECT
    transaction_id,
    amount,
    currency,
    CASE currency
        WHEN 'USD' THEN amount
        WHEN 'EUR' THEN amount * 1.10
        WHEN 'GBP' THEN amount * 1.27
        WHEN 'RUB' THEN amount * 0.011
        ELSE NULL
    END AS amount_usd
FROM transactions;

⚠️ Захардкоженные курсы устаревают уже на следующий день. Как только цифры уходят кому-то в отчёт — переходите на таблицу курсов.

Ежедневные FX-курсы

Правильный способ — хранить курсы в отдельной таблице fx_rates с разбивкой по дате и валюте и джойнить транзакцию с курсом на её дату:

-- Таблица fx_rates: (date, currency, rate_to_usd)

SELECT
    t.transaction_id,
    t.amount,
    t.currency,
    t.created_at::DATE AS txn_date,
    fx.rate_to_usd,
    t.amount * fx.rate_to_usd AS amount_usd
FROM transactions t
JOIN fx_rates fx ON fx.currency = t.currency
  AND fx.DATE = t.created_at::DATE          -- курс именно на дату транзакции
WHERE t.created_at >= CURRENT_DATE - INTERVAL '30 days';

Каждая транзакция берёт курс своего дня — так выручка считается в тех деньгах, что реально были получены. Это historical-подход, и для факта (actuals) он единственно верный.

Мультивалютная выручка

Часто нужно увидеть выручку и в локальной валюте, и в USD одновременно — например, чтобы понять, где рост реальный, а где его «съел» или «надул» курс:

SELECT
    DATE_TRUNC('month', t.created_at) AS month,
    t.currency,
    SUM(t.amount) AS local_total,                    -- в локальной валюте
    SUM(t.amount * fx.rate_to_usd) AS usd_total      -- приведённая к USD
FROM transactions t
JOIN fx_rates fx ON fx.currency = t.currency
  AND fx.DATE = t.created_at::DATE
WHERE t.status = 'paid'
  AND t.created_at >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY 1, 2
ORDER BY 1, 2;

Расхождение между динамикой local_total и usd_total по валюте — прямой сигнал влияния курса: локально рынок мог вырасти, а в долларах просесть из-за ослабления валюты.

Свести всё в единую USD-выручку с ARPPU:

SELECT
    DATE_TRUNC('month', t.created_at) AS month,
    SUM(t.amount * fx.rate_to_usd) AS revenue_usd,
    COUNT(DISTINCT t.user_id) AS paying_users,
    SUM(t.amount * fx.rate_to_usd) / COUNT(DISTINCT t.user_id) AS arppu_usd
FROM transactions t
JOIN fx_rates fx ON fx.currency = t.currency
  AND fx.DATE = t.created_at::DATE
WHERE t.status = 'paid'
GROUP BY 1
ORDER BY 1;

Spot vs Historical

Это ключевая развилка, вокруг которой строятся вопросы на собесе. Есть два способа пересчёта, и они отвечают на разные вопросы:

  • Historical — каждая транзакция по курсу своего дня. Это фактически полученная выручка в USD. Годится для отчётности по факту.
  • Spot — все транзакции по одному сегодняшнему курсу. Это переоценка «сколько это стоит сейчас», удобная для сравнения текущей стоимости, но искажающая историю.
-- Spot (курс на сегодня): единая оценка текущей стоимости
SELECT
    t.transaction_id,
    t.amount,
    t.amount * (
        SELECT rate_to_usd FROM fx_rates
        WHERE currency = t.currency AND DATE = CURRENT_DATE
    ) AS amount_spot_usd
FROM transactions t;

-- Historical (по факту): каждая транзакция по своему курсу
-- (запрос уже показан выше в разделе про ежедневные курсы)

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

Закрепи формулу currency conversion в Карьернике
Запомнить надолго — 5 коротких сессий с задачами на эту тему. Бесплатно
Тренировать currency conversion в Telegram

Обработка пропущенных курсов

В таблице курсов бывают дыры — выходные и праздники, когда торгов нет и курса на дату просто не существует. Джойн по точной дате в такие дни вернёт NULL и молча потеряет выручку. Решение — цепочка fallback: точная дата, иначе ближайший предыдущий курс:

SELECT
    t.transaction_id,
    t.amount,
    t.currency,
    t.created_at::DATE,
    -- Цепочка fallback: точная дата → ближайший предыдущий → NULL
    COALESCE(
        (SELECT rate_to_usd FROM fx_rates
         WHERE currency = t.currency AND DATE = t.created_at::DATE),
        (SELECT rate_to_usd FROM fx_rates
         WHERE currency = t.currency AND DATE <= t.created_at::DATE
         ORDER BY DATE DESC LIMIT 1)
    ) AS rate,
    t.amount * COALESCE(
        (SELECT rate_to_usd FROM fx_rates
         WHERE currency = t.currency AND DATE = t.created_at::DATE),
        (SELECT rate_to_usd FROM fx_rates
         WHERE currency = t.currency AND DATE <= t.created_at::DATE
         ORDER BY DATE DESC LIMIT 1)
    ) AS amount_usd
FROM transactions t;

COALESCE берёт первый непустой курс: сначала пытается найти курс ровно на дату, а если его нет — подставляет последний доступный до этой даты. Так суббота и воскресенье считаются по курсу пятницы, а не теряются.

Влияние курса на рост

Чтобы отделить реальный рост от валютного эффекта, сравнивают локальную и USD-выручку с изменением среднего курса по кварталам:

WITH metrics AS (
    SELECT
        DATE_TRUNC('quarter', t.created_at) AS quarter,
        t.currency,
        SUM(t.amount) AS local_revenue,
        SUM(t.amount * fx.rate_to_usd) AS usd_revenue,
        AVG(fx.rate_to_usd) AS avg_rate
    FROM transactions t
    JOIN fx_rates fx ON fx.currency = t.currency AND fx.DATE = t.created_at::DATE
    GROUP BY 1, 2
)
SELECT
    quarter,
    currency,
    local_revenue,
    usd_revenue,
    LAG(avg_rate) OVER (PARTITION BY currency ORDER BY quarter) AS prev_rate,
    avg_rate,
    avg_rate - LAG(avg_rate) OVER (PARTITION BY currency ORDER BY quarter) AS fx_change
FROM metrics
ORDER BY quarter, currency;

Оконная функция LAG подтягивает курс предыдущего квартала, а fx_change показывает, насколько он сдвинулся. Если USD-выручка упала, а локальная выросла — рост реальный, но его перекрыло ослабление валюты.

Как это спрашивают на собесе

«Как посчитать выручку в разных валютах в одной цифре?» Сильный ответ: таблица курсов fx_rates, джойн транзакции с курсом на её дату, суммирование amount * rate_to_usd. Не хардкодить курсы — они устаревают.

«Spot или historical — какой курс возьмёте?» Для отчёта о факте — historical (курс дня транзакции), для переоценки текущей стоимости — spot. Перепутать их — исказить либо историю, либо текущую оценку.

«Что делать, если на дату нет курса?» Выходные и праздники дают дыры в таблице. Fallback через COALESCE на ближайший предыдущий курс, иначе транзакция молча потеряется в JOIN.

Частые ошибки

Захардкоженные курсы. Курс EUR/USD меняется ежедневно, а зашитая в запрос константа устаревает мгновенно. Всегда через таблицу курсов с разбивкой по датам.

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

Всё по spot-курсу. Переоценивать историческую выручку по сегодняшнему курсу — искажать реальность: цифра перестаёт отражать то, что реально получили.

Пропущенные даты. В выходные и праздники курса нет, и джойн по точной дате теряет транзакцию. Нужен fallback на последний доступный курс.

Разные источники курсов. ЦБ, ECB, OXR, внутренний банковский курс дают разные значения. Смешивать их в одном отчёте нельзя — выберите один источник и зафиксируйте его в определении метрики.

Связанные темы

FAQ

Откуда брать курсы валют?

Из внешнего источника, который зафиксирован для отчётности: ЦБ РФ, ECB (бесплатный, но евро-центричный), Open Exchange Rates (платный, точный) или внутренний банковский курс компании. Главное — не смешивать источники: у каждого свои значения, и один и тот же день по ЦБ и по ECB даст разные цифры.

Какой курс брать — дневной, месячный или квартальный?

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

Spot или historical — что использовать?

Historical для фактической выручки (actuals): каждая транзакция по курсу своего дня отражает реально полученные деньги. Spot нужен для переоценки — «сколько это стоит в сегодняшних деньгах». Путать их нельзя: spot по историческим данным исказит факт, а historical для текущей оценки даст устаревшую цифру.

Что делать с курсами на выходные?

Курса на выходные и праздники обычно нет — торгов не было. Стандартное решение — брать последний доступный курс предыдущего рабочего дня через COALESCE с fallback. Реже применяют интерполяцию между соседними днями, но для большинства задач достаточно «курса пятницы».

Как быть с криптовалютой?

Крипта слишком волатильна, чтобы использовать дневной курс — за день значение может сильно измениться. Обычно берут почасовые курсы или, надёжнее всего, фиксируют курс на момент самой транзакции (snapshot at transaction time), чтобы конвертация отражала реальную стоимость в тот момент.