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

Парсинг котировок акций: API, источники, код и хранение данных

Откуда брать котировки акций: биржевые API, финансовые порталы, код на Python и организация хранения исторических данных.

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

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


1. Что такое «котировка» и какие данные бывают

Под «котировкой» в зависимости от задачи понимают разные вещи, и это первое, что нужно определить:

  • Last / текущая цена — цена последней сделки. То, что показывают в виджете «цена акции сейчас».
  • OHLCV-бар (свеча) — Open, High, Low, Close и Volume за интервал (минута, час, день). Основа графиков и бэктестов.
  • Bid/Ask (стакан) — лучшие цены покупки и продажи. Нужны для трейдинга, обычно это самые «дорогие» и лицензируемые данные.
  • EOD (end-of-day) — итоговые цены закрытия дня. Дёшево/бесплатно, подходит для аналитики и портфельного учёта.
  • Adjusted close — цена закрытия, скорректированная на сплиты и дивиденды, чтобы история была непрерывной.

И ключевая развилка по «свежести»: реальное время → задержка 15–20 минут → end-of-day. Чем свежее и детальнее данные, тем строже лицензия и выше цена.


2. Биржи СНГ и региональные источники

Главный «дружелюбный к разработчику» источник в регионе — MOEX ISS API. Это полноценный REST-сервис Московской биржи, который отдаёт акции, облигации, фьючерсы, индексы и валютные пары в JSON/XML/CSV. Данные с задержкой около 15 минут доступны бесплатно; реальное время и биржевой стакан — по подписке.

Биржа Что отдаёт Доступ
MOEX (Москва), iss.moex.com акции, облигации, фьючерсы, индексы, валютные пары задержка ~15 мин бесплатно; реалтайм и стакан — по подписке
KASE (Казахстан), kase.kz акции, индекс KASE, облигации данные публикуются на сайте; стабильного публичного REST API уровня MOEX обычно нет — берут у вендоров или парсингом
UZSE (Ташкент), uzse.uz акции узбекских эмитентов данные на сайте
СПБ Биржа иностранные бумаги сайт/вендоры

Удобный мостик к валютной статье: MOEX ISS отдаёт не только акции, но и валютные пары (например, USD000UTSTOM — доллар к рублю на бирже). То есть одна интеграция с ISS закрывает сразу и акции, и биржевой курс валют — биржевой, а не официальный курс ЦБ.

Особенность формата ISS: данные приходят «колоночно» — отдельно массив имён колонок (columns) и массив строк (data). Чтобы достать нужное поле, надо сопоставить имя колонки с её индексом, а не полагаться на фиксированный порядок (см. пример на PHP).


3. Мировые API

Здесь свободного «как у ЦБ» источника нет: у всех есть ключ и лимиты, а реальное время почти всегда платное.

Сервис Покрытие Ключ Бесплатный лимит Заметки
MOEX ISS рынки MOEX не нужен задержка ~15 мин реалтайм платно
Finnhub акции US и интернац., FX, крипто нужен ~60 запросов/мин есть WebSocket; история на free ограничена
Twelve Data акции, FX, крипто нужен ~800 запросов/день OHLC, индикаторы, чистый REST
Alpha Vantage 200k+ тикеров, 20+ бирж нужен 25 запросов/день (5/мин) EOD и индикаторы; реалтайм US — платно
yfinance (неоф. Yahoo) очень широкое не нужен явного лимита нет, но это скрейпинг для прототипов и обучения, не для продакшена
EODHD 150k+ тикеров глобально нужен пробный силён массовой выгрузкой истории
Financial Modeling Prep цены + фундаментал нужен лимит отчётность, мультипликаторы
Tiingo EOD + фундаментал US нужен лимит чистые end-of-day
Marketstack / Polygon.io глобальные / реалтайм US нужен пробный / по сути платно Polygon — низкая задержка, тики

Важная свежая деталь: IEX Cloud закрылся 31 августа 2024 года. Если в каком-то гайде он ещё рекомендуется — это устаревшая информация; ближайшие замены — Alpha Vantage и Financial Modeling Prep.

Бесплатные тарифы отлично подходят для прототипа, но быстро упираются в лимиты (25 запросов в сутки у Alpha Vantage — это буквально пара десятков тикеров в день). Поэтому на бесплатном тарифе всегда строят логику вокруг кэша и локального хранения: тянут историю один раз, дальше только обновляют свежие точки.


4. Чем котировки акций сложнее курсов валют

