[Из песочницы] Пишем учебное приложение на Go и Javascript для оценки реальной доходности акций. Часть 1 — backend

Давайте попробуем написать небольшую тренировочную, но вполне себе законченную информационную систему, состоящую из серверной части на Go и клиентского веб-приложения на Javascript + Vue JS.

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

  • Деньги съедает инфляция (инфляционный риск)
  • Рубль может обесцениться (курсовой риск)


Было принято решение изучить вопрос и выбрать подходящий инструмент для инвестирования. Основными критериями были надёжность и защита сбережений от указанных выше рисков.
Вопрос я изучил и в результате пришёл к выводу, что единственным адекватным инвестиционным инструментом для жителя России являются акции биржевых фондов (ETF), причём именно те, что торгуются на Московской Бирже.

Таким образом, предлагаю написать учебное приложение, которое бы показывало доходность всех ETF, которые представлены на Московской Бирже.
Вы можете сказать, что эту доходность можно посмотреть на самом сайте биржи, а приложение, хотя бы и учебное, должно быть сколько-нибудь полезным. Согласен, поэтому попытаемся отобразить некоторую условную реальную доходность акций. Под этой условной реальной доходностью, я буду понимать доходность, скорректированную на инфляцию в России.
В первой части статьи мы разберём серверную часть приложения. Наш бэкенд написан на Go и в ходе разработки мы попытаемся применить такие возможности языка, как параллельное исполнение кода, интерфейсы, тестирование и прочее.

Требования ТЗ:

  1. Серверная часть приложения должна предоставлять по запросу данные о котировках всех ETF Московской Биржи и данные об инфляции за все месяцы торгов по каждой бумаге
  2. Серверная часть приложения должна поддерживать несколько поставщиков хранилища данных, переключение между поставщиками не должно требовать изменения кода
  3. Серверная часть приложения должна предоставлять API по протоколу http для получения данных из хранилища


Итак, давайте спроектируем программную архитектуру серверной части нашей системы.

Во-первых, придумаем структуру пакетов приложения. Согласно ТЗ, приложение будет состоять из веб-сервера, который будет предоставлять REST API и отдавать фалы нашего веб-приложения (впоследствии мы напишем SPA на Vue). Кроме того, по ТЗ мы должны сделать несколько пакетов для поставщиков хранилища данных.

На этом моменте следует остановиться поподробнее. Каким образом можно предоставить возможность переключения между поставщиками некоторой функциональности в Go? Ответ: с помощью интерфейсов. Таким образом мы должны будем разработать интерфейс (контракт) для пакетов, каждый из которых будет выполнять контракт для своего типа хранилища. В статье рассмотрим хранение данных в оперативной памяти, но по аналогии можно легко добавить любую СУБД. Итоговая структура пакетов будет такая:

pp90aq3otzcejglz3gty9uolsgw.png

Во-вторых, давайте определимся с типами данных, в которых мы будем хранить полученную информацию, и контрактом для поставщиков хранилища.

Нам потребуются типы данных для котировок акций и инфляции. Котировки и инфляцию мы будем брать по месяцам, этот масштаб вполне подходит для такого неспекуляционного инструмента, как ETF.

Контракт будет требовать наличия методов заполнения хранилища данными с сервера Мосбиржи (инициализация) и предоставления данных котировок по запросу. Всё предельно просто.

В итоге в модуль storage мы помещаем типы для хранения котировок и интерфейс:

// Package storage описывает общие требования к поставщику хранилища и используемые типы данных
package storage

// Security - ценная бумага
type Security struct {
        ID        string  // ticker
        Name      string  // полное имя бумаги
        IssueDate int64   // дата выпуска в обращение
        Quotes    []Quote // котировки
}

// Quote - котировка ценной бумаги (цена 'close')
type Quote struct {
        SecurityID string  // ticker
        Num        int     // номер измерения (номер месяца)
        TimeStamp  int64   // отметка времени в формате Unix Time
        Price      float64 // цена закрытия
}

