При работе с финансовыми сервисами рано или поздно появится задача подключить профессиональные графики. Собрал книгу рецептов по charting_library — подключение данных, realtime, кастомизация, серверный адаптер. Сэкономит минимум сутки работы. Кто уже работает с библиотекой — часть кейсов будет новой.



Привет, Хабр!


У charting_library высокий порог входа, но менее популярной она от этого не стала — стоит на одноименном сервисе TradingView.com и на многих финансовых площадках. Собрал ответы на вопросы, которые всплывают при подключении. Контент дополняю по мере новых сложностей.


Дальше по тексту — ссылки на документацию. Если по ссылке открывается 404, значит нет доступа к закрытому репозиторию (об этом ниже).


Лицензия


Использовать можно бесплатно в коммерческих и некоммерческих целях. Главное условие — сохранить логотип компании на графиках.


При запросе доступа обязательно указать конечный домен, где графики будут работать. На одном проекте я подключил биржевые данные к графику, все настроил и запустил. На второй месяц заказчик не оплатил данные провайдера — графики перестали грузиться полностью, пропал логотип. Проверяющий со стороны TradingView написал заказчику на почту с вопросами по ситуации. Что держать под контролем на своей стороне — домен в заявке, оплату данных у провайдера, логотип на графике. Это отслеживают.


Получение доступа


У библиотеки закрытый репозиторий на GitHub. Чтобы получить доступ:


  1. Заполнить заявку на сайте
  2. Подписать договор
  3. Получить доступ к репозиторию на GitHub

Мой опыт получения доступа


Примерно через 4 недели после заявки прислали договор. Через 3 дня после подписания открыли доступ. По отзывам, срок плавает, точных гарантий нет.


Не отображается график, даже с тестовыми данными


Нужно подключить виджет и глобально указать путь к библиотеке.


Подключение виджета


import { widget } from '../public/charting_library/charting_library.min'
const chart = new widget({ <options> })

Доступ к библиотеке


Указать глобальный путь к папке charting_library в опции виджета library_path: '/charting_library/'.


Глобальный путь зависит от сборки. У меня Vue.js с publicPath: '/' в vue.config.js. Структура папок — /public/index.html, /public/charting_library/ и настройки виджета выше. Параметр разобран в документации по library_path.


Подключение данных


В базовом варианте идут тестовые данные. Дальше подключается свой провайдер — одно из двух решений, JS API или UDF. Напрямую скормить массив данных не выйдет.


Выбор между ними — где живет подключение к данным и нужен ли стрим. JS API работает на стороне клиента и умеет WebSocket. UDF работает на сервере, WebSocket у него нет. Интервал polling задаете в конструкторе — datafeed: new Datafeeds.UDFCompatibleDatafeed('http://localhost:3000/datafeed', 1000). Нужен realtime — берите JS API. Данные и так на своем сервере — UDF.


Дальше разберу JS API. UDF подключается так же, отличие — в указании конечной точки на сервере, откуда берутся данные. Оба способа описаны в документации по подключению данных.


TradingView JS API adapter


Каждый хук адаптера выполняется последовательно. Для отладки добавьте в каждый хук вывод в консоль — console.log('[<название хука>]: Method call').


Последовательность запуска — onReadyresolveSymbolgetBarssubscribeBarsunsubscribeBars.


При смене таймфрейма или символа вызывается unsubscribeBars. Он дергает вашу функцию, которая сбрасывает WebSocket-подключение с провайдером. Не используете subscribeBarsunsubscribeBars не нужен. Хук getServerTime необязателен, подключайте, если нужно время сервера.


Провайдер не отдает объемы — укажите в resolveSymbol параметр has_no_volume: true.


export default {
    // Инициализация настроек, должна отдаваться АСИНХРОННО
    onReady: (callback) => {
        console.log('[onReady]: Method call');
        // setTimeout(() => callback(<объект с настройками>))
    },
    /*
     // Не требуется, если не используете поиск
    searchSymbols: (userInput, exchange, symbolType, onResultReadyCallback) => {
        console.log('[searchSymbols]: Method call');
    },
     */
    // получение данных о конкретном символе
    resolveSymbol: (symbolName, onSymbolResolvedCallback, onResolveErrorCallback) => {
        console.log('[resolveSymbol]: Method call', symbolName);
        // onSymbolResolvedCallback({ ..., has_no_volume: true })
    },
    // получение исторических данных по символу
    getBars: (symbolInfo, interval, from, to, onHistoryCallback, onErrorCallback, firstDataRequest) => {
        console.log('[getBars] Method call', symbolInfo, interval)
        console.log('[getBars] First request', firstDataRequest)
    },
    // подписка на обновления по WebSocket
    subscribeBars: (symbolInfo, interval, onRealtimeCallback, subscribeUID, onResetCacheNeededCallback) => {
        console.log('[subscribeBars]: Method call with subscribeUID:', subscribeUID);
    },
    // вызывается для отписки от стрима
    unsubscribeBars: (subscriberUID) => {
        console.log('[unsubscribeBars]: Method call with subscriberUID:', subscriberUID);
    },
    getServerTime: (callback) => {}
};

