Целевым REST API будет jquants-api, описанный в предыдущей статье.

Я решил реализовать обёртку на Golang, что оказалось чрезвычайно быстро и удобно. В итоге я выполнил эту задачу за один вечер, а получившуюся Golang-обёртку с базовыми функциями загрузил на GitHub.

В этой статье я вкратце расскажу о процессе написания API и моих шагах по реализации проекта.

▍ Цели


Для начала перечислим задачи, которые нам предстоит выполнить:
  • Создать тест и поддерживающий код, проверяющий, что мы можем сохранять имя пользователя и пароль в файл edn, совместимый с форматом jquants-api-jvm.
  • Написать ещё один тест и поддерживающий код для получения токена обновления.
  • Написать ещё один тест и поддерживающий код для получения токена ID.
  • Написать ещё один тест и поддерживающий код с использованием токена ID для получения суточных значений.
  • Опубликовать нашу обёртку на GitHub.
  • Использовать нашу библиотеку Go в другой программе.

▍ Начнём с написания тестового случая, подготовки и сохранения структуры логина для доступа к API


Мы постоянно говорим о написании кода при помощи TDD, и теперь настало время его применить. Проверим, что у нас есть код для ввода и сохранения имени пользователя и пароля в файл edn, совместимый с форматом jquants-api-jvm.

В файле helper_test.go напишем скелет теста для функции PrepareLogin.

package jquants_api_go

import (
	"fmt"
	"os"
	"testing"
)

func TestPrepareLogin(t *testing.T) {
	PrepareLogin(os.Getenv("USERNAME"), os.Getenv("PASSWORD"))
}

Здесь мы берём USERNAME и PASSWORD из окружения при помощи os.GetEnv.

Запишем функцию подготовки в файл helper.go. Она будет делать следующее:

  • Получать в качестве параметров имя пользователя и пароль.
  • Создавать экземпляр структуры Login.
  • Структурировать его как содержимое файла EDN.

Структура Login будет выглядеть просто:

type Login struct {
    UserName string `edn:"mailaddress"`
    Password string `edn:"password"`
}

А вызов edn.Marshal будет создавать содержимое массива byte[], которое мы сможем записывать в файл, поэтому writeConfigFile просто будет вызывать os.WriteFile с массивом, возвращённым после упорядочивания в формат EDN.

func writeConfigFile(file string, content []byte) {
    os.WriteFile(getConfigFile(file), content, 0664)
}

Чтобы использовать библиотеку EDN, нам понадобится добавить её в файл go.mod:

require olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3

Перед запуском теста введите свои учётные данные jquants API:

export USERNAME="youremail@you.com"
export PASSWORD="yourpassword"

На этом этапе у вас уже должна быть возможность выполнять go test в папке проекта, а затем получать следующий результат:

PASS
ok      github.com/hellonico/jquants-api-go    1.012s

Также вы должны увидеть, что содержимое файла login.edn заполнено правильно:

cat ~/.config/jquants/login.edn

{:mailaddress "youremail@you.com" :password "yourpassword"}

▍ Использование Login для отправки HTTP-запроса jQuants API и получения RefreshToken


Второй функцией, которую надо протестировать, будет TestRefreshToken. Она отправляет HTTP-запрос POST с именем пользователя и паролем для получения в качестве ответа на вызов API токена обновления. Мы дополним файл helper_test.go новым тестовым случаем:

func TestRefreshToken(t *testing.T) {
    token, _ := GetRefreshToken()
    fmt.Printf("%s\n", token)
}

Функция GetRefreshToken будет выполнять следующее:

  • Загружать пользователя, сохранённого в файл ранее, и подготавливать его в виде данных JSON.
  • Подготавливать HTTP-запрос с URL и отформатированным в JSON пользователем в качестве содержимого body.
  • Отправлять HTTP-запрос.
  • API вернёт данные, которые будут храниться как структура RefreshToken.
  • После этого мы будем сохранять токен обновления как файл EDN.

Поддерживающая функция GetUser загружает содержимое файла, которое было записано на предыдущем этапе. У нас уже есть структура Login, и теперь мы просто используем edn.Unmarshall() с содержимым файла.