// Interface - контракт для драйвера хранилища котировок
type Interface interface {
        InitData() error                 // инициализирует хранилище данными с сервера Мосбиржи
        Securities() ([]Security, error) // получить список бумаг с котировками
}


Данные по инфляции для простоты закодируем в модуле сервера:

var inflation = []struct {
        Year   int
        Values [12]float64
}{
        {
                Year:   2013,
                Values: [12]float64{0.97, 0.56, 0.34, 0.51, 0.66, 0.42, 0.82, 0.14, 0.21, 0.57, 0.56, 0.51},
        },
        {
                Year:   2014,
                Values: [12]float64{0.59, 0.70, 1.02, 0.90, 0.90, 0.62, 0.49, 0.24, 0.65, 0.82, 1.28, 2.62},
        },
        {
                Year:   2015,
                Values: [12]float64{3.85, 2.22, 1.21, 0.46, 0.35, 0.19, 0.80, 0.35, 0.57, 0.74, 0.75, 0.77},
        },
        {
                Year:   2016,
                Values: [12]float64{0.96, 0.63, 0.46, 0.44, 0.41, 0.36, 0.54, 0.01, 0.17, 0.43, 0.44, 0.40},
        },
        {
                Year:   2017,
                Values: [12]float64{0.62, 0.22, 0.13, 0.33, 0.37, 0.61, 0.07, -0.54, -0.15, 0.20, 0.22, 0.42},
        },
        {
                Year:   2018,
                Values: [12]float64{0.31, 0.21, 0.29, 0.38, 0.38, 0.49, 0.27, 0.01, 0.16, 0.35, 0.50, 0.84},
        },
}


В-третьих, давайте опишем конечные точки нашего API. Их будет всего две: для котировок и инфляции. Только метод HTTP GET.

  // API нашего сервера
        http.HandleFunc("/api/v1/securities", securitiesHandler) // список бумаг с котировками
        http.HandleFunc("/api/v1/inflation", inflationHandler)   // инфляция по месяцам


Собственно получение и обработка данных с сайта Мосбиржи осуществляется в методе инициализации. Данные берём согласно справке по API биржи.
На что стоит обратить внимание: мы вынуждены использовать отдельный запрос по каждой ценной бумаге (а их уже пара десятков). Исполнение инициализации данных последовательно, в один поток, заняло бы много времени. Поэтому мы будем использовать гордость Go — горутины. Обратите внимание на следующий кусок кода:

// InitData инициализирует хранилище данными с сервера Мосбиржи
func (s *Storage) InitData() (err error) {

        securities, err := getSecurities()
        if err != nil {
                return err
        }

        // объект синхронизации горутин
        var wg sync.WaitGroup

        // увеличиваем счётчик горутин по количеству ценных бумаг
        wg.Add(len(securities))

        for _, security := range securities {

                go func(item storage.Security) {
                        // уменьшаем счётчик перед завершением функции
                        defer wg.Done()

                        var quotes []storage.Quote
                        quotes, err = getSecurityQuotes(item)
                        if err != nil {
                                fmt.Println(item, err)
                                return
                        }

                        item.Quotes = quotes

                        err = s.Add(item)
                        if err != nil {
                                return
                        }

                }(security)

        }
        // ожидаем выполнения всех горутин
        wg.Wait()

        return err

}


В функции инициализации данных мы распараллелили запросы к серверу. На практике такой парсинг сайтов имеет ряд проблем:

  • Может привести к автоматической блокировке запросов из-за подозрения на DoS
  • Нужно использовать модуль context или управляющий канал для принудительного завершения горутин
  • Нужно использовать канал для возврата ошибки из горутины


Для простоты все эти моменты опускаются.

