TINKOFF-INVEST. Разработка торгового робота на JAVA. Часть 2

56bc7ba618428fff2823a735ff96a6f3.png

Введение

Второй шаг делается потому, что сделан первый; второй шаг делается ради третьего (Фэн Цзицай, из книги «Полет души»)

Как же быстро летит время… Прошло почти 2 месяца с момента публикации моей первой статьи о работе с TINKOFF INVEST API — Разработка торгового робота на JAVA. Часть 1, в которой мы начали свое знакомство с инструментарием автоматизации торговли, предоставляемым брокером ТИНЬКОФФ.

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

Если нет желания вникать в код и читать статью, то можете сразу мотать к разделу «Демонстрация работы приложения».

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

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

Стюардесса в салоне нового лайнера объявляет о то, что находится в самолете:

— На первой палубе — багаж, на второй — бар, на третьей — поле для гольфа, на четвертой бассейн.

И добавляет:

— А теперь, господа, пристегнитесь. Сейчас со всей этой фигней мы попробуем взлететь.

В компанию к описанным в первой части компонентам добавляются:

  • SPRING FRAMEWORK — фреймворк для построения web-приложений;

  • SPRING DATA — компонент для взаимодействия с БД;

  • FLYWAY — библиотека для контроля версий БД;

  • H2 — легковесная СУБД, разработнная на JAVA;

  • MODELMAPPER — библиотека для маппинга объектов;

  • SPRING SHELL — инструмент для создания CLI-интерфейса (командная строка).

Таким образом, файл зависимостей maven (pom.xml) приобретает следующий вид:

pom.xml



    4.0.0
    
        org.springframework.boot
        spring-boot-starter-parent
        2.5.7
         
    

    ru.dsci
    stockdock
    0.0.1-SNAPSHOT
    stockdock
    stockdock
    
        11
    

    
        
            org.springframework.boot
            spring-boot-starter-data-jpa
        
        
            org.springframework.boot
            spring-boot-starter
        
        
            org.springframework.boot
            spring-boot-starter-web
            
                
                    org.springframework.boot
                    spring-boot-starter-logging
                
            
        
        
            org.projectlombok
            lombok
            true
        
        
            org.springframework.boot
            spring-boot-starter-test
            test
        
        
            ru.tinkoff.invest
            openapi-java-sdk-core
            0.5.1
        
        
            ru.tinkoff.invest
            openapi-java-sdk-java8
            0.5.1
        
        
            com.squareup.okhttp3
            okhttp
            4.10.0-RC1
        
        
            com.h2database
            h2
            1.4.200
            runtime
        
        
            org.flywaydb
            flyway-core
            8.2.3
        
        
            org.springframework.shell
            spring-shell-starter
            2.0.0.RELEASE
        
        
            org.modelmapper
            modelmapper
            3.0.0
        
    

    
        
            
                org.springframework.boot
                spring-boot-maven-plugin
                
                    
                        
                            org.projectlombok
                            lombok
                        
                    
                
            
        
    


В качестве основы я выбрал его величество SPRING. На данном этапе я планирую использовать СУБД лишь для тестов, поэтому выбрал легковесную H2, более того, она будет использоваться в IN-MEMORY-режиме, а это значит, что база данных будет создаваться при запуске приложения, все данные хранится в оперативной памяти до останова программы. Структура базы данных будет инициализирована с помощью скриптов DDL (Data Definition Language), которые мигрируются в БД механизмами FLYWAY. При таком подходе заменить СУБД не составит труда на любом этапе разработки. SPRING SHELL поможет создать интерфейс командной строки для управления приложением из консоли.

Поговорим немного о настройках. Настроечные параметры среды разработки хранятся в файле application-dev.yml. Сервер приложения будет доступен на порту :8800 (локально localhost:8800), логи сохраняются по пути logs/dev/stockdock.log, режим запуска СУБД в оперативной памяти (jdbc: h2: mem: mydatabase), консоль управления базой даннных доступна по пути /h2 (локально localhost:8800/h2).

application-dev.yml

server:
  port: 8800

spring:
  output:
    ansi:
      enabled: detect
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:mydatabase;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
    username: sa
    password:
  jpa:
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.H2Dialect
        ddl-auto: none
  h2:
    console:
      enabled: true
      settings:
        web-allow-others: false
      path: /h2

logging:
  level.ru.dsci.stockdock.* : debug
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} [%thread]: %msg%n"
    file: "%d{yyyy-MM-dd HH:mm:ss.sss} [%thread] %-5level %logger{36}: %msg%n"
  file:
    name: logs/dev/stockdock.log
    path: logs/dev

Структура БД

Есть только одно действительно неистощимое сокровище — это большая библиотека (Пьер Буаст — французский лексикограф)

Торговые стратегии строятся на основе анализа, для анализа необходимы данные, где их получить — понятно из контекста, где сохранить — тоже не должно вызывать вопросов, остался одна нерешенная задача — как хранить? И тут нет правильного ответа, каждый архитектор предложит свое решение.

Для хранения торговой информации я использую следующие таблицы:

  • instrument_type — тип инструмента (акции, облигации, фонды, валюты);

  • instrument — инструмент (описание конкретного инструмента);

  • timeframe — таймфрейм (минута, час, день, и т.д.);

  • candlestick — свеча (информации о котировках).

    Для создания структуры БД я использовал скрипт (V1__init_tables.sql), написанный с помощью DDL операторов, после его загрузки в СУБД получим, готовую к работе базу. Полагаю, дополнительных пояснений здесь не требуется.

    Для первоначального наполнения базы данных я также написал скрипт (V2__init_data.sql). С его помощью сохраним в базе данных доступные к загрузке инструменты и таймфреймы.