func GetUser() Login {
    s, _ := os.ReadFile(getConfigFile("login.edn"))
    var user Login
    edn.Unmarshal(s, &user)
    return user
}

Стоит заметить, что нам нужно считывать/записывать структуру Login в файл в формате EDN, а также при отправке HTTP-запроса нам требуется преобразовывать структуру в JSON.

То есть метаданные структуры Login нужно немного изменить:

type Login struct {
    UserName string `edn:"mailaddress" json:"mailaddress"`
    Password string `edn:"password" json:"password"`
}

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

type RefreshToken struct {
    RefreshToken string `edn:"refreshToken" json:"refreshToken"`
}

И теперь у нас есть все компоненты, чтобы написать функцию GetRefreshToken:

func GetRefreshToken() (RefreshToken, error) {
    // загрузка пользователя, ранее сохранённого в файл, и подготовка его в виде данных json
    var user = GetUser()
    data, err := json.Marshal(user)

    // подготовка http-запроса с url и пользователем, отформатированным в json, в качестве контента body
    url := fmt.Sprintf("%s/token/auth_user", BASE_URL)
    req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
    
    // отправка запроса
    client := http.Client{}
    res, err := client.Do(req)

    // API вернёт данные, которые будут храниться в структуре RefreshToken
    var rt RefreshToken
    json.NewDecoder(res.Body).Decode(&rt)

    // также сохраним этот токен обновления в виде файла EDN
    encoded, err := edn.Marshal(&rt)
    writeConfigFile(REFRESH_TOKEN_FILE, encoded)

    return rt, err
}

Результат выполнения go test будет чуть более многословным, поскольку мы печатаем в стандартный вывод refreshToken, но тесты должны завершаться успешно!

{eyJjdHkiOiJKV1QiLC...}

PASS
ok      github.com/hellonico/jquants-api-go    3.231s

▍ Получение токена ID


Из Refresh Token можно получить IdToken, то есть токен, используемый для отправки запросов к jquants API. Процесс выполнения будет почти таким же, как у GetRefreshToken, и для его поддержки нам достаточно добавить новую структуру IdToken с необходимыми метаданными для преобразования в/из edn/json.

type IdToken struct {
    IdToken string `edn:"idToken" json:"idToken"`
}

Остальная часть кода на этот раз будет выглядеть так:

func GetIdToken() (IdToken, error) {
    var token = ReadRefreshToken()

    url := fmt.Sprintf("%s/token/auth_refresh?refreshtoken=%s", BASE_URL, token.RefreshToken)

    req, err := http.NewRequest(http.MethodPost, url, nil)
    client := http.Client{}
    res, err := client.Do(req)

    var rt IdToken
    json.NewDecoder(res.Body).Decode(&rt)

    encoded, err := edn.Marshal(&rt)
    writeConfigFile(ID_TOKEN_FILE, encoded)

    return rt, err
}

▍ Получение суточных котировок


Мы добрались до ядра кода обёртки, где будем использовать IdToken и запрашивать суточные котировки из HTTP API при помощи GET-запроса HTTP.

Поток выполнения кода для получения суточных котировок будет выглядеть так:

  • Как и ранее, считываем токен ID из файла EDN.
  • Подготавливаем целевой URL с кодом параметров и параметрами дат.
  • Отправляем HTTP-запрос, используя в качестве HTTP-заголовка idToken.
  • Парсим результат как структуру суточных котировок, которая будет являться срезом структур Quote.

Тестовый случай просто проверяет возврат ненулевого (nul) значения и печатает текущие котировки.

func TestDaily(t *testing.T) {
    var quotes = Daily("86970", "", "20220929", "20221003")
    
    if quotes.DailyQuotes == nil {
        t.Failed()
    }

    for _, quote := range quotes.DailyQuotes {
        fmt.Printf("%s,%f\n", quote.Date, quote.Close)
    }
}

Поддерживающий код для func Daily показан ниже:

func Daily(code string, date string, from string, to string) DailyQuotes {
    // чтение токена id
    idtoken := ReadIdToken()

    // подготовка url с параметрами
    baseUrl := fmt.Sprintf("%s/prices/daily_quotes?code=%s", BASE_URL, code)
    var url string
    if from != "" && to != "" {
        url = fmt.Sprintf("%s&from=%s&to=%s", baseUrl, from, to)
    } else {
        url = fmt.Sprintf("%s&date=%s", baseUrl, date)
    }
    // отправка HTTP-запроса с использованием idToken
    res := sendRequest(url, idtoken.IdToken)

    // парсинг результатов в виде суточных котировок
    var quotes DailyQuotes
    err_ := json.NewDecoder(res.Body).Decode(&quotes)
    Check(err_)
    return quotes
}

Теперь нам нужно заполнить пробелы:

  • Функции sendRequest требуется чуть больше подробностей.
  • Парсинг DailyQuotes на самом деле не так прост.

Давайте сначала разберёмся с sendRequest. Она задаёт заголовок при помощи http.Header. Обратите внимание, что здесь можно добавить любое количество заголовков. Затем она отправляет HTTP-запрос GET и возвращает ответ без изменений.

func sendRequest(url string, idToken string) *http.Response {

    req, _ := http.NewRequest(http.MethodGet, url, nil)
    req.Header = http.Header{
        "Authorization": {"Bearer " + idToken},
    }
    client := http.Client{}

    res, _ := client.Do(req)
    return res
}

Теперь перейдём к парсингу суточных котировок. Если вы пользуетесь редактором GoLand, то заметите, что при копировании и вставке содержимого JSON в файл на Go редактор спросит, нужно ли напрямую преобразовать JSON в код на Go!


Довольно неплохо.

type Quote struct {
    Code             string   `json:"Code"`
    Close            float64  `json:"Close"`
    Date             JSONTime `json:"Date"`
    AdjustmentHigh   float64  `json:"AdjustmentHigh"`
    Volume           float64  `json:"Volume"`
    TurnoverValue    float64  `json:"TurnoverValue"`
    AdjustmentClose  float64  `json:"AdjustmentClose"`
    AdjustmentLow    float64  `json:"AdjustmentLow"`
    Low              float64  `json:"Low"`
    High             float64  `json:"High"`
    Open             float64  `json:"Open"`
    AdjustmentOpen   float64  `json:"AdjustmentOpen"`
    AdjustmentFactor float64  `json:"AdjustmentFactor"`
    AdjustmentVolume float64  `json:"AdjustmentVolume"`
}

type DailyQuotes struct {
    DailyQuotes []Quote `json:"daily_quotes"`
}

Хотя стандартные параметры очень хороши, нам нужно настроить их, чтобы правильно преобразовать Dates обратно. Всё, что идёт далее, взято из поста о том, как преобразовывать даты JSON.

Тип JSONTime хранит свою внутреннюю дату как 64-битное integer, и мы добавляем JSONTime функции для преобразования/обратного преобразования JSONTime. Как видно, значение времени, получаемое из содержимого JSON, может быть или строкой, или integer.

type JSONTime int64

// String преобразует метку времени unix в string
func (t JSONTime) String() string {
    tm := t.Time()
    return fmt.Sprintf("\"%s\"", tm.Format("2006-01-02"))
}

// Time возвращает это значение в виде time.Time.
func (t JSONTime) Time() time.Time {
    return time.Unix(int64(t), 0)
}

// UnmarshalJSON производит обратное преобразование значений string и int JSON
func (t *JSONTime) UnmarshalJSON(buf []byte) error {
    s := bytes.Trim(buf, `"`)
    aa, _ := time.Parse("20060102", string(s))
    *t = JSONTime(aa.Unix())
    return nil
}

Изначально написанный тестовый случай теперь должен успешно завершаться с go test.

"2022-09-29",1952.000000
"2022-09-30",1952.500000
"2022-10-03",1946.000000
PASS
ok      github.com/hellonico/jquants-api-go    1.883s

Наш вспомогательный файл готов, теперь можно добавлять к нему CI.

▍ Конфигурация CircleCI


Конфигурация посимвольно схожа с официальной документацией CircleCI по тестированию с Golang.

Мы просто обновим образ Docker до 1.17.

