CMS и платформы 14 мин чтения

Парсинг курсов валют: API, источники, код и хранение данных

Сбор курсов валют: официальные API центробанков, агрегаторы, парсинг сайтов банков и хранение истории для аналитики.

КP
Команда Parsing.agency
Сбор данных под задачи бизнеса
Опубликовано: 20 марта 2025

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


1. Откуда вообще берутся курсы

Важно с самого начала разделить два принципиально разных типа курсов:

Официальный курс центрального банка. Одно значение на дату, без покупки/продажи. Это «учётный» курс — по нему считают налоги, таможенные платежи, бухгалтерию, договоры. Он стабилен, публикуется по расписанию (обычно раз в сутки), и почти у всех ЦБ есть бесплатный машиночитаемый источник.

Курсы коммерческих банков и обменников. У каждого банка свой курс покупки и продажи со спредом, он меняется в течение дня, отличается от официального и зависит от банка, города, суммы, наличной/безналичной формы. Здесь единого источника нет — этим занимаются агрегаторы (раздел 6).

Для большинства задач (учёт, мультивалютные цены, конвертеры) нужен официальный курс ЦБ. Если задача — «показать пользователю, где выгоднее купить доллар», нужны банковские курсы.


2. Центробанки СНГ и соседних стран

Почти все ЦБ отдают данные бесплатно и без ключа. Форматы разные — где-то аккуратный JSON, где-то XML в кодировке windows-1251, где-то RSS.

Страна Орган Эндпоинт (официальный курс на сегодня) Формат
Россия ЦБ РФ https://www.cbr.ru/scripts/XML_daily.asp (динамика — XML_dynamic.asp) XML (cp1251)
Казахстан НБ РК https://nationalbank.kz/rss/get_rates.cfm?fdate=DD.MM.YYYY XML/RSS
Беларусь НБ РБ https://api.nbrb.by/exrates/rates?periodicity=0 JSON
Узбекистан ЦБ РУз https://cbu.uz/ru/arkhiv-kursov-valyut/json/ JSON/XML
Кыргызстан НБ КР https://www.nbkr.kg/XML/daily.xml XML
Азербайджан ЦБА https://www.cbar.az/currencies/DD.MM.YYYY.xml XML
Армения ЦБ РА сервис курсов на cb.am XML/SOAP
Молдова НБМ https://www.bnm.md/ru/official_exchange_rates?get_xml=1&date=DD.MM.YYYY XML
Грузия НБГ https://nbg.gov.ge/gw/api/ct/monetarypolicy/currencies/ru/json/ JSON
Таджикистан НБТ курсы на nbt.tj XML

Эндпоинты приведены по состоянию на момент написания. Банки периодически меняют инфраструктуру (например, и НБ РК, и ЦБ РФ в своё время принудительно перевели всё на HTTPS), поэтому перед продакшеном стоит свериться с официальной страницей «для веб-мастеров» соответствующего ЦБ.

На что обратить внимание в данных ЦБ:

  • Номинал (nominal / scale / quant). Курс часто указан не за 1 единицу, а за 10, 100 или 1000. Например, у НБ РБ японская иена котируется за 100 единиц. Игнорировать это поле — классическая ошибка, дающая курс, завышенный в 100 раз.
  • Кодировка. ЦБ РФ отдаёт XML в windows-1251, а не UTF-8. Прочитаете «как есть» — получите кракозябры в названиях.
  • Десятичный разделитель. В XML ЦБ РФ дробная часть отделяется запятой (74,1234), а не точкой.
  • Расписание. Курс публикуется заранее на следующий рабочий день и/или по итогам торгов. В Казахстане, например, официальный курс тенге к доллару — это средневзвешенный биржевой курс на 15:30 по Астане, устанавливаемый на следующий рабочий день.

3. Мировые API

Когда нужны кросс-курсы, много валют или единый источник «всё к USD/EUR», удобнее мировые сервисы.