V1__init_tables.sql

-- INSTRUMENT_TYPE ----------------------------------------
drop table if exists insrtrument_type;
create table instrument_type
(
    id        serial primary key,
    code      varchar(256) not null,
    name      varchar(255)
);
create unique index instrument_type_code_uindex
    on instrument_type(code);
-----------------------------------------------------------

-- INSTRUMENT ---------------------------------------------
drop table if exists instrument cascade;
create table instrument
(
    id                 serial primary key,
    figi               varchar(255) not null,
    isin               varchar(255),
    ticker             varchar(255) not null,
    currency           varchar(255) not null,
    increment          numeric(19, 4),
    name               varchar(255),
    CHECK (increment >= 0),
    lot                integer      not null,
    instrument_type_id varchar(255) not null,
    foreign key (instrument_type_id) references instrument_type (id)
);
create unique index instrument_figi_uindex
    on instrument (figi);
create index instrument_ticker_index
    on instrument (ticker);
create index instrument_isin_index
    on instrument(isin);
create index instrument_currency_index
    on instrument (currency);
create index instrument_type_id_index
    on instrument (instrument_type_id);
-----------------------------------------------------------

-- TIMEFRAME ----------------------------------------------
drop table if exists timeframe cascade;
create table timeframe
(
    id   serial primary key,
    code varchar(64) not null,
    name varchar(256)
);
create unique index timeframe_code_uindex
    on timeframe(code);
-----------------------------------------------------------

-- CANDLESTICK --------------------------------------------
drop table if exists candlestick;
create table candlestick
(
    id            serial primary key,
    maximum_value numeric(20, 10),
    CHECK (maximum_value >= minimum_value),
    minimum_value numeric(20, 10),
    opened_value  numeric(20, 10),
    closed_value  numeric(20, 10),
    volume        integer CHECK (volume >= 0),
    since         timestamp not null,
    timeframe_id  integer,
    instrument_id integer,
    foreign key (timeframe_id) references timeframe (id),
    foreign key (instrument_id) references instrument (id)
);
create unique index candlestick_ticker_timeframe_since
    on candlestick (instrument_id, timeframe_id, since);
-----------------------------------------------------------

V2__init_data.sql

INSERT INTO INSTRUMENT_TYPE (id, code, name)
VALUES (1, 'currency', 'валюта'),
       (2, 'stock', 'акция'),
       (3, 'bond', 'облигация'),
       (4, 'etf', 'биржевой фонд');

INSERT INTO TIMEFRAME (id, code, name)
VALUES (1,  'MIN1', '1 минута'),
       (2,  'MIN2', '2 минуты'),
       (3,  'MIN5', '5 минут'),
       (4,  'MIN10', '10 минут'),
       (5,  'MIN15', '15 минут'),
       (6,  'MIN30', '30 минут'),
       (7,  'HOUR1', '1 час'),
       (8,  'DAY1', 'день'),
       (9,  'WEEK1', 'неделя'),
       (10, 'MON1', 'месяц');

Сущности

Я построю свой луна-парк, с блекджеком и шлюхами! (робот Бендер, мультфильм «Футурама»)

Обычно в проектах SPRING для манипулирования данными используется ORM (Object Relation Model), данная технология позволяет представить строки таблиц БД и их реляционные связи в виде объектов (они же сущности, они же entity).

Для представления таблиц БД insrtrument_type, instrument, timeframe, candlestick я использовал классы InstrumentType, Instrument, Timeframe и Candlestick, соответственно. Эти классы, как и структура БД, не повторяют в точности классы, которые нам предоставляет Tinkoff Invest API, сделано так по ряду причин, основная — это желание не затачиваться на конкретную реализацию.

InstrumentType.java

package ru.dsci.stockdock.models.entities;

import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
@Data
public class InstrumentType {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Setter(value = AccessLevel.PRIVATE)
    private Long id;

    private String code;

    private String name;

    @Override
    public String toString() {
        return this.code;
    }
}

Instrument.java

package ru.dsci.stockdock.models.entities;

import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;

import javax.persistence.*;
import java.math.BigDecimal;

@Entity
@Data
public class Instrument {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Setter(value = AccessLevel.PRIVATE)
    private Long id;

    private String figi;

    private String isin;

    private String ticker;

    private String currency;

    private String name;

    private BigDecimal increment;

    private int lot;

    @ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.MERGE)
    @JoinColumn(name = "instrument_type_id")
    private InstrumentType instrumentType;

    @Override
    public String toString() {
        return String.format("%s [%s] (%s)", this.ticker, this.figi, this.instrumentType.getCode());
    }

}

Timeframe.java

package ru.dsci.stockdock.models.entities;

import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
@Data
public class Timeframe {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Setter(value = AccessLevel.PRIVATE)
    private Long id;

    private String code;

    private String name;

    @Override
    public String toString() {
        return this.code;
    }
}

Candlestick.java

package ru.dsci.stockdock.models.entities;

import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;
import ru.dsci.stockdock.core.tools.DateTimeTools;

import javax.persistence.*;
import java.math.BigDecimal;
import java.time.ZonedDateTime;

@Entity
@Data
public class Candlestick {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Setter(value = AccessLevel.PRIVATE)
    private Long id;

    @ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.MERGE)
    @JoinColumn(name = "timeframe_id")
    private Timeframe timeframe;

    @ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.MERGE)
    @JoinColumn(name = "instrument_id")
    private Instrument instrument;

    private BigDecimal maximumValue;

    private BigDecimal minimumValue;

    private BigDecimal openedValue;

    private BigDecimal closedValue;

    private int volume;

    private ZonedDateTime since;

    @Override
    public String toString() {
        return String.format("%s [%s] %s: %.4f",
                this.instrument.getTicker(),
                this.timeframe.getCode(),
                DateTimeTools.getTimeFormatted(this.since),
                this.closedValue);
    }

}