Иногда провайдер не дает запрашивать данные напрямую с клиента — так делают некоторые биржи. Тогда запрос проксируется через свой бэкенд.


TradingView UDF adapter


UDF-адаптер актуален, когда данные запрашиваются со своего сервера. В конструкторе клиента указываете datafeed: new Datafeeds.UDFCompatibleDatafeed('http://localhost:3000/datafeed', 1000).


// пример оформления плагина для Fastify
// main.js
const app = Fastify()
app.register(import('./modules/tradingview'), {})

// tradingview.js
const plugin = async (app, options) => {

    // проверяем работу конечной точки
    app.get('/', (req, res) => {
        res.code(200).header('Content-Type', 'text/plain')
            .send('Welcome to UDF Adapter for TradingView. See ./config for more details.')
    })

    // время сервера
    app.get('/time', (req, res) => {
        console.log('[time]: Method call')
        const time = Math.floor(Date.now() / 1000)  // In seconds
        res.code(200).header('Content-Type', 'text/plain').send(time.toString())
    })

    // аналог onReady
    // https://github.com/tradingview/charting_library/wiki/UDF#data-feed-configuration-data
    app.get('/config', (req, res) => {
        console.log('[config]: Method call')
    })

    // вызывается если: supports_group_request: true & supports_search: false
    app.get('/symbol_info', async (req, res) => {
        console.log('[symbol_info]: Method call')
    })

    // вызывается если: supports_group_request: false & supports_search: true
    app.get('/symbols', async (req, res) => {
        console.log('[symbols]: Method call')
        const symbol = await getSymbols(req.query.symbol)
        return symbol
    })

    // аналог getBars, запрашивает исторические данные
    app.get('/history', async (req, res) => {
        console.log('[history]: Method call')
    })
}

Полный список конечных точек — в документации по UDF.


Хук getBars вызывается много раз


Так бывает, когда данных не хватает и библиотека сама пытается догрузить историю. В хуке getBars есть параметр firstDataRequest с булевым значением — true только при первой загрузке маркета. Используйте его.


getBars: (symbolInfo, interval, from, to, onHistoryCallback, onErrorCallback, firstDataRequest) => {
        console.log('[getBars] Method call', symbolInfo, interval)
        console.log('[getBars] First request', firstDataRequest)

        if (firstDataRequest) {
            console.log('do something')
        }
},

У провайдера нет WebSocket-подключения


UDF-провайдер необязателен, если стрима нет. Интервал запросов для JS API адаптера не задать, но никто не мешает добавить setInterval в subscribeBars и отдавать обновления оттуда.


subscribeBars: (symbolInfo, resolution, onRealtimeCallback, subscribeUID, onResetCacheNeededCallback) => {
        console.log('[subscribeBars]: Method call with subscribeUID:', subscribeUID)

        window.interval = setInterval(function () {
            getLastKline(symbolInfo.ticker, resolution).then(kline => onRealtimeCallback(kline))
        }, 1000 * 60) // 60s update interval
},
unsubscribeBars: (subscriberUID) => {
        console.log('[unsubscribeBars]: Method call with subscriberUID:', subscriberUID)

        clearInterval(window.interval)
        console.log('[unsubscribeBars]: cleared')
}

Кастомизация дизайна


По умолчанию доступны две темы — theme: "Light" || "Dark". Можно задать и свои цвета. Со временем всплывет проблема — цвета поменялись везде, кроме header_widget (верхний блок с поиском, сравнением и прочим). Его правят через .css.


В опциях виджета укажите custom_css_url: '/tradingview.css', где / — абсолютный путь от вашего index.html. Содержимое:


.chart-controls-bar {
    border-top: none !important;
}

.chart-page, .group-wWM3zP_M- {
    background: transparent !important;
}

.pane-separator {
    display: none !important;
}

Темы и переопределение цветов — в документации по theme.


Сохранение данных


Может понадобиться сохранять пользовательские рисунки на графике.


Save/Load методы