Сервис Источник данных Ключ Бесплатный лимит Заметки
Frankfurter (api.frankfurter.dev) ЕЦБ не нужен без месячных лимитов (только защита от абьюза) ~30 валют, история с 1999, open-source, можно self-host
ЕЦБ напрямую (файл eurofxref-daily.xml на ecb.europa.eu) ЕЦБ не нужен да XML-файл от первоисточника, всё к EUR
NBP (Польша, api.nbp.pl) НБ Польши не нужен да есть таблица C с buy/sell
exchangerate-api.com смесь нескольких ЦБ нужен ~1500/мес 160+ валют, усреднённый midpoint
Open Exchange Rates агрегат нужен 1000/мес на free базис только USD
Fixer.io / currencylayer (apilayer) ЕЦБ и др. нужен ~100/мес
currencyapi.com агрегат нужен ~300/мес есть крипто
Twelve Data / Alpha Vantage биржевые котировки нужен лимиты внутридневной форекс, не «учётный» курс

Ключевой нюанс мировых агрегаторов: их курсы — это индикативный midpoint (без спреда). Они отлично подходят для оценочного пересчёта цен в e-commerce или для дашбордов, но не годятся для реального форекс-трейдинга или расчёта суммы, которую банк реально спишет при конвертации. Frankfurter и ЕЦБ к тому же публикуют курс лишь по рабочим дням: запрос на 1 января вернёт курс последнего рабочего дня, и в ответе будет поле date с фактической датой курса — ориентироваться надо на него, а не на запрошенную дату.


4. Пять решений на разных языках

Чтобы показать разнообразие источников и языков, каждый пример бьёт в свой ЦБ. Везде сознательно акцент на двух вещах: учёт номинала и хранение значения без двоичного float.

4.1. PHP — ЦБ РФ (XML, windows-1251, BCMath)

php
<?php
declare(strict_types=1);

/**
 * Возвращает курсы ЦБ РФ за 1 единицу валюты в виде строк.
 * Строки + BCMath — чтобы не терять точность на float.
 */
function fetchCbrRates(): array
{
    $raw = file_get_contents('https://www.cbr.ru/scripts/XML_daily.asp');
    if ($raw === false) {
        throw new RuntimeException('Не удалось получить данные ЦБ РФ');
    }

    // XML приходит в кодировке windows-1251 — перекодируем
    $utf8 = mb_convert_encoding($raw, 'UTF-8', 'windows-1251');
    $xml  = simplexml_load_string($utf8);
    if ($xml === false) {
        throw new RuntimeException('Ошибка разбора XML');
    }

    $rates = [];
    foreach ($xml->Valute as $valute) {
        $code    = (string) $valute->CharCode;                 // USD, EUR...
        $nominal = (string) $valute->Nominal;                  // 1, 10, 100
        // дробный разделитель в XML ЦБ РФ — запятая
        $value   = str_replace(',', '.', (string) $valute->Value);

        // курс за ОДНУ единицу, с точностью 6 знаков, как строка
        $rates[$code] = bcdiv($value, $nominal, 6);
    }

    return $rates;
}

$rates = fetchCbrRates();
echo "USD: {$rates['USD']} RUB\n";
echo "EUR: {$rates['EUR']} RUB\n";

Тут показателен bcdiv: деление на номинал делается строковой арифметикой, без преобразования в float. Требует расширения bcmath.

4.2. Python — НБ РБ (JSON, Decimal)

python
from decimal import Decimal, getcontext
import requests

getcontext().prec = 28  # достаточный запас точности


def fetch_nbrb_rates() -> dict[str, Decimal]:
    """Официальные курсы НБ РБ за 1 единицу валюты (тип Decimal)."""
    resp = requests.get(
        "https://api.nbrb.by/exrates/rates",
        params={"periodicity": 0},  # 0 — ежедневные курсы
        timeout=10,
    )
    resp.raise_for_status()

    rates: dict[str, Decimal] = {}
    for item in resp.json():
        code  = item["Cur_Abbreviation"]              # USD, EUR, RUB...
        scale = Decimal(str(item["Cur_Scale"]))       # номинал (1, 100...)
        rate  = Decimal(str(item["Cur_OfficialRate"]))
        rates[code] = rate / scale                    # курс за одну единицу
    return rates