Осталось всего ничего — получить данные у ТИНЬКОФФ, преобразовать их к нашей структуре и сохранить.

Загрузка данных

Взять всё, да и поделить! (Шариков, повесть Михаила Афанасьевича Булгакова «Собачье Сердце»)

Как соединиться с источником данных посредством TINKOFF NVEST API я описывал в первой части, изменений не много, разве, что классы подключения к API (ApiConnector) и предоставления данных (ContextProvider) были переименованы в TcsApiConnector и TcsContextProvider, помимо этого в класс TcsContextProvider был добавлен метод getCandles, назначение которого — загрузка торговой информации по конкретному инструменту.

TcsApiConnector.java

package ru.dsci.stockdock.tcs;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import ru.dsci.stockdock.core.Parameters;
import ru.tinkoff.invest.openapi.OpenApi;
import ru.tinkoff.invest.openapi.model.rest.SandboxRegisterRequest;
import ru.tinkoff.invest.openapi.okhttp.OkHttpOpenApi;

@Slf4j
@Component
public class TcsApiConnector implements AutoCloseable {

    private final Parameters parameters;
    private OpenApi openApi;

    public TcsApiConnector(Parameters parameters) {
        this.parameters = parameters;
    }

    public OpenApi getOpenApi() throws Exception {
        if (openApi == null) {
            close();
            openApi = new OkHttpOpenApi(parameters.getToken(), parameters.isSandBoxMode());
            if (openApi.isSandboxMode()) {
                openApi.getSandboxContext().performRegistration(new SandboxRegisterRequest()).join();
            }
        }
        return openApi;
    }

    @Override
    public void close() throws Exception {
        if (openApi != null) {
            openApi.close();
        }
    }
}

TcsContextProvider.java

package ru.dsci.stockdock.tcs;

import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Component;
import ru.dsci.stockdock.models.entities.Instrument;
import ru.dsci.stockdock.models.entities.Timeframe;
import ru.tinkoff.invest.openapi.OpenApi;
import ru.tinkoff.invest.openapi.model.rest.CandleResolution;
import ru.tinkoff.invest.openapi.model.rest.Candles;
import ru.tinkoff.invest.openapi.model.rest.MarketInstrumentList;

import java.time.ZonedDateTime;
import java.util.Optional;

@Slf4j
@Component
@AllArgsConstructor
public class TcsContextProvider {

    private final TcsApiConnector tcsApiConnector;
    private final ModelMapper modelMapper;

    public MarketInstrumentList getStocks() throws Exception {
        return getOpenApi().getMarketContext().getMarketStocks().join();
    }

    public MarketInstrumentList getBonds() throws Exception {
        return getOpenApi().getMarketContext().getMarketBonds().join();
    }

    public MarketInstrumentList getEtfs() throws Exception {
        return getOpenApi().getMarketContext().getMarketEtfs().join();
    }

    public MarketInstrumentList getCurrencies() throws Exception {
        return getOpenApi().getMarketContext().getMarketCurrencies().join();
    }

    public Optional getCandles(
            Instrument instrument, Timeframe timeframe, ZonedDateTime begPeriod, ZonedDateTime endPeriod)
            throws Exception {
        return getOpenApi().getMarketContext().getMarketCandles(
                instrument.getFigi(),
                begPeriod.toOffsetDateTime(),
                endPeriod.toOffsetDateTime(),
                modelMapper.map(timeframe, CandleResolution.class)).join();
    }

    private OpenApi getOpenApi() throws Exception {
        return tcsApiConnector.getOpenApi();
    }

}

Стоит заострить внимание на методе TINKOFF INVEST API getMarketCandles, он возвращает инстанс объекта класса Candles, из которого посредством метода getCandles можно получить коллекцию объектов класса Candle, она содержит необходимы нам свечи.

Candle.java

package ru.tinkoff.invest.openapi.model.rest;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Objects;

public class Candle {
    @JsonProperty("figi")
    private String figi = null;
    @JsonProperty("interval")
    private CandleResolution interval = null;
    @JsonProperty("o")
    private BigDecimal o = null;
    @JsonProperty("c")
    private BigDecimal c = null;
    @JsonProperty("h")
    private BigDecimal h = null;
    @JsonProperty("l")
    private BigDecimal l = null;
    @JsonProperty("v")
    private Integer v = null;
    @JsonProperty("time")
    private OffsetDateTime time = null;

    public Candle() {
    }

    public Candle figi(String figi) {
        this.figi = figi;
        return this;
    }

    @Schema(
        required = true,
        description = ""
    )
    public String getFigi() {
        return this.figi;
    }

    public void setFigi(String figi) {
        this.figi = figi;
    }

    public Candle interval(CandleResolution interval) {
        this.interval = interval;
        return this;
    }

    @Schema(
        required = true,
        description = ""
    )
    public CandleResolution getInterval() {
        return this.interval;
    }

    public void setInterval(CandleResolution interval) {
        this.interval = interval;
    }

    public Candle o(BigDecimal o) {
        this.o = o;
        return this;
    }

    @Schema(
        required = true,
        description = ""
    )
    public BigDecimal getO() {
        return this.o;
    }

    public void setO(BigDecimal o) {
        this.o = o;
    }

    public Candle c(BigDecimal c) {
        this.c = c;
        return this;
    }

    @Schema(
        required = true,
        description = ""
    )
    public BigDecimal getC() {
        return this.c;
    }

    public void setC(BigDecimal c) {
        this.c = c;
    }