Простой вариант, если рисовать на графиках планируется немного. Вызываете объект со всеми данными графика — widget.save(cb => this.setOverlay(cb)) — и сохраняете, где удобно. Методы описаны в документации по Widget Methods.


Save/Load adapter


Похож на UDF-адаптер. На сервере поднимаете конечные точки для сохранения и загрузки данных. Подробности — в документации по Save/Load Adapter.


Как вывести ордера на график


После добавления ордера на график доступа к массиву ордеров нет — отслеживать их приходится самому. Поделюсь своим решением, оформленным как миксин для Vue.js, суть переносится и на другой стек.


import orders from '../../../multiblock/orders/mixin'

import createOrder from './createOrder'
import openOrders from './openOrders'
import trades from './trades'

export default {
    mixins: [orders, createOrder, openOrders, trades],
    data: () => ({
        lines: new Map()
    }),
    watch: {
        onChartReady(val) {
            if (val) {
                //* Uncomment: Testing price line
                // this.line({ id: 'test', price: 0.021, quantity: 100 })
            }
        },
    },
    methods: {
        // Line: open orders
        positionLine(data) {
            this.line(data)
                .onCancel(() => {
                    this.deleteLine(data.id)
                    this.$bus.$emit('market-orders-deleteOrder', data.id)
                })
                .onMove(() => this.$bus.$emit('market-orders-updateOrder', { id: data.id, price: this.lines.get(data.id).getPrice() }))

        },
        // Line: order module ('price', 'stopPrice')
        orderLine({ id = 'price', ...data }) {
            this.line({ id, ...data })
                .onMove(() => {
                    // Set new value on dragging
                    this.$store.commit('setMarketOrder', { [id]: this.lines.get(id).getPrice() })
                })
                .onCancel(() => {
                    // Delete price line & set price = 0
                    this.deleteLine(id)
                    this.$store.commit('setMarketOrder', { [id]: 0 }) // set 0 value in vuex storage
                })
        },
        line({ id = 'price', text = 'Price', color = '#ff9f0a', price, quantity, fontColor = '#fff', lineStyle = 2, lineLength = 25 }) {
            if (this.lines.has(id)) this.deleteLine(id)

            // Creating line from scratch
            const widget = this.widget.chart().createOrderLine()
                .setText(text)
                .setPrice(price)
                .setQuantity(quantity)
                .onModify(res => res) // Need for dragging

                // Customize color
                .setLineColor(color)
                .setBodyTextColor(fontColor)
                .setBodyBorderColor(color)
                .setBodyBackgroundColor(color)

                .setQuantityBorderColor(color)
                .setQuantityTextColor(fontColor)
                .setQuantityBackgroundColor(color)

                .setCancelButtonBorderColor(color)
                .setCancelButtonBackgroundColor(color)
                .setCancelButtonIconColor(fontColor)

                .setLineLength(lineLength) // Margin right 25%
                .setLineStyle(lineStyle)

            this.lines.set(id, widget)

            return widget // return for orderLine func()

        },
        deleteLine(id) {
            this.lines.get(id).remove()
            this.lines.delete(id)
        },
        deleteLines() {
            this.lines.forEach((value, key) => this.deleteLine(key))
        }
    }
}

Полный набор методов линий — в документации по Chart Methods.


Как добавить фигуры на график (линии, формы)


Добавлять можно только формы, предложенные библиотекой, — те, что в тулбаре. Нужно, когда надо вывести информацию поверх графика. Метод — createShape в Chart Methods, полный список доступных форм.


Хочу добавить свой индикатор


Смотрите в сторону Custom Studies.


Хочу использовать PineScript


charting_library такое не поддерживает. PineScript можно переписать на JavaScript и гонять алгоритм на клиенте или на сервере.


Хочу несколько графиков в одном окне


В бесплатной версии charting_library такого нет. При необходимости — верстка своими силами, HTML+CSS.


Делаю все по документации, но не работает


Реальный кейс. Обратился фрилансер со старым проектом, который переписывал. Оказалась просто старая версия библиотеки — проверяйте версию.


Второй случай — вызов методов у еще не загруженного графика. Отслеживайте состояние через onChartReady. Нет реактивности под капотом — используйте паттерн Observer.


widget.onChartReady(function () {
    // It's now safe to call any other methods of the widget
});

Графики библиотеки отличаются от версий на TradingView.com


Да, это нормально.


Заключение


charting_library закрывает почти любую задачу с финансовыми графиками, но часть доки за 404, и половину ответов приходится добывать перебором. Собрал сюда те, что стоили мне времени. Есть свой кейс проблема-решение — пишите в комментарии, добавлю в статью с указанием авторства.