if __name__ == "__main__":
    rates = fetch_nbrb_rates()
    print(f"USD: {rates['USD']:.4f} BYN")
    print(f"RUB(за 100): {rates['RUB'] * 100:.4f} BYN")

Принципиальный момент — Decimal(str(value)), а не Decimal(value). Если число уже прилетело как float, оборачивание в str фиксирует ровно то десятичное представление, которое было в JSON.

4.3. JavaScript / Node.js — ЦБ РУз (JSON, нативный fetch)

javascript
// Node 18+ — fetch встроен
async function fetchCbuRates() {
  const res = await fetch("https://cbu.uz/ru/arkhiv-kursov-valyut/json/");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);

  const data = await res.json(); // массив объектов по каждой валюте
  const rates = {};

  for (const item of data) {
    const code    = item.Ccy;              // USD, EUR, RUB...
    const nominal = Number(item.Nominal);  // обычно 1
    // Rate приходит строкой — сохраняем и сырое значение, и нормализованное
    rates[code] = {
      raw: item.Rate,                       // строка, без потерь
      perOne: Number(item.Rate) / nominal,  // для отображения
      date: item.Date,
    };
  }
  return rates;
}

fetchCbuRates().then((rates) => {
  console.log(`USD: ${rates.USD.perOne} UZS (на ${rates.USD.date})`);
  console.log(`EUR: ${rates.EUR.perOne} UZS`);
});

В JavaScript нет нативного десятичного типа, а number — это IEEE 754 double. Поэтому здесь хранится и сырая строка raw, и числовое значение для показа. Для денежных расчётов (а не просто вывода курса) в JS берут библиотеку вроде decimal.js или big.js и считают на строках.

4.4. Go — НБ РК (XML/RSS, строгая типизация)

go
package main

import (
    "encoding/xml"
    "fmt"
    "io"
    "net/http"
    "time"
)

type Rates struct {
    Items []Item `xml:"item"`
}

type Item struct {
    Title       string `xml:"title"`       // код валюты: USD, EUR
    Description string `xml:"description"`  // значение курса
    Quant       string `xml:"quant"`       // номинал
}

func fetchNbkRates() (map[string]string, error) {
    url := "https://nationalbank.kz/rss/get_rates.cfm?fdate=" +
        time.Now().Format("02.01.2006") // формат DD.MM.YYYY

    client := &http.Client{Timeout: 10 * time.Second}
    resp, err := client.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }

    var rates Rates
    if err := xml.Unmarshal(body, &rates); err != nil {
        return nil, err
    }

    result := make(map[string]string, len(rates.Items))
    for _, it := range rates.Items {
        // храним сырую строку; для арифметики — shopspring/decimal
        result[it.Title] = it.Description
    }
    return result, nil
}

func main() {
    rates, err := fetchNbkRates()
    if err != nil {
        panic(err)
    }
    fmt.Printf("USD: %s KZT\n", rates["USD"])
    fmt.Printf("EUR: %s KZT\n", rates["EUR"])
}

В Go нет встроенного decimal, поэтому курс держится строкой, а для вычислений подключают github.com/shopspring/decimal. На выходных файл может прийти пустым — это стоит обрабатывать (взять предыдущую дату).

4.5. C# / .NET — Frankfurter (ЕЦБ, мировой источник, decimal)

c#
using System.Net.Http.Json;
using System.Text.Json.Serialization;

public record FrankfurterResponse(
    [property: JsonPropertyName("base")] string Base,
    [property: JsonPropertyName("date")] string Date,
    [property: JsonPropertyName("rates")] Dictionary<string, decimal> Rates
);

public static class CurrencyClient
{
    private static readonly HttpClient Http = new();