Несколько отличий, которые ломают наивный парсер:

  • Тикер не уникален. Один и тот же символ может торговаться на разных биржах. Поэтому ключ инструмента — это пара «биржа + тикер», а надёжнее — международные идентификаторы ISIN или FIGI.
  • Торговые сессии и праздники. У каждой биржи свой график, часовой пояс, пред- и постторговые сессии. «Последняя цена» в выходные — это цена пятницы.
  • Корпоративные действия. Сплит 1:10 «уронит» цену в 10 раз — но это не падение рынка, а технический пересчёт. Аналог поля номинал из валютной статьи: проигнорируете — получите ложную аномалию. Дивиденды и сплиты учитывают через adjusted close.
  • Валюта инструмента. Цена бумаги выражена в валюте биржи (RUB, USD, KZT…), и для портфеля в одной валюте её нужно пересчитывать — вот тут и пригождаются курсы из соседней статьи.
  • Объём (volume). Это целое число, но очень большое — миллионы и миллиарды штук. Под него нужен 64-битный целочисленный тип.

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

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

5.1. PHP — MOEX ISS (колоночный JSON, СНГ)

php
<?php
declare(strict_types=1);

/**
 * Последняя цена акции на Московской бирже, основной режим TQBR.
 * Бесплатно — с задержкой ~15 минут.
 */
function moexLastPrice(string $ticker, string $board = 'TQBR'): ?string
{
    $url = sprintf(
        'https://iss.moex.com/iss/engines/stock/markets/shares/boards/%s/securities/%s.json?iss.meta=off',
        $board,
        $ticker
    );

    $raw = file_get_contents($url);
    if ($raw === false) {
        throw new RuntimeException('Не удалось получить данные MOEX');
    }
    $json = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);

    // ISS отдаёт данные «колоночно»: отдельно columns, отдельно data
    $columns = $json['marketdata']['columns'] ?? [];
    $rows    = $json['marketdata']['data'] ?? [];
    if (!$rows) {
        return null; // нет торгов или неверный тикер
    }

    $idx  = array_flip($columns);             // имя колонки -> индекс
    $last = $rows[0][$idx['LAST']] ?? null;   // цена последней сделки

    return $last === null ? null : (string) $last;
}

echo 'SBER: ' . (moexLastPrice('SBER') ?? 'нет данных') . " RUB\n";
echo 'GAZP: ' . (moexLastPrice('GAZP') ?? 'нет данных') . " RUB\n";

Здесь показателен array_flip($columns) — сопоставление имени колонки с индексом. Это правильный способ читать ISS: порядок полей не гарантирован, и «магические» индексы вроде [12] ломаются при изменении ответа. Для бит-точной цены лучше запрашивать CSV или хранить сырой текст — JSON-декодер уже превращает число в float.

5.2. Python — Finnhub (текущая котировка, Decimal)

python
import os
import requests
from decimal import Decimal

FINNHUB_TOKEN = os.environ["FINNHUB_TOKEN"]  # бесплатный ключ, ~60 запросов/мин


def finnhub_quote(symbol: str) -> dict[str, Decimal]:
    """Текущая котировка по символу (на бесплатном тарифе — рынок США)."""
    resp = requests.get(
        "https://finnhub.io/api/v1/quote",
        params={"symbol": symbol, "token": FINNHUB_TOKEN},
        timeout=10,
    )
    resp.raise_for_status()
    d = resp.json()
    # Decimal(str(...)) фиксирует ровно то значение, что пришло в JSON
    return {
        "current":    Decimal(str(d["c"])),   # текущая цена
        "open":       Decimal(str(d["o"])),
        "high":       Decimal(str(d["h"])),
        "low":        Decimal(str(d["l"])),
        "prev_close": Decimal(str(d["pc"])),
    }


if __name__ == "__main__":
    q = finnhub_quote("AAPL")
    print(f"AAPL: {q['current']} USD (откр. {q['open']}, макс. {q['high']})")

5.3. JavaScript / Node.js — Twelve Data (OHLC, объём)

javascript
// Бесплатно: ~800 запросов в день. Ключ обязателен.
const API_KEY = process.env.TWELVE_DATA_KEY;

async function twelveQuote(symbol) {
  const url = new URL("https://api.twelvedata.com/quote");
  url.searchParams.set("symbol", symbol);
  url.searchParams.set("apikey", API_KEY);

  const res = await fetch(url);
  const d = await res.json();
  if (d.status === "error") throw new Error(d.message);

  return {
    symbol: d.symbol,
    open: d.open,        // строки — не приводим к Number без необходимости
    high: d.high,
    low: d.low,
    close: d.close,
    volume: d.volume,    // объём — большое целое, держим строкой/BigInt
    exchange: d.exchange,
  };
}