    public Candle h(BigDecimal h) {
        this.h = h;
        return this;
    }

    @Schema(
        required = true,
        description = ""
    )
    public BigDecimal getH() {
        return this.h;
    }

    public void setH(BigDecimal h) {
        this.h = h;
    }

    public Candle l(BigDecimal l) {
        this.l = l;
        return this;
    }

    @Schema(
        required = true,
        description = ""
    )
    public BigDecimal getL() {
        return this.l;
    }

    public void setL(BigDecimal l) {
        this.l = l;
    }

    public Candle v(Integer v) {
        this.v = v;
        return this;
    }

    @Schema(
        required = true,
        description = ""
    )
    public Integer getV() {
        return this.v;
    }

    public void setV(Integer v) {
        this.v = v;
    }

    public Candle time(OffsetDateTime time) {
        this.time = time;
        return this;
    }

    @Schema(
        required = true,
        description = "ISO8601"
    )
    public OffsetDateTime getTime() {
        return this.time;
    }

    public void setTime(OffsetDateTime time) {
        this.time = time;
    }

    public boolean equals(Object o) {
        if (this == o) {
            return true;
        } else if (o != null && this.getClass() == o.getClass()) {
            Candle candle = (Candle)o;
            return Objects.equals(this.figi, candle.figi) && Objects.equals(this.interval, candle.interval) && Objects.equals(this.o, candle.o) && Objects.equals(this.c, candle.c) && Objects.equals(this.h, candle.h) && Objects.equals(this.l, candle.l) && Objects.equals(this.v, candle.v) && Objects.equals(this.time, candle.time);
        } else {
            return false;
        }
    }

    public int hashCode() {
        return Objects.hash(new Object[]{this.figi, this.interval, this.o, this.c, this.h, this.l, this.v, this.time});
    }

    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("class Candle {\n");
        sb.append("    figi: ").append(this.toIndentedString(this.figi)).append("\n");
        sb.append("    interval: ").append(this.toIndentedString(this.interval)).append("\n");
        sb.append("    o: ").append(this.toIndentedString(this.o)).append("\n");
        sb.append("    c: ").append(this.toIndentedString(this.c)).append("\n");
        sb.append("    h: ").append(this.toIndentedString(this.h)).append("\n");
        sb.append("    l: ").append(this.toIndentedString(this.l)).append("\n");
        sb.append("    v: ").append(this.toIndentedString(this.v)).append("\n");
        sb.append("    time: ").append(this.toIndentedString(this.time)).append("\n");
        sb.append("}");
        return sb.toString();
    }

    private String toIndentedString(Object o) {
        return o == null ? "null" : o.toString().replace("\n", "\n    ");
    }
}

Тиньковский класс Candle и мой Candlestick служат для хранения однотипной информации, но это совсем не одно и то же, соответственно стоит вопрос, как преобразовать Candle в Candlestick? Для этого и других конвертаций я использовал библиотеку ModelMapper. Правила преобразования сущностей определены в классе TcsModelMapper. Теперь необходимые мне преобразования можно выполнить путем вызова метода map (, ) бина modelPapper. Сам бин modelMapper и его первоначальные настройки содержатся в классе-конфигураторе приложения StockDockConfiguration, кроме того, класс конфигурации возвращает бин параметров приложения Parameters, необходимый для инициализации соединения с API.

TcsMapper.java

package ru.dsci.stockdock.tcs;

import lombok.AllArgsConstructor;
import lombok.Data;
import org.modelmapper.Converter;
import org.modelmapper.ModelMapper;
import org.modelmapper.spi.MappingContext;
import org.springframework.stereotype.Component;
import ru.dsci.stockdock.models.entities.Candlestick;
import ru.dsci.stockdock.models.entities.Instrument;
import ru.dsci.stockdock.models.entities.Timeframe;
import ru.dsci.stockdock.services.impl.InstrumentServiceImpl;
import ru.dsci.stockdock.services.impl.InstrumentTypeServiceImpl;
import ru.dsci.stockdock.services.impl.TimeFrameServiceImpl;
import ru.tinkoff.invest.openapi.model.rest.Candle;
import ru.tinkoff.invest.openapi.model.rest.CandleResolution;
import ru.tinkoff.invest.openapi.model.rest.MarketInstrument;

import javax.annotation.PostConstruct;

@Component
@Data
@AllArgsConstructor
public class TcsMapper {

    private final ModelMapper modelMapper;
    private final InstrumentTypeServiceImpl instrumentTypeService;
    private final InstrumentServiceImpl instrumentService;
    private final TimeFrameServiceImpl timeFrameService;