    public static async Task<FrankfurterResponse> FetchEcbRatesAsync(string baseCcy = "EUR")
    {
        var url = $"https://api.frankfurter.dev/v1/latest?base={baseCcy}";
        return await Http.GetFromJsonAsync<FrankfurterResponse>(url)
               ?? throw new InvalidOperationException("Пустой ответ от Frankfurter");
    }
}

class Program
{
    static async Task Main()
    {
        var data = await CurrencyClient.FetchEcbRatesAsync("USD");
        Console.WriteLine($"Курс на дату: {data.Date}"); // фактическая дата ЕЦБ
        Console.WriteLine($"USD->EUR: {data.Rates["EUR"]}");
        Console.WriteLine($"USD->GBP: {data.Rates["GBP"]}");
    }
}

System.Text.Json десериализует числа JSON прямо в decimal (читая текстовый литерал), так что точность не теряется. decimal в .NET — правильный тип и для курсов, и для денег.


5. В каком типе данных хранить курс

Это, пожалуй, главный технический вопрос темы — и тот, где чаще всего ошибаются.

Почему не float/double

Двоичные числа с плавающей точкой (IEEE 754) физически не могут точно представить большинство десятичных дробей. 0.1 + 0.2 не равно 0.3. На одном курсе это незаметно, но при умножении на суммы, многократном пересчёте и агрегации за период ошибки накапливаются — и в финансовом отчёте появляются необъяснимые расхождения в копейках, которые не проходят сверку. Для денег и курсов float/double использовать нельзя.

Что использовать

Уровень Правильный выбор
База данных DECIMAL / NUMERIC с фиксированными precision и scale
Python decimal.Decimal
PHP BCMath / строки (или библиотека Money)
Java / C# BigDecimal / decimal
Go github.com/shopspring/decimal
JavaScript decimal.js / big.js (хранить как строку)

Сколько знаков

Курсы обычно публикуются с 4–6 знаками после запятой, но валюты с большим номиналом (UZS, IDR, старые суммы) дают крупные целые части. Безопасный компромисс для БД — NUMERIC(20, 6), а для нормализованного «курса за 1 единицу» иногда берут больше дробных знаков, например NUMERIC(24, 10), чтобы деление на номинал не теряло точность.

Что хранить, кроме самого числа

Курс без контекста бесполезен. Минимально полезная запись содержит источник, обе валюты, номинал, дату действия и тип курса:

sql
CREATE TABLE exchange_rates (
    id            BIGSERIAL PRIMARY KEY,
    source        VARCHAR(32)   NOT NULL,   -- 'CBR', 'NBRB', 'CBU', 'NBK', 'ECB'
    base_ccy      CHAR(3)       NOT NULL,   -- валюта котировки: RUB, BYN, UZS, KZT
    quote_ccy     CHAR(3)       NOT NULL,   -- что котируем: USD, EUR...
    nominal       INTEGER       NOT NULL DEFAULT 1,
    rate          NUMERIC(20,6) NOT NULL,   -- курс за `nominal` единиц (как у источника)
    rate_per_one  NUMERIC(24,10) NOT NULL,  -- нормализованный курс за 1 единицу
    rate_type     VARCHAR(8)    NOT NULL DEFAULT 'official', -- official | buy | sell
    effective_date DATE         NOT NULL,   -- дата, на которую действует курс
    fetched_at    TIMESTAMPTZ   NOT NULL DEFAULT now(),
    UNIQUE (source, base_ccy, quote_ccy, rate_type, effective_date)
);

Хранить и «сырой» rate (как отдал источник, с его номиналом), и нормализованный rate_per_one удобно: первый нужен для сверки с первоисточником, второй — для расчётов. Коды валют — по стандарту ISO 4217 (трёхбуквенные), это снимает проблему разных написаний.

Отдельно про денежные суммы (не курсы): их часто хранят как целое число в минорных единицах — копейках, центах, тийинах. То есть 19.99 USD = 1999. Это исключает дробную арифметику в принципе. Но сами курсы так не хранят — им нужна десятичная дробь.