twelveQuote("MSFT").then((q) =>
  console.log(`${q.symbol} (${q.exchange}): close ${q.close}, vol ${q.volume}`)
);

В JavaScript нет десятичного типа, а Number — это double. Поэтому цены оставлены строками; для арифметики над ценами берут decimal.js/big.js, а для объёма — нативный BigInt.

5.4. Go — Alpha Vantage (дневные свечи OHLCV)

go
package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "sort"
    "time"
)

// Alpha Vantage: бесплатно 25 запросов/сутки, 5/мин.
type avDaily struct {
    Series map[string]struct {
        Open   string `json:"1. open"`
        High   string `json:"2. high"`
        Low    string `json:"3. low"`
        Close  string `json:"4. close"`
        Volume string `json:"5. volume"`
    } `json:"Time Series (Daily)"`
}

func dailyBars(symbol string) (avDaily, error) {
    key := os.Getenv("ALPHAVANTAGE_KEY")
    url := fmt.Sprintf(
        "https://www.alphavantage.co/query?function=TIME_SERIES_DAILY&symbol=%s&apikey=%s",
        symbol, key,
    )
    client := &http.Client{Timeout: 15 * time.Second}
    resp, err := client.Get(url)
    if err != nil {
        return avDaily{}, err
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    var out avDaily
    if err := json.Unmarshal(body, &out); err != nil {
        return avDaily{}, err
    }
    return out, nil
}

func main() {
    bars, err := dailyBars("IBM")
    if err != nil {
        panic(err)
    }
    // находим последнюю по дате свечу
    dates := make([]string, 0, len(bars.Series))
    for d := range bars.Series {
        dates = append(dates, d)
    }
    sort.Strings(dates)
    last := dates[len(dates)-1]
    b := bars.Series[last]
    // цены оставлены строками; для расчётов — shopspring/decimal
    fmt.Printf("IBM %s: O=%s H=%s L=%s C=%s V=%s\n",
        last, b.Open, b.High, b.Low, b.Close, b.Volume)
}

Удобно, что Alpha Vantage отдаёт цены строками — десятичная точность сохраняется «из коробки», достаточно не превращать их в float64.

5.5. C# / .NET — Yahoo Finance (неофициальный эндпоинт, decimal)

c#
using System.Text.Json;

// ⚠️ Неофициальный эндпоинт Yahoo Finance — его же использует библиотека yfinance.
// Хорош для прототипов и обучения, но без гарантий и НЕ для продакшена.
public static class YahooChart
{
    private static readonly HttpClient Http = new();

    public static async Task<(string Date, decimal Close)> LastDailyCloseAsync(string symbol)
    {
        var url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval=1d&range=5d";

        var req = new HttpRequestMessage(HttpMethod.Get, url);
        req.Headers.UserAgent.ParseAdd("Mozilla/5.0"); // Yahoo требует User-Agent

        var resp = await Http.SendAsync(req);
        resp.EnsureSuccessStatusCode();
        using var doc = JsonDocument.Parse(await resp.Content.ReadAsStreamAsync());

        var result = doc.RootElement.GetProperty("chart").GetProperty("result")[0];
        var timestamps = result.GetProperty("timestamp");
        var closes = result.GetProperty("indicators")
            .GetProperty("quote")[0].GetProperty("close");

        var i = timestamps.GetArrayLength() - 1;
        var unix = timestamps[i].GetInt64();
        var close = closes[i].GetDecimal();    // decimal — корректно для цены
        var date = DateTimeOffset.FromUnixTimeSeconds(unix).ToString("yyyy-MM-dd");

        return (date, close);
    }
}

class Program
{
    static async Task Main()
    {
        var (date, close) = await YahooChart.LastDailyCloseAsync("AAPL");
        Console.WriteLine($"AAPL close {date}: {close} USD");
    }
}

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


6. В каком типе данных хранить котировки

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

Поле Тип Почему
Цена (open/high/low/close, last) NUMERIC(18,6) / Decimal / decimal точность без потерь
Объём (volume) BIGINT / int64 миллиарды штук не влезают в обычный int
Adjusted close NUMERIC(18,6) хранить рядом с «сырой» ценой, а не вместо
Время бара TIMESTAMPTZ обязательно с часовым поясом биржи
Валюта инструмента CHAR(3) (ISO 4217) для пересчёта портфеля

Пример таблицы под свечи (OHLCV):

sql
CREATE TABLE quotes (
    id           BIGSERIAL PRIMARY KEY,
    source       VARCHAR(16)   NOT NULL,   -- 'MOEX', 'FINNHUB', 'AV', 'YAHOO'
    exchange     VARCHAR(16)   NOT NULL,   -- биржа: 'MOEX', 'NASDAQ'...
    ticker       VARCHAR(20)   NOT NULL,   -- SBER, AAPL...
    isin         CHAR(12),                 -- надёжный идентификатор инструмента
    ccy          CHAR(3)       NOT NULL,   -- валюта цены: RUB, USD...
    ts           TIMESTAMPTZ   NOT NULL,   -- время бара/котировки
    interval     VARCHAR(8)    NOT NULL DEFAULT '1d', -- 1m | 1h | 1d
    open         NUMERIC(18,6) NOT NULL,
    high         NUMERIC(18,6) NOT NULL,
    low          NUMERIC(18,6) NOT NULL,
    close        NUMERIC(18,6) NOT NULL,
    adj_close    NUMERIC(18,6),            -- скорректировано на сплиты/дивиденды
    volume       BIGINT        NOT NULL DEFAULT 0,
    fetched_at   TIMESTAMPTZ   NOT NULL DEFAULT now(),
    UNIQUE (source, exchange, ticker, interval, ts)
);

Отдельно стоит хранить корпоративные действия (сплиты с коэффициентом и дивиденды с датой), потому что при их появлении историю adj_close приходится пересчитывать задним числом.


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

  • Различайте реалтайм, задержку и EOD. Для большинства задач (портфель, аналитика, дашборд) хватает данных с задержкой или end-of-day — они дешевле и проще лицензируются.
  • Уважайте лицензии. Это главное отличие от курсов ЦБ. Биржевые данные в реальном времени регулируются (биржи, FINRA, SEC), и даже их перепоказ пользователям может требовать соглашения. Поэтому, например, реалтайм по США у Alpha Vantage — платный. Прежде чем публиковать данные, проверьте условия источника.
  • Стройте всё вокруг кэша. При лимите 25 запросов в сутки иначе никак: тянете историю один раз, дальше — только инкрементальные обновления, остальное отдаёте из своей базы.
  • Учитывайте сплиты как «номинал». Резкий скачок цены в N раз — почти всегда корпоративное действие, а не движение рынка. Простая проверка «изменение к предыдущему дню больше X%» ловит и сплиты, и ошибки парсинга.
  • Идентифицируйте инструмент по бирже + тикеру (а лучше по ISIN/FIGI). Один тикер живёт на разных площадках.
  • Помните про сессии и праздники. Пустой ответ в выходной — норма; берите последнюю доступную дату из ответа, а не запрошенную.
  • Не стройте продакшен на скрейпинге Yahoo. Для прототипа — отлично, для сервиса, который должен жить годами, — берите API с гарантиями.

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

  • Портфельные трекеры — текущая стоимость активов, P&L, пересчёт в базовую валюту.
  • Алготрейдинг и торговые боты — сигналы и исполнение (тут уже нужен реалтайм и стакан).
  • Дашборды и BI — визуализация рынка, отраслевые срезы.
  • Скринеры и бэктест — фильтрация бумаг и проверка стратегий на истории (OHLCV + adjusted).
  • Робо-эдвайзеры и финтех — рекомендации и автоматическое управление.
  • Учёт и оценка — переоценка вложений по рыночной цене на дату.
  • Алерты — уведомления о достижении ценой заданного уровня.

Итого

Парсинг котировок акций технически близок к парсингу курсов валют — те же десятичные типы, то же кэширование, та же осторожность с «техническими» скачками (только вместо номинала валюты здесь сплиты). Но добавляются три вещи: тикер привязан к бирже (и лучше к ISIN/FIGI), история требует корректировки на корпоративные действия, и — самое важное на практике — данные лицензируются: бесплатно почти везде доступны лишь задержка и end-of-day, а реальное время стоит денег и связано условиями. В регионе самый удобный публичный источник — MOEX ISS, который заодно отдаёт и биржевые валютные пары, смыкаясь с темой соседней статьи про курсы валют. Сложите дисциплину типов, кэш и уважение к лицензиям — и получите котировки, на которые можно опереться в расчётах.