Для целей учебной программы нам хватит встроенного маршрутизатора HTTP-запросов. В более сложных системах, вы, вероятно, захотите использовать какой-нибудь другой. Лично я пользуюсь маршрутизатором из проекта Gorilla, но вообще их полно.

Впоследствии мы ещё добавим точку для отдачи файлов нашего веб-приложения. Забегая вперёд скажу, что для этого следует просто использовать отдачу файлового содержимого.

Итак давайте напишем наш сервер:

// Package main реализует веб-сервер проетка moex-etf
package main

import (
        "encoding/json"
        "fmt"
        "log"
        "moex_etf/server/storage"
        "moex_etf/server/storage/inmemory"
        "net/http"
)

var db storage.Interface

func main() {

        // здесь мы можем, например, добавить проверку флагов запуска или переменной окружения
        // для выбора поставщика хранилища. выбрали память
        db = inmemory.New()

        fmt.Println("Inititalizing data")
        // инициализация данных хранилища
        err := db.InitData()
        if err != nil {
                log.Fatal(err)
        }

        // API нашего сервера
        http.HandleFunc("/api/v1/securities", securitiesHandler) // список бумаг с котировками
        http.HandleFunc("/api/v1/inflation", inflationHandler)   // инфляция по месяцам

        // запускаем веб сервер на порту 8080
        const addr = ":8080"
        fmt.Println("Starting web server at", addr)
        log.Fatal(http.ListenAndServe(addr, nil))

}

// обработчик запроса котировок
func securitiesHandler(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET")
        w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")

        if r.Method != http.MethodGet {
                return
        }

        securities, err := db.Securities()
        if err != nil {
                w.WriteHeader(http.StatusInternalServerError)
                w.Write([]byte(err.Error()))
        }

        err = json.NewEncoder(w).Encode(securities)
        if err != nil {
                w.WriteHeader(http.StatusInternalServerError)
                w.Write([]byte(err.Error()))
        }

}

// обработчик запроса инфляции
func inflationHandler(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET")
        w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")

        if r.Method != http.MethodGet {
                return
        }

        err := json.NewEncoder(w).Encode(inflation)
        if err != nil {
                w.WriteHeader(http.StatusInternalServerError)
                w.Write([]byte(err.Error()))
        }

}

// инфляция в России по месяцам
var inflation = []struct {
        Year   int
        Values [12]float64
}{
        {
                Year:   2013,
                Values: [12]float64{0.97, 0.56, 0.34, 0.51, 0.66, 0.42, 0.82, 0.14, 0.21, 0.57, 0.56, 0.51},
        },
        {
                Year:   2014,
                Values: [12]float64{0.59, 0.70, 1.02, 0.90, 0.90, 0.62, 0.49, 0.24, 0.65, 0.82, 1.28, 2.62},
        },
        {
                Year:   2015,
                Values: [12]float64{3.85, 2.22, 1.21, 0.46, 0.35, 0.19, 0.80, 0.35, 0.57, 0.74, 0.75, 0.77},
        },
        {
                Year:   2016,
                Values: [12]float64{0.96, 0.63, 0.46, 0.44, 0.41, 0.36, 0.54, 0.01, 0.17, 0.43, 0.44, 0.40},
        },
        {
                Year:   2017,
                Values: [12]float64{0.62, 0.22, 0.13, 0.33, 0.37, 0.61, 0.07, -0.54, -0.15, 0.20, 0.22, 0.42},
        },
        {
                Year:   2018,
                Values: [12]float64{0.31, 0.21, 0.29, 0.38, 0.38, 0.49, 0.27, 0.01, 0.16, 0.35, 0.50, 0.84},
        },
}


Приводить здесь код реализации хранилища в памяти не буду, всё доступно на Гитхабе.

Для проверки наш API:

инфляция
котировки

На этом первая часть статьи завершена. Во второй части напишем тесты и замеры производительности для наших пакетов. В третьей части разработаем веб-приложение.

© Habrahabr.ru