    @PostConstruct
    public void init() {

        modelMapper.createTypeMap(CandleResolution.class, Timeframe.class).setConverter(new Converter() {
            @Override
            public Timeframe convert(MappingContext mappingContext) {
                String timeframeCode;
                switch (mappingContext.getSource()) {
                    case _1MIN:
                        timeframeCode = "MIN1";
                        break;
                    case _2MIN:
                        timeframeCode = "MIN2";
                        break;
                    case _3MIN:
                        timeframeCode = "MIN3";
                        break;
                    case _5MIN:
                        timeframeCode = "MIN5";
                        break;
                    case _10MIN:
                        timeframeCode = "MIN10";
                        break;
                    case _15MIN:
                        timeframeCode = "MIN15";
                        break;
                    case _30MIN:
                        timeframeCode = "MIN30";
                        break;
                    case HOUR:
                        timeframeCode = "HOUR1";
                        break;
                    case DAY:
                        timeframeCode = "DAY1";
                        break;
                    case WEEK:
                        timeframeCode = "WEEK1";
                        break;
                    case MONTH:
                        timeframeCode = "MON1";
                        break;
                    default:
                        timeframeCode = null;
                }
                return timeFrameService.getByCode(timeframeCode);
            }
        });

        modelMapper.createTypeMap(Timeframe.class, CandleResolution.class).setConverter(new Converter() {
            @Override
            public CandleResolution convert(MappingContext mappingContext) {
                switch (mappingContext.getSource().getCode()) {
                    case "MIN1":
                        return CandleResolution._1MIN;
                    case "MIN2":
                        return CandleResolution._2MIN;
                    case "MIN3":
                        return CandleResolution._3MIN;
                    case "MIN5":
                        return CandleResolution._5MIN;
                    case "MIN10":
                        return CandleResolution._10MIN;
                    case "MIN15":
                        return CandleResolution._15MIN;
                    case "MIN30":
                        return CandleResolution._30MIN;
                    case "HOUR1":
                        return CandleResolution.HOUR;
                    case "DAY1":
                        return CandleResolution.DAY;
                    case "WEEK1":
                        return CandleResolution.WEEK;
                    case "MONTH1":
                        return CandleResolution.MONTH;
                    default:
                        return null;
                }
            }
        });

        modelMapper.createTypeMap(Candle.class, Candlestick.class).setConverter(new Converter() {
            @Override
            public Candlestick convert(MappingContext mappingContext) {
                Candle candle = mappingContext.getSource();
                Candlestick candlestick = new Candlestick();
                candlestick.setTimeframe(modelMapper.map(candle.getInterval(), Timeframe.class));
                candlestick.setInstrument(instrumentService.getByFigi(candle.getFigi()));
                candlestick.setSince(candle.getTime().toZonedDateTime());
                candlestick.setOpenedValue(candle.getO());
                candlestick.setClosedValue(candle.getC());
                candlestick.setMaximumValue(candle.getH());
                candlestick.setMinimumValue(candle.getL());
                candlestick.setVolume(candle.getV());
                return candlestick;
            }
        });

        modelMapper.createTypeMap(MarketInstrument.class, Instrument.class).setConverter(new Converter() {
            @Override
            public Instrument convert(MappingContext mappingContext) {
                Instrument instrument = new Instrument();
                MarketInstrument marketInstrument = mappingContext.getSource();
                instrument.setCurrency(marketInstrument.getCurrency().getValue());
                instrument.setTicker(marketInstrument.getTicker());
                instrument.setFigi(marketInstrument.getFigi());
                instrument.setIsin(marketInstrument.getIsin());
                instrument.setName(marketInstrument.getName());
                instrument.setLot(marketInstrument.getLot());
                instrument.setIncrement(marketInstrument.getMinPriceIncrement());
                instrument.setInstrumentType(instrumentTypeService.getByCode(marketInstrument.getType().toString()));
                return instrument;
            }
        });
    }
}

StockDockConfiguration.java

package ru.dsci.stockdock;

import org.modelmapper.ModelMapper;
import org.modelmapper.convention.MatchingStrategies;
import org.springframework.boot.ApplicationArguments;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import ru.dsci.stockdock.core.Parameters;

import static org.modelmapper.config.Configuration.AccessLevel.PRIVATE;

@Configuration
@ComponentScan
public class StockDockConfiguration {

    @Bean
    public ModelMapper modelMapper() {
        ModelMapper modelMapper = new ModelMapper();
        modelMapper.getConfiguration()
                .setMatchingStrategy(MatchingStrategies.STRICT)
                .setFieldMatchingEnabled(true)
                .setSkipNullEnabled(true)
                .setFieldAccessLevel(PRIVATE);
        return modelMapper;
    }

    @Bean
    public Parameters parameters(ApplicationArguments arguments) {
        return new Parameters(arguments.getSourceArgs());
    }

}

Итак, почти все готово, пробуем запросить минутные данные по акциям Сбера за 2022 год. И бЯда… бЯда… Получаем примерно такую ошибку ошибку:

2022-02-03 23:18:45 [main]: ru.tinkoff.invest.openapi.exceptions.OpenApiException: [to]: Bad candle interval: from=2021-12-31T21:00:00Z to=2022-02-03T21:00:00Z expected 
from 1 minute to 1 day

Так в чем же дело? А вот в чем! Разработчики TINKOFF INVEST API позаботились о снижении нагрузки на свои серверы, как водится, усложнив жизнь своим клиентам.

Ограничения имеют следующий вид:

Тайм фрейм

Максимальный период

1 минута

1 день

2 минты

1 день

3 минуты

1 день

5 минут

1 день

10 минут

1 день

15 минут

1 день

30 минут

1 день

1 час

7 дней

1 день

1 год

1 неделя

2 года

1 месяц

10 лет

И что же нам делать, если хочется получить минутные данные за месяц? Я для этих целей разработал инструмент, который разобьет период, превышающий установленный разработчиками, на более мелкие, затем запросить данные порциями.

Для описания интервалов служит класс TimeInterval, описание правил разбиения на периоды содержатся в классе TcsTools, методы для работы с датами и временем я определил в классе DateTimeTools.

TcsTools.java

package ru.dsci.stockdock.tcs;

import ru.dsci.stockdock.core.tools.DateTimeTools;
import ru.dsci.stockdock.core.tools.TimeInterval;
import ru.dsci.stockdock.models.entities.Timeframe;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;

