Курс валюты — одна из тех величин, которая выглядит безобидно («просто число»), но при неаккуратном обращении превращается в источник трудноуловимых багов: расхождения в копейках, неверно посчитанный номинал, «потерянный» курс на выходных, накопленная ошибка округления в отчёте за год. Эта статья — практический разбор того, откуда брать курсы (центробанки СНГ и мировые сервисы), как их парсить на пяти языках, в каком типе данных хранить и почему банковские курсы — это отдельная история со своими агрегаторами.
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
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)
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)
// 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, строгая типизация)
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)
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), чтобы деление на номинал не теряло точность.
Что хранить, кроме самого числа
Курс без контекста бесполезен. Минимально полезная запись содержит источник, обе валюты, номинал, дату действия и тип курса:
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 на всём пути от парсера до базы. Для официальных курсов почти всегда есть бесплатный источник у самого ЦБ; для банковских — нужны агрегаторы и аккуратный, уважительный к источнику парсинг. Сложите это с кэшированием и нормализацией на входе — и получите данные, которым можно доверять в финансовых расчётах.