Как с помощью js и google sheets стать соседом Билла Гейтса по гольф клубу

  • Tutorial


В последнее время на хабре стали появляться статьи, начинающиеся с рассказов про свободное время на самоизоляции и, как итог, появившихся троллейбусов из буханки. Возможно, администрации стоит задуматься о добавлении нового хаба — Самоизоляция..


Вот и у меня появилось свободное время, которое я посвятил анализу своих сделок в Тинькофф Инвестициях. Есть 2 типа людей: одни прекрасно строят многомерные массивы у себя в голове, пробегаясь по ним for-циклом в IPython Notebook, другим же нравится "щупать" цифры, раскладывая их по полочкам в Excel. Себя я отношу ко второй категории, поэтому все свои сделки аккуратно заносил в Google Sheets.


Под катом я расскажу, как автоматизировал свою рутину при помощи Google Apps Script и API от Тинькофф Инвестиций.


Перед тем как мы перейдём к сути, маленький словарик терминов, которыми я пользуюсь в статье:


  • ТИ — Тинькофф Инвестиции
  • Инструмент — любая ценная бумага, такая как акция, облигация или ETF.
  • Ticker — короткий ID инструмента на бирже. Как правило, участники биржи знают тикеры тех инструментов, которые покупают или продают. По крайней мере я исхожу из этого в своём коде
  • Figi — Financial Instrument Global Identifier (Финансовый Глобальный Идентификатор инструмента). Большая часть API-запросов ТИ принимает на вход именно figi.
  • Стакан — таблица заявок на покупку и продажу конкретного инструмента.

Задача


У каждого инвестора или трейдера есть свой особый способ вести аналитику сделок. Кому-то достаточно тех инструментов, которые предоставляет брокер — дэшборд в личном кабинете или еженедельные отчёты. Мой способ такой: я веду отдельную таблицу по каждому инструменту, которым торгую. В этих таблицах я рассчитываю прибыль/убыток, определяю стратегию будущих сделок и всячески учусь на своих ошибках.


Какое-то время я заносил сделки вручную, перепечатывая их из мобильного приложения ТИ. Мне захотелось оптимизировать этот процесс. В поисках решения я наткнулся на статью хабраюзера OvkHabr. Из неё я узнал, что брокер предоставляет API, который полностью покрывает мои нужды и принялся за разработку.


Google Apps Script


Всё, что нужно, чтобы расширить возможности документа Google Sheets, это перейти в Tools -> Script editor, задать название проекта и начать писать код на JavaScript.



OpenAPI


Методы взаимодействия с ТИ реализованы с помощью OpenAPI, а сама документация представлена через swagger-ui


Авторизация представляет собой http-заголовок с токеном, который нужно посылать при каждом запросе. Процедура получение токена описана в ещё одной документации.


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


  1. Получать описание инструмента, из которого будем брать figi
  2. Получать стакан инструмента, из которого будем брать актуальную цену
  3. Получать список сделок, отфильтровывая его по времени

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


class TinkoffClient
class TinkoffClient {
  constructor(token) {
    this.token = token
    this.baseUrl = 'https://api-invest.tinkoff.ru/openapi/'
  }

  _makeApiCall(methodUrl) {
    const url = this.baseUrl + methodUrl
    Logger.log(`[API Call] ${url}`)
    const params = {'escaping': false, 'headers': {'accept': 'application/json', "Authorization": `Bearer ${this.token}`}}
    const response = UrlFetchApp.fetch(url, params)
    if (response.getResponseCode() == 200)
      return JSON.parse(response.getContentText())
  }

  getInstrumentByTicker(ticker) {
    const url = `market/search/by-ticker?ticker=${ticker}`
    const data = this._makeApiCall(url)
    return data.payload.instruments[0]
  }

  getOrderbookByFigi(figi) {
    const url = `market/orderbook?depth=1&figi=${figi}`
    const data = this._makeApiCall(url)
    return data.payload
  }

  getOperations(from, to, figi) {
    // Arguments `from` && `to` should be in ISO 8601 format
    const url = `operations?from=${from}&to=${to}&figi=${figi}`
    const data = this._makeApiCall(url)
    return data.payload.operations
  }
}

const tinkoffClient = new TinkoffClient(OPENAPI_TOKEN)

Получение цены инструмента


Протестируем получившийся клиент на чём-нибудь простом, чтобы узнать что у нас всё работает. Например, получим цену акции yandex с тикером YNDX.


Custom Functions


Google Sheets предлагает большой выбор встроенных формул, таких как AVERAGE, SUM, или VLOOKUP. Но, когда этого недостаточно, мы всегда можем сделать свою. Всё, что для этого нужно, — обозначить функцию в .gs файле. То, что будет возвращать такая функция, будет вставляться в ячейку, которая вызвала функцию. Причём, если функция возвращает двумерный массив, то данные заполнят область справа и снизу, при условии, что там не будет занятых клеток.


Давайте сделаем функцию getPriceByTicker, которая будет возвращать текущую цену инструмента. Её мы будем использовать в качестве формулы в любой ячейке (=getPriceByTicker("YNDX")).