public class TcsTools {
    public static List splitPeriod (
            Timeframe timeFrame, ZonedDateTime begPeriod, ZonedDateTime endPeriod) {

        ChronoUnit chronoUnit;
        int chronoSize;
        switch (timeFrame.getCode()) {
            case "MON1":
                chronoUnit = ChronoUnit.YEARS;
                chronoSize = 10;
                break;
            case "WEEK1":
                chronoUnit = ChronoUnit.YEARS;
                chronoSize = 2;
                break;
            case "DAY1":
                chronoUnit = ChronoUnit.YEARS;
                chronoSize = 1;
                break;
            case "HOUR1":
                chronoUnit = ChronoUnit.DAYS;
                chronoSize = 7;
                break;
            default:
                chronoUnit = ChronoUnit.DAYS;
                chronoSize = 1;
        }
        return DateTimeTools.splitInterval(chronoUnit, chronoSize, begPeriod, endPeriod);
    }

}

TimeInteerval.java

package ru.dsci.stockdock.core.tools;

import lombok.Data;
import lombok.NonNull;

import java.time.ZonedDateTime;

@Data
public class TimeInterval {

    private ZonedDateTime begInterval;
    private ZonedDateTime endInterval;

    @Override
    public String toString() {
        return String.format("%s-%s",
                DateTimeTools.getTimeFormatted(begInterval),
                DateTimeTools.getTimeFormatted(endInterval));
    }

    public TimeInterval(@NonNull ZonedDateTime begInterval, @NonNull ZonedDateTime endInterval) {
        DateTimeTools.checkInterval(begInterval, endInterval);
        this.begInterval = begInterval;
        this.endInterval = endInterval;
    }
}

DateTimeTools.java

package ru.dsci.stockdock.core.tools;

import java.time.DateTimeException;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.chrono.ChronoZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;

public class DateTimeTools {

    public static ZoneId DEFAULT_ZONE_ID = ZoneId.of("Europe/Moscow");

    public static ZoneId zoneId = DEFAULT_ZONE_ID;

    public static final String DATE_PATTERN = "dd.MM.yyyy";
    public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter
            .ofPattern(DATE_PATTERN)
            .withZone(zoneId);
    public static final String DATE_TIME_PATTERN = "dd.MM.yyyy HH:mm:ss";
    public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter
            .ofPattern(DATE_TIME_PATTERN)
            .withZone(zoneId);

    public static String getTimeFormatted(ZonedDateTime dateTime) {
        return dateTime.format(DATE_TIME_FORMATTER);
    }

    public static String getDateFormatted(ZonedDateTime dateTime) {
        return dateTime.format(DATE_FORMATTER);
    }

    public static void checkInterval(ZonedDateTime begInterval, ZonedDateTime endInterval) {
        if (endInterval.isBefore(begInterval))
            throw new DateTimeException(String.format("Invalid date interval: %s - %s",
                    getTimeFormatted(begInterval),
                    getTimeFormatted(endInterval)));
    }

    public static List splitInterval(
            ChronoUnit chronoUnit, int chronoSize, ZonedDateTime begInterval, ZonedDateTime endInterval) {
        checkInterval(begInterval, endInterval);
        if (chronoSize <= 0)
            throw new DateTimeException(String.format("Invalid interval: %d", chronoSize));
        List intervals = new ArrayList<>();
        ChronoZonedDateTime[] periodTmp = new ChronoZonedDateTime[2];
        periodTmp[0] = begInterval;
        while (periodTmp[0].isBefore(endInterval)) {
            periodTmp[1] = periodTmp[0].plus(chronoSize, chronoUnit);
            if (periodTmp[1].isAfter(endInterval))
                periodTmp[1] = endInterval;
            intervals.add(new TimeInterval((ZonedDateTime) periodTmp[0], (ZonedDateTime) periodTmp[1]));
            periodTmp[0] = periodTmp[0]
                    .plus(chronoSize, chronoUnit)
                    .plus(1, ChronoUnit.MICROS);
        }
        return intervals;
    }

    public static ZonedDateTime parseDate(String textDate) {
        ZonedDateTime date;
        try {
            String[] dateArray = textDate.split("\\.");
            if (dateArray.length < 3)
                throw new IllegalArgumentException(String.format("Incorrect date: %s", textDate));
            int day = Integer.parseInt(dateArray[0]);
            int month = Integer.parseInt(dateArray[1]);
            int year = Integer.parseInt(dateArray[2]);
            date = ZonedDateTime.of(year, month, day, 0, 0, 0, 0, DEFAULT_ZONE_ID);
        } catch (Throwable e) {
            throw new DateTimeException(
                    String.format("Can't parse '%s' into date: %s", textDate, e.getMessage()));
        }
        return date;
    }

}

Работа с базой данных

Искусство революции простое. Главное — занять и ценой каких угодно потерь удержать — телефон, телеграф, железнодорожный станции и мосты. (Владимир Ильич Ленин, в представлении не нуждается)

Для загрузки / выгрузки данных я использовал механизмы SPRING DATA. Все по классике — репозиторий (слой доступа к данным), сервис (слой бизнес-логики), сущность (представляет связанные реляционно записи базы данных в виде объекта).

репозиторий

сервис

сущность

InstrumentTypeRepository

InstrumentTypeService

InstrumentType

InstrumentRepository

InstrumentService

Instrument

TimeframeRepository

TimeframeService

Timeframe

CandlestickRepository

CandlestickService

Candlestick

Углубляться в описание кода не вижу смысла, как мне кажется, тут все просто.

InstrumentTypeRepository.java

package ru.dsci.stockdock.repositories;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.dsci.stockdock.models.entities.InstrumentType;

@Repository
public interface InstrumentTypeRepository extends JpaRepository {

    InstrumentType findByCodeIgnoreCase(String code);

}

InstrumentTypeServiceImpl.java

package ru.dsci.stockdock.services.impl;