6. Банковские курсы и агрегаторы

Официальный курс ЦБ — один. Но человек, который идёт менять валюту, видит совсем другие числа: у каждого банка свой курс покупки и продажи, со спредом, меняющийся в течение дня. Свести курсы десятков банков в одном месте — это и есть задача агрегаторов.

Примеры агрегаторов по странам:

  • Россия: banki.ru, sravni.ru, finmarket.ru — курсы банков, лучшие предложения покупки/продажи, по городам.
  • Беларусь: myfin.by, select.by, banki24.by — курсы обменников и банков, часто с разбивкой по городам и отделениям.
  • Казахстан: kurs.kz, mybanker.kz, bank.kz — курсы обменников по городам.
  • Узбекистан: bank.uz, kurs.uz — курсы банков к суму.
  • Мировые / P2P-крипто: p2p.army, fexant и подобные сводят курсы бирж и P2P-площадок.

Технический нюанс: у многих агрегаторов нет открытого API — данные приходится парсить из HTML. Здесь критично соблюдать правила: уважать robots.txt и условия использования, не долбить сайт запросами (rate limiting, кэширование), по возможности указывать источник. У части сервисов API есть, но платный. Данные же центробанков, как правило, можно свободно переиспользовать.

Если строите свой агрегатор, разумная архитектура — это набор независимых сборщиков (по одному на источник) → слой нормализации (приведение к ISO 4217, к курсу за единицу, разбор buy/sell) → единое хранилище → собственный API поверх. В модели данных, в отличие от курсов ЦБ, обязательно появляются измерения: банк, тип операции (покупка/продажа, наличный/безналичный), иногда город и сумма, и почти всегда — точное время снятия курса, потому что он живёт минутами.


7. Практические советы

  • Кэшируйте. ЦБ обновляют курс раз в сутки (изредка дважды). Дёргать API чаще, чем меняются данные, бессмысленно и вредно — кэшируйте на стороне приложения.
  • Учитывайте выходные и праздники. В нерабочие дни ЦБ не публикуют новый курс — обычно возвращается последний. Всегда смотрите на дату курса в ответе, а не на запрошенную.
  • Делайте fallback. Один источник может лечь. Полезно иметь запасной (например, мировой API вроде Frankfurter или другой ЦБ) и таймауты с повторами по экспоненциальной задержке.
  • Нормализуйте на входе. Перекодировка (windows-1251 у ЦБ РФ), замена запятой на точку, деление на номинал, приведение кодов к ISO 4217 — всё это лучше делать сразу при загрузке, а в БД класть уже чистые данные.
  • Мониторьте аномалии. Резкий скачок курса в разы — чаще признак ошибки источника или парсинга (забытый номинал!), чем реального события. Простая проверка «отклонение от вчерашнего не больше N%» ловит большинство таких случаев.

8. Где это применяется

  • E-commerce — мультивалютные цены, локализованный прайс для покупателя.
  • Бухгалтерия и учёт — пересчёт операций по курсу ЦБ на дату, налоги, таможенные платежи.
  • Финтех, кошельки, P2P-обмен — конвертация и отображение балансов.
  • Аналитика и BI-дашборды — приведение разновалютной выручки к одной валюте.
  • Конвертеры и тревел-сервисы — быстрый пересчёт для пользователя.
  • Договоры и биллинг — фиксация курса на дату выставления счёта.

Итого

Парсинг курсов — задача, в которой 80% сложности не в HTTP-запросе, а в деталях: номинал, кодировка, десятичный разделитель, выходные дни и — самое важное — выбор десятичного типа данных вместо float на всём пути от парсера до базы. Для официальных курсов почти всегда есть бесплатный источник у самого ЦБ; для банковских — нужны агрегаторы и аккуратный, уважительный к источнику парсинг. Сложите это с кэшированием и нормализацией на входе — и получите данные, которым можно доверять в финансовых расчётах.