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

в 12:36, , рубрики: api, java, sql, tinkoff, tinkoff api, аналитика, биржа, брокер, Программирование, торговый робот, финансы в IT, фондовый рынок

Введение

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

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

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

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

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

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

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

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

И добавляет:

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

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

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

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

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

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

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

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

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

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.7</version>
        <relativePath/> 
    </parent>

    <groupId>ru.dsci</groupId>
    <artifactId>stockdock</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>stockdock</name>
    <description>stockdock</description>
    <properties>
        <java.version>11</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-logging</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>ru.tinkoff.invest</groupId>
            <artifactId>openapi-java-sdk-core</artifactId>
            <version>0.5.1</version>
        </dependency>
        <dependency>
            <groupId>ru.tinkoff.invest</groupId>
            <artifactId>openapi-java-sdk-java8</artifactId>
            <version>0.5.1</version>
        </dependency>
        <dependency>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>okhttp</artifactId>
            <version>4.10.0-RC1</version>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <version>1.4.200</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
            <version>8.2.3</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.shell</groupId>
            <artifactId>spring-shell-starter</artifactId>
            <version>2.0.0.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.modelmapper</groupId>
            <artifactId>modelmapper</artifactId>
            <version>3.0.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

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

Поговорим немного о настройках. Настроечные параметры среды разработки хранятся в файле application-dev.yml. Сервер приложения будет доступен на порту <host>:8800 (локально localhost:8800), логи сохраняются по пути logs/dev/stockdock.log, режим запуска СУБД в оперативной памяти (jdbc:h2:mem:mydatabase), консоль управления базой даннных доступна по пути <host>/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<Candles> 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(<src_entity>,<target_class>) бина 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<CandleResolution, Timeframe>() {
            @Override
            public Timeframe convert(MappingContext<CandleResolution, Timeframe> 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<Timeframe, CandleResolution>() {
            @Override
            public CandleResolution convert(MappingContext<Timeframe, CandleResolution> 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<Candle, Candlestick>() {
            @Override
            public Candlestick convert(MappingContext<Candle, Candlestick> 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<MarketInstrument, Instrument>() {
            @Override
            public Instrument convert(MappingContext<MarketInstrument, Instrument> 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<TimeInterval> 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<TimeInterval> 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<TimeInterval> 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, Long> {

    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<InstrumentType> 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, Long> {

    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<Instrument> 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<Instrument> 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, Long> {

    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<Timeframe> 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<Candlestick, Long> {

    List<Candlestick> 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<Candlestick> getCandlesticks(Instrument instrument, Timeframe timeframe) {
        return getCandlesticks(instrument, timeframe, null, null);
    }

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

    @Override
    public List<Candlestick> 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>], где где:

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

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

    примеры:

    • ui – обновить все инструменты;

    • ui stock,etf (ui -t stock,etf) – обновить инструменты по списку

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

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

    uc [-i] <identifier> [-t] <timeframe> [[-b]<beg_period>] [[-e]<end_period>], где:

    • 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 [<command>] – вызов справки [справки по команде];

    • 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 = <none>]

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

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

CliProcessor.java
package ru.dsci.stockdock.core;

import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;
import org.springframework.stereotype.Component;
import ru.dsci.stockdock.core.tools.DateTimeTools;
import ru.dsci.stockdock.models.entities.Instrument;
import ru.dsci.stockdock.models.entities.Timeframe;
import ru.dsci.stockdock.services.InstrumentService;
import ru.dsci.stockdock.services.impl.DataServiceImpl;
import ru.dsci.stockdock.services.impl.TimeFrameServiceImpl;

import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;

@Component
@ShellComponent
@AllArgsConstructor
@Slf4j
public class CliProcessor {

    private final DataServiceImpl dataService;
    private final InstrumentService instrumentService;
    private final TimeFrameServiceImpl timeFrameService;

    @ShellMethod(key = "ui", value = "update instruments")
    public void updateInstruments(
            @ShellOption(value = {"-t", "--type"},
                    help = "data type to update (instruments [bond,etf,currency,stock])",
                    defaultValue = ShellOption.NULL)
                    String type)
            throws Exception {
        if (type != null) {
            String[] types = type.split(",");
            for (int i = 0; i < types.length; i++)
                dataService.updateInstruments(types[i]);
        } else
            dataService.updateInstruments();
    }

    @ShellMethod(key = "uc", value = "update candlesticks")
    public void updateCandlesticks(
            @ShellOption(value = {"-i", "--id"},
                    help = "instrument identifier (ticker or figi)")
                    String iIdentifier,
            @ShellOption(value = {"-t", "--tf"},
                    help = "timeframe (min1,min2,min5,min10,min15,min30,hour1,day1,week1,mon1)",
                    defaultValue = "day1")
                    String iTimeFrame,
            @ShellOption(value = {"-b", "--bp"},
                    help = "the beginning of period",
                    defaultValue = ShellOption.NULL)
                    String iBegPeriod,
            @ShellOption(value = {"-e", "--ep"},
                    help = "the end of period",
                    defaultValue = ShellOption.NULL)
                    String iEndPeriod) {
        ZonedDateTime begPeriod = iBegPeriod == null ?
                GlobalContext.BEG_DATE :
                DateTimeTools.parseDate(iBegPeriod);
        ZonedDateTime endPeriod = iEndPeriod == null ?
                ZonedDateTime.now().truncatedTo(ChronoUnit.DAYS).plusDays(1) :
                DateTimeTools.parseDate(iEndPeriod);
        if (begPeriod != null && endPeriod != null)
            DateTimeTools.checkInterval(begPeriod, endPeriod);
        String[] identifiers = iIdentifier.split(",");
        String[] timeframes = iTimeFrame.split(",");
        for (int i = 0; i < identifiers.length; i++) {
            for (int j = 0; j < timeframes.length; j++) {
                Instrument instrument = instrumentService.getByFigiOrTicker(identifiers[i]);
                if (instrument == null)
                    throw new IllegalArgumentException(String.format(
                            "Unknown instrument identifier: %s, try update instruments first (ui)", identifiers[i]));
                Timeframe timeframe = timeFrameService.getByCode(timeframes[j]);
                if (timeframe == null)
                    throw new IllegalArgumentException(String.format("Unsupported timeframe: %s", timeframes[j]));
                dataService.updateCandlesticks(instrument, timeframe, begPeriod, endPeriod);
            }
        }
    }
}

Демонстрация работы приложения

Дети, за главного остается телевизор. Ляжете спать, когда он скажет. (Гомер Симпсон, мультфильм «Симпсоны»)

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

Итоги

Никто из нас не умнее всех нас вместе. (Кен Бланшар – американский эксперт по менеджменту и автор книг)

Мы медленно, но верно движемся вперед. Разработали хранилище торговых данных, и инструменты для его наполнения, выявили существующие ограничения на загрузку и придумали пути для их обхода, а еще добавили интерфейс командной строки для управления приложением. Далее перейдем к разработке инструментария технического анализа и построению стратегий на основе полученной информации.

Ну а пока, можете скачивать исходные коды с GitHub, билдить приложение, загружать данные в базу для последующего анализа. Никаких настроек делать не потребуется, все работает, что называется, "из коробки", единствнное, чего я не могу сделать за вас – это получить токен от аккаунта ТИНЬКОФФ.

После публикации первой части ко мне вличку Хабра и в Телегу обратились несколько человек, готовых принять участие в разработке приложения или же обсудить идеи. Ребята, спасибо Вам, как только я дойду до торговых стратегий ваши мысли и практический опыт будут очень полезны, пишите, буду рад знакомству.

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

P.S: Забавно, но пока я писал эту статью, увидел новость о том, что со дня на день выйдут официальные сборки SDK для TINKOFF INVEST API v 2.0, где будет возможно использовать несколько тоговых счетов, торговать фьючерсами, и множество других плюшек. Учитывая архитектуру моего приложения, переключиться на новое API, полагаю, будет несложно. Об этом процессе я точно напишу в следующих публикациях.

Автор: Andrey

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js