Для этого нам сначала нужно получить figi инструмента, а потом получить его стакан, из которого мы и вытащим цену:


function _getFigiByTicker(ticker) {
  const {figi} = tinkoffClient.getInstrumentByTicker(ticker)
  return figi
}

function getPriceByTicker(ticker) {
  const figi = _getFigiByTicker(ticker)
  const {lastPrice} = tinkoffClient.getOrderbookByFigi(figi)
  return lastPrice
}



Здорово! Теперь в нашем распоряжении есть отличный тул, при помощи которого мы можем получать текущую цену акции, и использовать её в расчётах.


Автообновление формулы


Для того, чтобы данные в таблице всегда были актуальными, хочется сделать эту формулу автообновляемой. Прямого способа сделать это нет, но GAS комьюнити придумало вот такой хак:


  • Мы резервируем ячейку, в которую при каждом обновлении листа будет складываться случайное число
    function onEdit(e) {
      const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet()
      sheet.getRange('Z1').setValue(Math.random())
    }
  • Эту ячейку мы будем указывать в качестве аргумента у тех функций, которым необходим периодический пересчёт, например getPriceByTicker
    =getPriceByTicker("YNDX", Z1)

Cache Service


Как мы видим, получая цену мы делаем аж 2 API-вызова. И если в случае похода за стаканом это оправдано, так как цена постоянно меняется, то figi у инструмента является константой. Чтобы сделать нашу формулу чуть быстрее и надёжней, воспользуемся Apps Script Cache Service. Это простое key-value хранилище, которое отлично справится с нашей задачей:


const CACHE = CacheService.getScriptCache()

function _getFigiByTicker(ticker) {
  const cached = CACHE.get(ticker)
  if (cached != null) 
    return cached
  const {figi} = tinkoffClient.getInstrumentByTicker(ticker)
  CACHE.put(ticker, figi)
  return figi
}

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


Теперь получим список проведённых сделок по инструменту. Сделаем функцию getTrades, которая будет ходить за операциями по API, формировать двумерный массив с данными и возвращать его.


На вход будем получать тикер, а также временной интервал, по которому нас интересуют сделки. Для интервала сделаем дефолтные значения, чтобы каждый раз не бодаться с форматом ISO 8601, который требует на вход API.
Figi будем получать при помощи _getFigiByTicker, который мы уже реализовали выше.


function getTrades(ticker, from, to) {
  const figi = _getFigiByTicker(ticker)
  if (!from) {
    from = TRADING_START_AT.toISOString()
  }
  if (!to) {
    const now = new Date()
    to = new Date(now + MILLIS_PER_DAY)
    to = to.toISOString()
  }
  const operations = tinkoffClient.getOperations(from, to, figi)
  ...
}

Взвешенное среднее


Исходя из документации, объект Operation возвращается нам в виде:


Объект Operation
"operation": {
    "id": "string",
    "status": "Done",
    "trades": [{
        "tradeId": "string",
        "date": "2019-08-19T18:38:33.131642+03:00",
        "price": 0,
        "quantity": 0
     }],
    "commission": {
      "currency": "RUB",
      "value": 0
    },
    "currency": "RUB",
    "payment": 0,
    "price": 0,
    "quantity": 0,
    "figi": "string",
    "instrumentType": "Stock",
    "isMarginCall": true,
    "date": "2019-08-19T18:38:33.131642+03:00",
    "operationType": "Buy"
  }

Некоторые операции купли-продажи являются составными. Это обусловлено законами, по которым работает биржа: продавая 100 акций YNDX по 2500₽, в моменте может быть всего 40 предложений по цене 2500₽, и 60 предложений по цене 2499₽. Поэтому, как и было описано в статье у OvkHabr, часть данных о сделках лежит в биржевых операциях — подмассиве trades.


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


Для нашего примера с яндексом, взвешенное среднее считается так


$Price_w = \frac{2500 * 40 + 2499 * 60}{100} = 2 499,4$


А в коде это будет выглядеть так


function _calculateTrades(trades) {
  let totalSum = 0
  let totalQuantity = 0
  for (let j in trades) {
    const {quantity, price} = trades[j]
    totalQuantity += quantity
    totalSum += quantity * price
  }
  const weigthedPrice = totalSum / totalQuantity
  return [totalQuantity, totalSum, weigthedPrice]
}

Работа с таблицей


Как было описано выше, если custom функция возвращает двумерный массив, данные займут всё необходимое свободное пространство под ячейкой с формулой. Соответственно, нам необходимо сформировать такой массив.


Мы будем итерироваться по операциям и биржевым сделкам, чтобы этот массив заполнить. Нас не интересуют отменённые операции, а также операции списания комиссии, но интересует само значение комиссии. Помимо этого, мы "на лету" будем присваивать минус операциям покупки (символизируя списание с нашего брокерского счёта) и плюс продажам. Таким образом, нам будет проще понимать текущую стоимость позиции (просто просуммировав столбец)