version: 2.1
jobs:
  build:
    working_directory: ~/repo
    docker:
      - image: cimg/go:1.17.9
    steps:
      - checkout
      - restore_cache:
          keys:
            - go-mod-v4-{{ checksum "go.sum" }}
      - run:
          name: Install Dependencies
          command: go get ./...
      - save_cache:
          key: go-mod-v4-{{ checksum "go.sum" }}
          paths:
            - "/go/pkg/mod"
      - run: go test -v

Теперь мы готовы настроить проект на CircleCI:


Требуемые параметры USERNAME и PASSWORD в helper_test.go can можно установить непосредственно из настроек Environment Variables проекта CircleCI:


Любой коммит в основную ветвь будет запускать сборку CircleCI (разумеется, её можно запускать и вручную), и если всё в порядке, вы должны увидеть успешно выполненные этапы:


Наша обёртка хорошо протестирована. Теперь приступим к её публикации.

▍ Публикация библиотеки на GitHub


При условии, что наш файл go.mod имеет следующее содержимое:

module github.com/hellonico/jquants-api-go

go 1.17

require olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3

Удобнее всего будет публиковать код при помощи git tag. Давайте создадим git tag и запушим его в GitHub следующим образом:

git tag v0.6.0
git push --tags

Теперь можно обеспечить зависимость отдельного проекта от нашей библиотеки, использовав её в go.mod.

require github.com/hellonico/jquants-api-go v0.6.0

▍ Использование библиотеки из внешней программы


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

package main

import (
    "flag"
    "fmt"
    jquants "github.com/hellonico/jquants-api-go"
)

func main() {

    code := flag.String("code", "86970", "Company Code")
    date := flag.String("date", "20220930", "Date of the quote")
    from := flag.String("from", "", "Start Date for date range")
    to := flag.String("to", "", "End Date for date range")
    refreshToken := flag.Bool("refresh", false, "refresh RefreshToken")
    refreshId := flag.Bool("id", false, "refresh IdToken")

    flag.Parse()

    if *refreshToken {
        jquants.GetRefreshToken()
    }
    if *refreshId {
        jquants.GetIdToken()
    }

    var quotes = jquants.Daily(*code, *date, *from, *to)

    fmt.Printf("[%d] Daily Quotes for %s \n", len(quotes.DailyQuotes), *code)
    for _, quote := range quotes.DailyQuotes {
        fmt.Printf("%s,%f\n", quote.Date, quote.Close)
    }

}

Мы можем создать CLI при помощи go build.

go build

И выполнить его с нужными параметрами:
  • Обновление токена ID.
  • Обновление токена обновления.
  • Получение суточных значений для записи с кодом 86970 с 20221005 по 20221010.

./jquants-example --id --refresh --from=20221005 --to=20221010 --code=86970

Code: 86970 and Date: 20220930 [From: 20221005 To: 20221010]
[3] Daily Quotes for 86970 
"2022-10-05",2016.500000
"2022-10-06",2029.000000
"2022-10-07",1992.500000

Отличная работа. Мы оставим пользователю задачу написания statements и listedInfo, являющихся частью JQuants API, но пока не реализованных в этой оболочке.

Telegram-канал с полезностями и уютный чат

Комментарии (6)


  1. inferrna
    14.11.2022 19:58

    А вот я бы предостерёг от использования типов с плавающей точкой в биржевой торговле.

    В остальном ничего казать не могу, с go не знаком.


    1. HellWalk
      15.11.2022 15:05
      +1

      предостерёг от использования типов с плавающей точкой в биржевой торговле

      Каждый должен наступить на эти грабли)


      1. marsermd
        15.11.2022 18:07

        А что будет?:)


        1. HellWalk
          17.11.2022 12:25

          Приколов может быть много (при чем в разных языках по разному), погуглите "ошибка с плавающей запятой в программировании"

          Суть в другом - при работе с балансом очень просто избавиться от float - просто работать, условно, с копейками, и только при выдаче значения делить его на 100 (помня при этом, что в разных валютах разное количество цифр после запятой)


    1. TicSo
      15.11.2022 16:22

      Каких можно ожидать проблем и как правильно делать в биржевой торговле? Поделитесь, плиз.


  1. Stas911
    15.11.2022 04:11

    Не мешало бы пояснить, что за файлы EDN