
Граф валютных связей: 153 вершин, 287 рёбер — математический фундамент для расчёта абсолютных курсов
Что, если бы существовал единый стандарт измерения стоимости валют — абсолютная шкала, подобная температурной шкале Кельвина? Мы работаем над проектом, который превращает эту идею в реальность: AbsCur3 — открытая платформа для вычисления и визуализации абсолютных валютных курсов.
Проект является технологическим развитием нашего предыдущего исследования «Абсолютный валютный курс» (статья на Хабре, прототип на Kaggle). Если AbsCur2 был доказательством концепции, то AbsCur3 — это промышленная реализация с веб-интерфейсом, охватывающая 153 валюты вместо 45.
Но прежде чем строить дом, нужно заложить фундамент. В этой статье мы расскажем, как создали этот фундамент — масштабируемый ETL-процесс, который загружает исторические данные по 287 валютным парам. Это не самоцель, а необходимый первый шаг к чему-то большему: системе, которая преобразует парные котировки в универсальную абсолютную шкалу и представляет её через интуитивный веб-интерфейс.
Мы столкнулись с классической инженерной дилеммой: чтобы рассчитать абсолютные курсы для 153 валют, нужна история их попарных отношений. Однако готовых датасетов с таким покрытием не существует — мир финансовых данных живёт по принципу «парето», где 80% информации сконцентрировано вокруг 20% мажорных валют.
После анализа 10+ API мы выбрали Twelve Data — единственный источник, дающий в рамках бесплатного тарифа доступ ко всем необходимым 287 парам. Но цена этого выбора — технические ограничения: 8 запросов в минуту и максимум 5000 дней за один запрос.
Как загрузить 20+ лет истории для 287 пар в таких условиях? Как обеспечить качество данных для последующих математических расчётов? И главное — как этот ETL связан с конечной целью проекта — веб-интерфейсом абсолютных курсов?
В этой статье — технический разбор того, как мы:
Спроектировали ETL-систему как фундамент для веб-интерфейса AbsCur3
Реализовали стратегический Rate Limiting и интеллектуальное чанкование данных
Обеспечили гарантии качества, необходимые для точных математических расчётов
Подготовили открытую инфраструктуру для следующего этапа — вычисления абсолютных курсов методом наименьших квадратов
Статья будет полезна backend-разработчикам, data engineers и архитекторам, которые проектируют системы, где качество данных напрямую влияет на точность последующих вычислений и пользовательский опыт.
От парных котировок к абсолютной шкале: философия проекта AbsCur3
Почему нам нужны именно 287 пар, а не 20 самых популярных?
Большинство финансовых сервисов работают с парными курсами: EUR/USD, USD/JPY, GBP/USD. Это удобно для трейдеров, но неудобно для анализа: парный курс дает представление об отношении двух валют друг к другу, но дает понимания о каждой по-отдельности.
Проект "Абсолютный валютный курс" решает эту проблему принципиально иным способом. Мы вычисляем для каждой валюты абсолютный курс — числовое значение на единой шкале, где можно напрямую сравнивать любые валюты без посредников.
Математическая основа: Если у нас есть N валют, то идеальный абсолютный курс для валюты i можно обозначить как Aᵢ. Парный курс между валютами i и j в теории должен равняться Aᵢ/Aⱼ. На практике из-за рыночных шумов это равенство нарушается.
Задача сводится к решению системы из M уравнений (где M — количество парных наблюдений) методом наименьших квадратов (МНК) для нахождения оптимальных значений Aᵢ. И здесь возникает ключевой момент:
Для приемлемого решения системы уравнений методом наименьших квадратов нам нужен полный граф связей между валютами.
Вот почему мы загружаем именно 287 пар для 153 валют:
Научная необходимость: Каждое дополнительное ребро в графе (валютная пара) добавляет уравнение в систему, повышая точность решения
Устойчивость к выбросам: При 287 наблюдениях случайные ошибки в отдельных парах меньше влияют на общий результат
Аналогия: Представьте, что вы пытаетесь определить вес 153 предметов, имея только попарные сравнения на весах. Если вы сравните каждый предмет с каждым (полный граф), вы получите максимально точный результат. Если сравните только некоторые предметы (редкий граф) — точность снизится.
Веб-интерфейс как конечная цель
Собранные данные — не самоцель, а сырьё для создания веб-интерфейса AbsCur3, который позволит:
Сравнивать любые валюты на одном графике (евро, йена и рубль в одинаковых единицах измерения)
Анализировать относительную силу валют на абсолютной шкале
Скачивать абсолютные курсы для собственных исследований
Визуализировать граф валютных связей (как на картинке выше)
ETL-процесс, описанный в статье — это подготовка фундамента для этой системы. Сначала мы получаем качественные исторические данные, затем рассчитываем абсолютные курсы, и только после этого представляем результат через веб-интерфейс.
Правовой статус: производный научно-исследовательский продукт
Важно подчеркнуть: мы не перепродаём сырые данные полученные от поставщика. Мы создаём производный продукт — абсолютные валютные курсы, которые являются результатом сложных математических вычислений (метод наименьших квадратов) над исходными данными.
Это:
Научное исследование — разработка нового метода измерения валютных ценностей
Образовательный ресурс — открытая платформа для изучения финансовой математики
Инфраструктурный проект — предоставление уникальных данных сообществу
Все исходные коды и конфигурации открыты на GitHub: https://github.com/prog815/abscur3.
Архитектурный вызов: 287 пар × 20 лет ÷ (8 запросов/мин × 5000 дней/запрос)
Когда мы определили, что для расчёта абсолютных курсов нужны все 287 пар, встал технический вопрос: как получить эти данные?
Анализ рынка источников данных
Мы проанализировали более 10 API и публичных датасетов. Вот данные по нескольким из них:
Источник |
Глубина |
Покрытие |
Лимиты |
Пригодность для AbsCur3 |
|---|---|---|---|---|
Alpha Vantage |
20+ лет |
~50 пар |
500/день |
❌ Не хватает покрытия |
Finnhub |
20+ лет |
~100 пар |
60/мин |
❌ Нет многих экзотических пар |
ECB API |
25+ лет |
Только EUR-пары |
Нет |
⚠️ Только для валидации |
Frankfurter.app |
25+ лет |
~70 пар |
Неявные |
❌ Мало экзотики |
Twelve Data |
20+ лет |
1000+ пар |
8/мин, 800/день |
✅ Единственный с полным покрытием |
Twelve Data стал единственным жизнеспособным вариантом: бесплатный тариф давал доступ ко всем 287 парам с историей с 1989 года.
Арифметика ограничений
Бесплатный тариф Twelve Data накладывает следующие ключевые ограничения:
Лимит скорости: 8 запросов в минуту
Лимит запросов: 800 запросов в день
Лимит объёма: максимум 5000 точек данных за один запрос
Быстрый расчёт теоретического минимума:
Для пар с историей >5000 дней нужно 2+ запроса
287 пар × 2 запроса = 574 запроса
При максимальной скорости: 574 запроса / 8 запр./мин = 72 минуты
Но это идеальный случай без учёта:
Обработки и сохранения данных на диск
Пауз для гарантированного соблюдения лимита
Особенностей API с экзотическими парами
Наша стратегия: использовать не 8, а 7 запросов в минуту, оставляя один «про запас». Это даёт:
Теоретическое время: 574 / 7 ≈ 82 минуты
Запас надёжности для непредвиденных задержек
Гарантию отсутствия блокировок
Реальное время выполнения составило 4.5 часа. Разница объясняется дополнительными факторами: обработка данных на диске, вариативная глубина истории для разных пар, и — что важно — бескомпромиссное соблюдение лимитов API как принципиальная позиция проекта.
Архитектура ETL: стратегический rate limiting и чанкование данных
Итак, у нас есть 287 пар, жёсткий лимит 8 запросов в минуту и необходимость загрузить 20+ лет истории для каждой. Прямой подход «одна пара — один запрос» не работает. Вместо этого мы разработали трёхуровневую архитектуру.
1. Rate Limiter: сердце системы, которое думает наперёд
Главный вызов — никогда не превысить 8 запросов в минуту. Мы реализовали класс, который не просто считает запросы, а прогнозирует нагрузку:
class StrategicRateLimiter:
def __init__(self, max_per_minute, safety_buffer=1):
self.max_per_minute = max_per_minute
self.safety_buffer = safety_buffer # Оставляем "запасной" запрос
self.request_timestamps = []
def wait_if_needed(self):
now = time.time()
minute_ago = now - 60
# Очищаем старые записи
self.request_timestamps = [ts for ts in self.request_timestamps if ts > minute_ago]
# Работаем с учётом буфера безопасности
effective_limit = self.max_per_minute - self.safety_buffer
if len(self.request_timestamps) >= effective_limit:
# Рассчитываем точное время ожидания до освобождения слота
oldest_request = self.request_timestamps[0]
sleep_time = 60 - (now - oldest_request) + 0.1 # Небольшая дополнительная задержка
logger.info(f"Лимит приближается. Стратегическая пауза {sleep_time:.1f} сек.")
time.sleep(sleep_time)
self.request_timestamps = self.request_timestamps[1:] # Освобождаем слот
self.request_timestamps.append(now)
return True
Ключевое отличие от простого rate limiter: Мы не ждём, пока лимит будет полностью исчерпан. Система заранее предвидит проблему и делает стратегическую паузу, оставляя буфер для непредвиденных запросов (например, повторных попыток при ошибках сети).
2. Интеллектуальное чанкование: адаптация к реальным данным
Второе ограничение — максимум 5000 дней за запрос. Но не у всех пар история начинается в один день:
EUR/USD: с 1979-12-24 (≈16,800 дней) → 4 чанка
USD/AED: с 1990-03-07 (≈13,000 дней) → 3 чанка
UAH/KZT: с 2010-05-10 (≈5,700 дней) → 2 чанка
Мы сначала запрашиваем метаданные, чтобы узнать реальную глубину истории для каждой пары:
def get_earliest_available_date(symbol, cache_dir="metadata"):
"""Умное кэширование: запрашиваем начальную дату только если её нет в кэше"""
cache_file = os.path.join(cache_dir, "earliest_dates.json")
if os.path.exists(cache_file):
with open(cache_file, 'r') as f:
cached_dates = json.load(f)
if symbol in cached_dates:
return datetime.strptime(cached_dates[symbol], '%Y-%m-%d')
# Запрос к API /earliest_timestamp
earliest_date = fetch_earliest_from_api(symbol)
# Сохраняем в кэш для будущих запусков
with open(cache_file, 'w') as f:
cached_dates[symbol] = earliest_date.strftime('%Y-%m-%d')
json.dump(cached_dates, f, indent=2)
return earliest_date
Затем рассчитываем чанки с учётом реальной истории:
def calculate_optimal_chunks(symbol, chunk_size=5000):
"""Рассчитывает оптимальные чанки для загрузки"""
earliest_date = get_earliest_available_date(symbol)
today = datetime.now().date()
total_days = (today - earliest_date).days
chunks_needed = max(1, (total_days // chunk_size) + (1 if total_days % chunk_size else 0))
chunks = []
for i in range(chunks_needed):
chunk_start = earliest_date + timedelta(days=i * chunk_size)
chunk_end = min(
chunk_start + timedelta(days=chunk_size - 1),
today
)
chunks.append((chunk_start, chunk_end))
logger.debug(f"{symbol}: {total_days} дней → {chunks_needed} чанков")
return chunks
3. Умная загрузка: только недостающие данные
Загружать всё заново при каждом запуске — неэффективно. Наша система проверяет текущее состояние данных и загружает только то, чего не хватает:
[Для каждой из 287 пар]
├── Существует ли CSV файл?
│ ├── Да: читаем последнюю дату из файла
│ │ ├── Сравниваем с сегодняшним днём
│ │ │ ├── Если разница < 2 дня → данные актуальны, пропускаем
│ │ │ └── Если разница > 2 дней → догружаем недостающие дни
│ └── Нет: загружаем полную историю с самого начала
Это особенно важно для экзотических пар, где история может начинаться гораздо позже, чем у мажорных. Система автоматически адаптируется к особенностям каждой пары.
Структура данных: простота для сложных вычислений
Все данные сохраняются в предсказуемой структуре, удобной для последующей обработки методом наименьших квадратов:
data/raw/twelve_data/
├── pairs/ # 287 CSV файлов, готовых для МНК
│ ├── USDAED.csv # USD/AED (экзотическая, с 1990-03-07)
│ ├── EURUSD.csv # EUR/USD (мажорная, с 1979-12-24)
│ ├── AUDCAD.csv # AUD/CAD (минорная, с 1984-11-27)
│ └── ... (284 файла)
└── metadata/
├── earliest_dates.json # Кэш начальных дат для оптимизации
└── currency_graph.json # Математическое представление графа
Каждый CSV содержит 5 колонок и отсортирован по возрастанию даты — это обязательно для временных рядов:
date,open,high,low,close
2007-01-03,3.6730,3.6730,3.6730,3.6730
2007-01-04,3.6730,3.6730,3.6730,3.6730
...
2026-01-02,3.6725,3.6725,3.6725,3.6725
Почему именно CSV, а не база данных?
Совместимость с научным стеком: Pandas, NumPy, SciPy (для МНК) отлично работают с CSV
Прозрачность: данные можно проверить без специальных инструментов
Масштабируемость: каждый файл независим, можно обрабатывать параллельно
Результаты загрузки: данные для математики
После выполнения скрипта мы получили:
✅ Всего валютных пар: 287 (полный граф для 153 валют)
✅ Уникальных валют: 153
✅ Запросов к API: 762 (в пределах суточного лимита 800)
✅ Объём исторических данных: >1,000,000 строк OHLC
✅ Объём на диске: ~90 MB
✅ Время выполнения: 4.5 часа (с гарантией соблюдения лимитов)
Ключевое достижение: Ни одного нарушения лимитов API. Все данные отсортированы, дедуплицированы и готовы к использованию в алгоритме метода наименьших квадратов.
Архитектурный принцип: При работе с ограниченными API всегда проектируйте систему с запасом надёжности. Наш буфер в 1 запрос (7 вместо 8) спас бы от блокировки даже при сбое сети или неожиданном перезапросе.
Гарантии качества: подготовка данных для точных математических расчётов
Когда вы работаете с методом наименьших квадратов, качество входных данных критически важно. Ошибка в одном парном курсе может исказить абсолютные курсы для десятков связанных валют. Наша система гарантий качества состоит из четырёх уровней.
1. Дедупликация с приоритетом новых данных
При догрузке данных и чанковании возможны ситуации, когда одна и та же дата встречается в нескольких чанках. Наша система слияния обеспечивает целостность:
def merge_data_with_priority(existing_data, new_data):
"""Объединяет данные, отдавая приоритет новым значениям"""
# Преобразуем в словари для быстрого поиска
existing_dict = {row['date']: row for row in existing_data}
new_dict = {row['date']: row for row in new_data}
# Новые данные перезаписывают старые (приоритет актуальности)
merged_dict = {**existing_dict, **new_dict}
# Сортировка по дате — обязательно для временных рядов
sorted_dates = sorted(merged_dict.keys())
return [merged_dict[date] for date in sorted_dates]
Почему приоритет новым данным? API может возвращать исправленные значения для исторических дат. При догрузке мы хотим получить самую актуальную информацию, даже если она противоречит ранее загруженной.
2. Обязательная сортировка по времени
Временные ряды для финансовых расчётов должны быть строго отсортированы. Наш конвейер гарантирует это:
Неправильно (сырые данные от API): Правильно (после нашей обработки):
2026-01-02, 0.67120 2007-01-03, 0.88560
2007-01-04, 0.88560 2007-01-04, 0.88560
2007-01-03, 0.88560 ... 7300 дней ...
... 2026-01-01, 0.67280
2025-12-31, 0.67310 2026-01-02, 0.67120
Это критически важно для:
Корректного расчёта индексов в Pandas/NumPy
Построения непрерывных графиков без временных разрывов
Применения оконных функций (скользящие средние, ATR и т.д.)
3. Валидация структуры: пять полей OHLC
Каждая строка проходит проверку на корректность формата:
def validate_ohlc_point(point, symbol):
"""Валидирует точку OHLC-данных"""
required_fields = ['datetime', 'open', 'high', 'low', 'close']
for field in required_fields:
if field not in point:
logger.warning(f"Пропущено поле {field} для {symbol}, дата {point.get('datetime', 'unknown')}")
point[field] = '' # Заменяем на пустую строку вместо сбоя
# Дополнительная проверка: high >= low
try:
high_val = float(point['high']) if point['high'] else 0
low_val = float(point['low']) if point['low'] else 0
if high_val < low_val and high_val != 0 and low_val != 0:
logger.warning(f"Некорректные high/low для {symbol}: high={high_val} < low={low_val}")
except ValueError:
pass # Игнорируем ошибки преобразования для пустых значений
return point
Философия: Лучше сохранить неполные данные, чем уронить весь процесс загрузки. Для экзотических пар иногда отсутствуют значения open или high — мы отмечаем это, но продолжаем обработку.
4. Метаданные для математического графа
Помимо сырых данных, мы сохраняем структурированное представление графа валютных связей:
{
"currencies": ["USD", "EUR", "JPY", "GBP", "AUD", ...],
"edges": [
{"from": "USD", "to": "EUR", "pair": "EUR/USD", "data_file": "EURUSD.csv"},
{"from": "USD", "to": "JPY", "pair": "USD/JPY", "data_file": "USDJPY.csv"},
...
],
"graph_properties": {
"vertices": 153,
"edges": 287,
"is_connected": true,
"average_degree": 3.75
}
}
Этот файл — основа для построения системы уравнений методом наименьших квадратов. Каждое ребро графа превращается в уравнение вида (наверное мы немного забегаем вперед. Статью по расчету абсолютных курсов методом наименьших квадратов еще предстоит написать):
log(A_EUR) - log(A_USD) ≈ log(EURUSD_close)
Результат: данные, готовые для научных вычислений
После обработки каждый CSV файл обладает гарантиями:
Гарантия |
Реализация |
Важность для МНК |
|---|---|---|
Без дубликатов дат |
Приоритет новым данным |
Исключает переопределение уравнений |
Строгая временная сортировка |
Принудительная сортировка при сохранении |
Корректные временные индексы |
Полные OHLC-структуры |
Валидация всех полей |
Единый формат для всех уравнений |
Кэшированные метаданные |
JSON с графом связей |
Быстрое построение системы уравнений |
Публикация данных: инфраструктура для веб-интерфейса
Вся инфраструктура проекта построена на GitHub — от хранения исходного кода до будущего размещения веб-интерфейса. Это обеспечивает нулевую стоимость инфраструктуры при максимальной доступности.
Открытая архитектура проекта
Структура проекта оптимизирована для будущего веб-интерфейса:
abscur3/
├── data/raw/twelve_data/pairs/ # 287 CSV с историческими данными
├── scripts/ # ETL и аналитические скрипты
├── requirements.txt # Зависимости Python
└── README.md # Полная документация
Все данные доступны напрямую через GitHub, что позволяет будущему веб-интерфейсу загружать их без промежуточных API. Например, для построения графиков можно использовать прямое обращение к файлам данных.
Для чего нужны эти данные в веб-интерфейсе:
Графики парных курсов — отображение исторических данных в привычном формате
Визуализация графа валют — интерактивное представление связей между валютами
Расчёт абсолютных курсов — метод наименьших квадратов на основе полного графа из 287 уравнений
Диаграммы сравнения — сопоставление динамики разных валют на единой шкале
Дорожная карта: следующий шаг — веб-интерфейс
Текущий ETL-процесс — это фундамент. Далее проект развивается по чёткому плану:
Расчёт абсолютных курсов — реализация метода наименьших квадратов для системы из 287 уравнений
Разработка веб-интерфейса — создание интуитивного интерфейса для визуализации данных
Публикация на GitHub Pages — размещение работающего прототипа в открытом доступе
Каждый этап строится на результатах предыдущего, обеспечивая последовательное развитие проекта от сырых данных к готовому продукту.
Заключение: от данных к интерфейсу
Проект AbsCur3 демонстрирует комплексный подход к созданию финансовых аналитических инструментов:
Качественные данные — основа точных расчётов. Строгий ETL с гарантиями сортировки и дедупликации обеспечивает надёжную основу для математических вычислений.
Полный граф — математическая необходимость. 287 валютных пар для 153 валют — это не избыточность, а требование для устойчивого решения системы уравнений методом наименьших квадратов.
Открытая инфраструктура — доступность. Использование GitHub для всего жизненного цикла проекта делает его максимально доступным для исследователей и разработчиков.
Чёткая архитектура — предсказуемость развития. Разделение на ETL, алгоритмическую часть и веб-интерфейс позволяет развивать компоненты независимо.
Проект продолжает развитие, и следующий этап — создание веб-интерфейса, который превратит сложные математические расчёты в интуитивные визуализации для аналитиков и исследователей.
Все материалы проекта доступны на GitHub: https://github.com/prog815/abscur3
Контекст и предыдущие исследования:
Комментарии (6)

seriych
05.01.2026 17:22ценность для сообщества... Open-source данные... под лицензией MIT...
Вообще-то данные предоставлены в нарушение лицензии сервиса, откуда вы из взяли. Так вы не приносите ценность open-source сообществу, а вредите ему показывая ворованные данные в этом сообществе.
2.1 Platform access license
Subject to the terms of this Agreement and payment of applicable Fees, Twelve Data grants Customer a limited, revocable, non-exclusive, non-transferable, non-sublicensable license to access and use the Platform solely for Internal Use during the subscription term, except as otherwise expressly permitted by your Subscription Tier, Data add-ons, or a separate written agreement with Twelve Data.
"Internal Use" means use solely for Customer's internal business purposes and not for redistribution or external commercial purposes.

mnv
05.01.2026 17:22Twelve Data да, классный сервис. По форексу там помимо прямых курсов можно и любые экзотические получать через /time_series/cross. А для чего вам понадобилось столько пар?

akirsanov
05.01.2026 17:22чукча не читатель, чукча нейрописатель
Отлично, понимаю! Откорректируем статью:Картинкупредлагаю отредактировать вFigma(бесплатно, есть веб-версия) илиGIMP(open-source аналог Photoshop). Можно просто обрезать заголовок и сделать акцент на визуализации связей.Ежедневное обновление— уберём упоминание, так как это следующий этап.

Best-sda
05.01.2026 17:22Когда уже нейропостинг на хабре будут помечать? Тут "автор" даже не прочитал то что ему нейронка выдала...
kmatveev
Ссылку не дали, и такое не гуглится, я попробовал.
Нафига? Такое торгуется через доллар как валюту-посредник.
Вычитайте уже нормально ваше LLM-поделие и уберите ошмётки диалогов с LLM-кой
72 минуты - это чуть больше часа, а вы сделали за 4.5 часа, это да, на грани выполнимости. Спросите LLM-ку, как сделать так, чтобы параллельно выполнялась сортировка/дедупликация загруженных данных и загрузка новых данных, "архитектурное решение" у них, понимаешь.
Viktor-T
Может быть приватным был? https://github.com/prog815/abscur3