import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import ru.dsci.stockdock.exceptions.EntityNotFoundException;
import ru.dsci.stockdock.models.entities.InstrumentType;
import ru.dsci.stockdock.repositories.InstrumentTypeRepository;
import ru.dsci.stockdock.services.InstrumentTypeService;

import java.util.List;

@Service
@AllArgsConstructor
public class InstrumentTypeServiceImpl implements InstrumentTypeService {

    private InstrumentTypeRepository instrumentTypeRepository;

    @Override
    public List getAll() {
        return instrumentTypeRepository.findAll();
    }

    @Override
    public InstrumentType getById(Long id) {
        return instrumentTypeRepository
                .findById(id)
                .orElseThrow(() -> new EntityNotFoundException(InstrumentType.class, id));
    }

    @Override
    public InstrumentType getByCode(String code) {
        return instrumentTypeRepository.findByCodeIgnoreCase(code);
    }
}

InstrumentRepository.java

package ru.dsci.stockdock.repositories;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.dsci.stockdock.models.entities.Instrument;

@Repository
public interface InstrumentRepository extends JpaRepository {

    Instrument findByFigiIgnoreCase(String figi);

    Instrument findByTickerIgnoreCase(String ticker);

    Instrument findByFigiIgnoreCaseOrTickerIgnoreCase(String figi, String ticker);

    boolean existsInstrumentByFigiIgnoreCase(String figi);

}

InstrumentServiceImpl.java

package ru.dsci.stockdock.services.impl;

import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import ru.dsci.stockdock.exceptions.EntityNotFoundException;
import ru.dsci.stockdock.models.entities.Instrument;
import ru.dsci.stockdock.repositories.InstrumentRepository;
import ru.dsci.stockdock.services.InstrumentService;

import javax.transaction.Transactional;
import java.util.List;

@Service
@AllArgsConstructor
public class InstrumentServiceImpl implements InstrumentService {

    private InstrumentRepository instrumentRepository;

    @Override
    public List getAll() {
        return instrumentRepository.findAll();
    }

    @Override
    public Instrument getById(Long id) {
        return instrumentRepository
                .findById(id)
                .orElseThrow(() -> new EntityNotFoundException(Instrument.class, id));
    }

    @Override
    public Instrument getByTicker(String ticker) {
        return instrumentRepository.findByTickerIgnoreCase(ticker);
    }

    @Override
    public Instrument getByFigiOrTicker(String identifier) {
        return instrumentRepository.findByFigiIgnoreCaseOrTickerIgnoreCase(identifier, identifier);
    }

    @Override
    public Instrument getByFigi(String isin) {
        return instrumentRepository.findByFigiIgnoreCase(isin);
    }

    @Override
    public void saveAllIfNotExists(List instruments) {
        instruments.forEach(this::saveIfNotExists);
    }

    @Override
    @Transactional
    public void saveIfNotExists(Instrument instrument) {
        if (!instrumentRepository.existsInstrumentByFigiIgnoreCase(instrument.getFigi()))
            instrumentRepository.saveAndFlush(instrument);
    }

}

TimeframeRepository.java

package ru.dsci.stockdock.repositories;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.dsci.stockdock.models.entities.Timeframe;

@Repository
public interface TimeframeRepository extends JpaRepository {

    Timeframe findByCodeIgnoreCase(String code);

}

TimeframeServiceImpl.java

package ru.dsci.stockdock.services.impl;

import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import ru.dsci.stockdock.exceptions.EntityNotFoundException;
import ru.dsci.stockdock.models.entities.Timeframe;
import ru.dsci.stockdock.repositories.TimeframeRepository;
import ru.dsci.stockdock.services.TimeFrameService;

import java.util.List;

@Service
@AllArgsConstructor
public class TimeFrameServiceImpl implements TimeFrameService {

    private TimeframeRepository periodRepository;

    @Override
    public List getAll() {
        return periodRepository.findAll();
    }

    @Override
    public Timeframe getById(Long id) {
        return periodRepository
                .findById(id)
                .orElseThrow(() -> new EntityNotFoundException(Timeframe.class, id));
    }

    @Override
    public Timeframe getByCode(String code) {
        return periodRepository.findByCodeIgnoreCase(code);
    }
}

CandlestickRepository.java

package ru.dsci.stockdock.repositories;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.dsci.stockdock.models.entities.Candlestick;
import ru.dsci.stockdock.models.entities.Instrument;
import ru.dsci.stockdock.models.entities.Timeframe;

import java.time.ZonedDateTime;
import java.util.List;

@Repository
public interface CandlestickRepository extends JpaRepository {

    List getCandlestickByInstrumentAndTimeframeAndSinceBetweenOrderBySince(
            Instrument instrument, Timeframe timeframe, ZonedDateTime begPeriod, ZonedDateTime endPeriod);

    boolean existsByInstrumentAndTimeframeAndSince(Instrument instrument, Timeframe timeframe, ZonedDateTime since);

}

CandlestickServiceImpl.java

package ru.dsci.stockdock.services.impl;

import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import ru.dsci.stockdock.core.GlobalContext;
import ru.dsci.stockdock.core.tools.DateTimeTools;
import ru.dsci.stockdock.models.entities.Candlestick;
import ru.dsci.stockdock.models.entities.Instrument;
import ru.dsci.stockdock.models.entities.Timeframe;
import ru.dsci.stockdock.repositories.CandlestickRepository;
import ru.dsci.stockdock.services.CandlestickService;

import javax.transaction.Transactional;
import java.time.ZonedDateTime;
import java.util.List;

@Service
@AllArgsConstructor
public class CandlestickServiceImpl implements CandlestickService {

    private CandlestickRepository candlestickRepository;

    @Override
    public Candlestick getById(Long id) {
        return candlestickRepository.getById(id);
    }

