Целевым 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("es)
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-канал с полезностями и уютный чат
inferrna
А вот я бы предостерёг от использования типов с плавающей точкой в биржевой торговле.
В остальном ничего казать не могу, с go не знаком.
HellWalk
Каждый должен наступить на эти грабли)
marsermd
А что будет?:)
HellWalk
Приколов может быть много (при чем в разных языках по разному), погуглите "ошибка с плавающей запятой в программировании"
Суть в другом - при работе с балансом очень просто избавиться от float - просто работать, условно, с копейками, и только при выдаче значения делить его на 100 (помня при этом, что в разных валютах разное количество цифр после запятой)
TicSo
Каких можно ожидать проблем и как правильно делать в биржевой торговле? Поделитесь, плиз.