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



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

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

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

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

Таким образом, предлагаю написать учебное приложение, которое бы показывало доходность всех ETF, которые представлены на Московской Бирже.

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

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

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

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

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

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



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

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

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

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

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (4):

  1. pawlo16
    /#19764608 / +1

    Мне понравилось, что всё сделано на стандартном http без сторонних роутеров. Наглядно для демонстрации базовых принципов.

    Вызывает вопрос выбор sqlite. Без сервера — это хорошо. Но, так как модели сущностей простые, а использования cgo хотелось бы избежать дабы сохранить преимущества кроскомпиляции, я бы выбрал одну из pure Go встроенных nosql баз данных.

    Так же странным представляется выюор Vue.js — фреймворка со своими DSL шаблонов, в котором строгой типизации нет, и приделать её невозможно. Уж лучше реакт, в котором несмотря на все его недостатки не надо заучивать глупости в силе как мне сделать вокрфлоу в хтмл на v-if и v-for. Тем более что в данном случае стейт-менеджер не потребуется, можно весь стейт хранить в коревом компоненте страницы. А Vue.js с чистым сердцем выкинуть в помойку, без попыток в нём разобраться.

    • DmitriyTitov
      /#19764726

      1. По поводу SQLite — идея в том, чтобы сделать переключение хранилищ без изменения кода. Программа учебная, поэтому она должна запускаться сразу, без необходимости установить СУБД. Поэтому SQLite.
      Хотя про CGo резонно подмечено, я вообще про него забыл. Надо сделать на Mongo с бесплатной базой в облаке Atlas. Спасибо.
      2. По поводу Vue и выкинуть в помойку. Я бы вообще весь JS выкинул в помойку, но на нём работает веб. Без фреймворка в JS никуда — надо приучать себя даже маленькую программу разрабатывать с некоторой структурой (архитектурой, если хотите). Тем более, что она может и вырасти, кто знает. На чистом JS будет помойка. React я просто не знаю (да и структура кода во Vue поприятнее). Фронтенд вообще не особо моё.

  2. pawlo16
    /#19764754 / -2

    DmitriyTitov сорян не в ту ветку ответил

    может быть проще всего будет сделать на boltdb? Это хардкорно простое встраиваемое nosql хранилище на чистом Го.

    Ну тогда просто порекомендую — если уж обмазываться фронтендом с вебпаками и npm-ами, то с react.js жить будет значительно проще чем с vue.js. Но согласен — реакт та ещё дрянь. Очень тяжело не поддаться хайпу, который поднят около обеих технологий, но нужно быть стойким.

  3. esemi
    /#19768630

    Спасибо за статью, как учебный квикстарт для Go прям отлично!
    Немного офтопа на тему цели самого приложения: большинство инвестирующих так или иначе приходят к экселю для учёта результатов деятельности в целом и оценки доходности конкретного тикера в частности. В таком случае получение котировок проще сделать через тоже самое API, но средствами экселя/гуглотаблиц (там есть готовая функция для залива csv/xml). Если же стандартного функционала не хватает, то в гуглотаблицах есть вполне сносный gScript, на котором можно закодить что хочется.
    Что касается нужды в быстром скачивании котировок по всем бумагам, то во первых оно нужно на этапе ребаланса — а это как правило не чаще раза в квартал, а во вторых всех ETF на моэксе и 20 не будет.