    @Override
    public List getCandlesticks(Instrument instrument, Timeframe timeframe) {
        return getCandlesticks(instrument, timeframe, null, null);
    }

    @Override
    public List getCandlesticks(Instrument instrument, Timeframe timeframe, ZonedDateTime begPeriod) {
        return getCandlesticks(instrument, timeframe, begPeriod);
    }

    @Override
    public List getCandlesticks(
            Instrument instrument, Timeframe timeframe, ZonedDateTime begPeriod, ZonedDateTime endPeriod) {
        if (begPeriod == null)
            begPeriod = GlobalContext.BEG_DATE;
        if (endPeriod == null)
            endPeriod = ZonedDateTime.now();
        DateTimeTools.checkInterval(begPeriod, endPeriod);
        return candlestickRepository.getCandlestickByInstrumentAndTimeframeAndSinceBetweenOrderBySince(
                instrument, timeframe, begPeriod, endPeriod);
    }

    @Override
    @Transactional
    public void saveAllIfNotExists(Candlestick candlestick) {
        if (!candlestickRepository.existsByInstrumentAndTimeframeAndSince(
                candlestick.getInstrument(), candlestick.getTimeframe(), candlestick.getSince()))
            candlestickRepository.saveAndFlush(candlestick);
    }
}

Entites описаны в разделе сущности.

Интерфейс командной строки

Я вам помогу, ребята. Я буду командовать! (Кар Карыч, мультфильм «Смешарики»)

Сравнительно недавно открыл для себя замечательную библиотеку SPRING SHELL, в чем смысл ее использования? Допустим, разработали мы приложение на SPRING, а дальше? Как управлять его поведением? Как тестировать? Дергать контроллеры через web-интерфейс? Использовать Postman? А что если просто запускать методы сервисов из консоли, подобно тому, как мы работаем с текстовыми интерфейсами в посведневной жизни? Здорово? На мой взгляд, да! Кто-то возразит мне, мол, вот еще, это же дополнительные временные издержки для разработки CLI, и отчасти будут правы. Но что если разработка интеофейса командной строки сведется лишь к написанию аннотаций к существующим методам? С помощью SPRING SHELL можно просто прононотировать методы соответствующим образом, после запуска приложения они будут доступны для вызова из командной строки.

Пока что я ограничился двумя командами:

  • ui (update instruments) — обновляет интрументы (загружает из TINKOFF INVEST API пул инструментов и сохраняет в нашу базу данных если инструмент отсутствует).

    использование:

    ui [[-t][], где где:

    type_list — список типов (etf, bond, stock, currency) необязательный параметр, разделитель запятая, список заполнятся без пробелов;

    -t — необязательный ключ, после которого указывается список инструментов.

    примеры:

  • uc (update candlesticks) — обновляет тороговую информацию (загружает из TINKOFF INVEST API датасет с данными по указанному инструменту, затем сохраняет их в базу данных если данные отсутствуют).

    использование:

    uc [-i] [-t] [[-b]] [[-e]], где:

    • identifier — идентификатор инструмента (ticker или figi), можно указывать список (разделитель запятая, без пробелов);

    • timeframe — идентификатор таймфрейма (min1, min2, min5, min10, min15, min30, hour1, day1, week1, mon1), можно указывать список (разделитель запятая, без пробелов);

    • beg_period — начало периода запроса данных, параметр необязательный, по умолчанию указывается значение, установленное в GlobalContext.BEG_DATE (01.01.2020);

    • end_period — окончание периода запроса данных, параметр необязательные, по умолчанию указывается текущее время

    • -i, -t, -b, -e — необязательные ключи для идентификатора, таймфрейма, начала периода и окончения периода, соответственно, их можно указывать если есть необходимость переопределения последовательности параметров.

      примеры:

    • uc tatn, luk day1, hour1 01.01.2020 31.12.2021 — для эмитентов Татнефть и Лукоил обновить дневные и часовые данные за 2021 и 2022 годы;

    • uc tatn hour1 01.01.2022 — обновить часовые данные по эмитенту Татнефть с 01.01.2022 — по настоящее время.

    Также имеются встроенные команды:

    • help [] — вызов справки [справки по команде];

    • stacktrace — вывод на экран стектрейса по последней ошибке;

    • clear — очищает консоль;

    • exit, quit — закрывает shell;

    • script — запускает скрипт из текстового файла.

    Результаты выполнения команды help ниже:

help

Built-In Commands
clear: Clear the shell screen.
exit, quit: Exit the shell.
help: Display help about available commands.
script: Read and execute commands from a file.
stacktrace: Display the full stacktrace of the last error.

Cli Processor
uc: update candlesticks
ui: update instruments

help ui

NAME
ui — update instruments

SYNOPSYS
ui [[-t] string]

OPTIONS
-t or --type string
data type to update (instruments [bond, etf, currency, stock])
[Optional, default = ]

help uc

SYNOPSYS
uc [-i] string [[-t] string] [[-b] string] [[-e] string]

OPTIONS
-i or --id string
instrument identifier (ticker or figi)
[Mandatory]

-t or --tf string timeframe (min1, min2, min5, min10, min15, min30, hour1, day1, week1, mon1) [Optional, default = day1]

-b or --bp string the beginning of period [Optional, default = ]

-e or --ep string the end of period [Optional, default = ]

Команды текстового интерфейса я вынес в отдельный файл CliProcessor, хотя это и не обязательно, повторюсь, достаточно просто указать соответствующие нотации для существующих методов, SPRING SHELL соберет все воедино без вашего участия.

CliProcessor.java

package ru.dsci.stockdock.core;

import lombok.AllA
    
            

© Habrahabr.ru