Граф связей 145 валют проекта AbsCur3
Граф связей 145 валют проекта AbsCur3

Граф валютных связей: 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-процесс, описанный в статье — это подготовка фундамента для этой системы. Сначала мы получаем качественные исторические данные, затем рассчитываем абсолютные курсы, и только после этого представляем результат через веб-интерфейс.

Правовой статус: производный научно-исследовательский продукт

Важно подчеркнуть: мы не перепродаём сырые данные полученные от поставщика. Мы создаём производный продукт — абсолютные валютные курсы, которые являются результатом сложных математических вычислений (метод наименьших квадратов) над исходными данными.

Это:

  1. Научное исследование — разработка нового метода измерения валютных ценностей

  2. Образовательный ресурс — открытая платформа для изучения финансовой математики

  3. Инфраструктурный проект — предоставление уникальных данных сообществу

Все исходные коды и конфигурации открыты на 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 накладывает следующие ключевые ограничения:

  1. Лимит скорости: 8 запросов в минуту

  2. Лимит запросов: 800 запросов в день

  3. Лимит объёма: максимум 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, а не база данных?

  1. Совместимость с научным стеком: Pandas, NumPy, SciPy (для МНК) отлично работают с CSV

  2. Прозрачность: данные можно проверить без специальных инструментов

  3. Масштабируемость: каждый файл независим, можно обрабатывать параллельно

Результаты загрузки: данные для математики

После выполнения скрипта мы получили:

✅ Всего валютных пар: 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. Например, для построения графиков можно использовать прямое обращение к файлам данных.

Для чего нужны эти данные в веб-интерфейсе:

  1. Графики парных курсов — отображение исторических данных в привычном формате

  2. Визуализация графа валют — интерактивное представление связей между валютами

  3. Расчёт абсолютных курсов — метод наименьших квадратов на основе полного графа из 287 уравнений

  4. Диаграммы сравнения — сопоставление динамики разных валют на единой шкале


Дорожная карта: следующий шаг — веб-интерфейс

Текущий ETL-процесс — это фундамент. Далее проект развивается по чёткому плану:

  1. Расчёт абсолютных курсов — реализация метода наименьших квадратов для системы из 287 уравнений

  2. Разработка веб-интерфейса — создание интуитивного интерфейса для визуализации данных

  3. Публикация на GitHub Pages — размещение работающего прототипа в открытом доступе

Каждый этап строится на результатах предыдущего, обеспечивая последовательное развитие проекта от сырых данных к готовому продукту.


Заключение: от данных к интерфейсу

Проект AbsCur3 демонстрирует комплексный подход к созданию финансовых аналитических инструментов:

  1. Качественные данные — основа точных расчётов. Строгий ETL с гарантиями сортировки и дедупликации обеспечивает надёжную основу для математических вычислений.

  2. Полный граф — математическая необходимость. 287 валютных пар для 153 валют — это не избыточность, а требование для устойчивого решения системы уравнений методом наименьших квадратов.

  3. Открытая инфраструктура — доступность. Использование GitHub для всего жизненного цикла проекта делает его максимально доступным для исследователей и разработчиков.

  4. Чёткая архитектура — предсказуемость развития. Разделение на ETL, алгоритмическую часть и веб-интерфейс позволяет развивать компоненты независимо.

Проект продолжает развитие, и следующий этап — создание веб-интерфейса, который превратит сложные математические расчёты в интуитивные визуализации для аналитиков и исследователей.

Все материалы проекта доступны на GitHub: https://github.com/prog815/abscur3

Контекст и предыдущие исследования:

Комментарии (6)


  1. kmatveev
    05.01.2026 17:22

    AbsCur3 (размещен на GitHub)

    Ссылку не дали, и такое не гуглится, я попробовал.

    Но что если вам нужна именно полная картина — история 287 пар за 20 лет, включая экзотические связки вроде ILS/UYU или KWD/MYR?

    Нафига? Такое торгуется через доллар как валюту-посредник.

    Отлично, понимаю! Откорректируем статью:

    Вычитайте уже нормально ваше LLM-поделие и уберите ошмётки диалогов с LLM-кой

    Быстрая арифметика:

    • При линейной загрузке: 574 запроса / 8 запр./мин = 72 минуты (и это без учёта ошибок и пауз)

    Казалось бы, задача на грани выполнимости. Но именно здесь начинается интересная часть — архитектурное решение.

    Спойлер: Наш финальный скрипт загрузил все 287 пар за ~4.5 часа

    72 минуты - это чуть больше часа, а вы сделали за 4.5 часа, это да, на грани выполнимости. Спросите LLM-ку, как сделать так, чтобы параллельно выполнялась сортировка/дедупликация загруженных данных и загрузка новых данных, "архитектурное решение" у них, понимаешь.


    1. Viktor-T
      05.01.2026 17:22

      Может быть приватным был? https://github.com/prog815/abscur3


  1. 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.


  1. mnv
    05.01.2026 17:22

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


  1. akirsanov
    05.01.2026 17:22

    чукча не читатель, чукча нейрописатель

    Отлично, понимаю! Откорректируем статью:

    1. Картинку предлагаю отредактировать в Figma (бесплатно, есть веб-версия) или GIMP (open-source аналог Photoshop). Можно просто обрезать заголовок и сделать акцент на визуализации связей.

    2. Ежедневное обновление — уберём упоминание, так как это следующий этап.


  1. Best-sda
    05.01.2026 17:22

    Когда уже нейропостинг на хабре будут помечать? Тут "автор" даже не прочитал то что ему нейронка выдала...