const values = [
  ["ID", "Date", "Operation", "Ticker", "Quantity", "Price", "Currency", "SUM", "Commission"], 
]
for (let i=operations.length-1; i>=0; i--) {
  const {operationType, status, trades, id, date, currency, commission} = operations[i]
  if (operationType == "BrokerCommission" || status == "Decline") 
    continue
  let [totalQuantity, totalSum, weigthedPrice] = _calculateTrades(trades) // calculate weighted values
  if (operationType == "Buy") {  // inverse values in a way, that it will be easier to work with
    totalQuantity = -totalQuantity
    totalSum = -totalSum
  }
  values.push([
    id, isoToDate(date), operationType, ticker, totalQuantity, weigthedPrice, currency, totalSum, commission.value
  ])
}

Остаётся только вернуть массив values. Итоговый код функции выглядит так:


getTrades
function isoToDate(dateStr){
  // How to format date string so that google scripts recognizes it?
  // https://stackoverflow.com/a/17253060
  const str = dateStr.replace(/-/,'/').replace(/-/,'/').replace(/T/,' ').replace(/\+/,' \+').replace(/Z/,' +00')
  return new Date(str)
}

function _calculateTrades(trades) {
  let totalSum = 0
  let totalQuantity = 0
  for (let j in trades) {
    const {quantity, price} = trades[j]
    totalQuantity += quantity
    totalSum += quantity * price
  }
  const weigthedPrice = totalSum / totalQuantity
  return [totalQuantity, totalSum, weigthedPrice]
}

function getTrades(ticker, from, to) {
  const figi = _getFigiByTicker(ticker)
  if (!from) {
    from = TRADING_START_AT.toISOString()
  }
  if (!to) {
    const now = new Date()
    to = new Date(now + MILLIS_PER_DAY)
    to = to.toISOString()
  }
  const operations = tinkoffClient.getOperations(from, to, figi)

  const values = [
    ["ID", "Date", "Operation", "Ticker", "Quantity", "Price", "Currency", "SUM", "Commission"], 
  ]
  for (let i=operations.length-1; i>=0; i--) {
    const {operationType, status, trades, id, date, currency, commission} = operations[i]
    if (operationType == "BrokerCommission" || status == "Decline") 
      continue
    let [totalQuantity, totalSum, weigthedPrice] = _calculateTrades(trades) // calculate weighted values
    if (operationType == "Buy") {  // inverse values in a way, that it will be easier to work with
      totalQuantity = -totalQuantity
      totalSum = -totalSum
    }
    values.push([
      id, isoToDate(date), operationType, ticker, totalQuantity, weigthedPrice, currency, totalSum, commission.value
    ])
  }
  return values
}

Проверяем работу в бою:



Заключение


В рамках статьи мы познакомились с API Тинькофф Инвестиций, возможностями, которые предлагает Google Apps Script, а также решили задачу автоматизации заполнения Google Sheets реальными сделками с брокерского счёта. Надеюсь, вам было интересно)


Весь код и короткий how-to выложен на github


Для тех читателей, кто хочет вступить на дорогу инвестирования, но не знает с чего начать — могу посоветовать бесплатный курс от Тинькофф Журнала https://journal.tinkoff.ru/pro/invest/ — он короткий, информативный и доходчивый.


А при открытии брокерского счёта в ТИ по моей ссылке вы получите акцию стоимостью до 20000 рублей в подарок.


Благодарю за внимание.

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 8

    0

    Так а как стать соседом Билла Гейтса по гольф клубу то?

      0
      Могу предложить такой вариант:

      1. Разработать план чипирования россиян, который не предполагает вакцинации (наш народ сразу раскусил подвох, нужно что-то хитрее)
      2. Устроиться младшим обслуживающим персоналом на конференцию вирусологов в Женеве.
      3. Во время афтерпати выследить Билла, и предложить бокал шампанского. После его вежливого отказа, поведать о своём плане, вскользь упомянув что у вас нулевой гандикап в гольфе (тем самым разжигая в нём дух соперничества).
      4. Ваше обаяние покоряет Билла, и он соглашается на дружеский матч
      5. ???????
      6. PROFIT
      0
      Очень полезно, спасибо!
      Тинькофф клиент неплохо бы сделать библиотекой в GAS для использования ее и для других таблиц/скриптов.
      Я лично сделал отдельную таблицу для вывода списка своих акций в портфеле.
      Зачем? Не знаю
        0
        Хорошая идея, спасибо.
        Попробую вынести клиент в библиотеку для GAS.
        0

        Хорошо написано. Довольно познавательно, несмотря на то, что реклама прослеживается.

          +1
          Спасибо

          несмотря на то, что реклама прослеживается

          Похоже из-за этого пост не слишком хорошо встречен сообществом ¯\_(ツ)_/¯

          0
          Было бы не плохо выложить пример с ваших gif, с примерами тикеров в том числе, чтобы внести свой OPENAPI_TOKEN и быстро использовать и считать профит.
            0
            Тот способ, которым я храню OPENAPI_TOKEN не является безопасным (но и одновременно является безальтернативным): если пошерить этот документ пошерятся и ScriptProperties, а значит утечёт токен.

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

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое