Введение

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

Как же быстро летит время... Прошло почти 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, полагаю, будет несложно. Об этом процессе я точно напишу в следующих публикациях.

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


  1. AndreySSS
    05.02.2022 16:36
    +3

    Привет коллега. Тинькоф нам всем жизнь усложнил, только не понимаю, зачем и вы туда же. В данную секунду у меня на php крутится файлик, получая биржевые данные. Обновляю раз в 3 дня скачивая новые свечи для составляния определенной алгоритмической таблицы. Файлик содержит в себе менее 90 строк простого когда на процедурных функциях, точнее, одной. Умудряется писать обновляя и сразу в базу и в csv/txt файлы. Не чудо ли это? Наверное нет, потому что работает на API v1. А всю эту grpc v2 монстурозность, которую они понаплодили ради примитивных функций, вдруг я начинаю встречать и в других SDK. Вопрос лишь один.. зачем? Неужели так проще. У вас получилось хорошая техническая статья, словно вы запили новый Авито на минималках, вот только результат ради скачивания свечей уж больно похож на монстра. ЗЫ Сейчас сам пишу робота, стриминг принимаю и пока пытаюсь разобраться в куче оставленной после Гугла под названием protobuf. Но хорошо, что вы пишите о своих наработках, жаль я на яве не пишу.


    1. SkyZion Автор
      05.02.2022 16:41

      Коллега, приветствую! Спасибо за оценку! Я еще не ковырял v2, если честно. Мой любимый принцип в разработке - KISS (Keep it simple stupid). Не понимаю зачем усложнять... Что же касается "монструозности" моего проекта, это неокончательный вариант и служит он не только для скачивания данных (просто скачивание было в первой части). Идея запилить систему аналитики, а к ней уже прикрутить робота. Да и не ищу я святой грааль, если честно, не ставлю большие ставки на робота, скорее на долгосрочные инвестиции. К счастью, с финансовой частью мне помогает мой товарищ, который занимал заметные места на ЛЧИ. Паша, если ты читаешь этот коммент, привет тебе и слова благодарности :)


  1. tuxi
    05.02.2022 17:13
    +1

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

    Мне кажется, что спринг это как то очень overhead. Раньше все тоже самое на pure java было. Потом переписал ради фана и компактности. БД обычный mysql. БД может быть в принципе любой, она нужна в основном для быстрого холодного старта.

    Вот нету только торговых роботов. Не нужны просто.


    1. SkyZion Автор
      05.02.2022 17:18

      Основная задача - собрать аналитику и поделеиться с внешними сервисами (или пользователями), потому собственно, SPRING. Торговые роботы уже до кучи будут.


      1. tuxi
        05.02.2022 17:26

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

        Если сервер (и канал) будет мощный, можно попробовать таблицу обезличенных сделок получать. У меня пока не выходит, квик 9й версии просто умирает на интеле i7 с 32gb оперативки. Но это рабочий комп, и там еще есть нагрузка на систему. Надо попробовать запускать второй инстанс квика на отдельной железке.


        1. SkyZion Автор
          05.02.2022 17:38

          Спасибо за дельное замечание. Я не хочу связываться с квиком, а архитектура проекта построена таким образом, чтобы источник данных можно было подменить. Кроме того, приложение планирую разместить на хостинге, где скорее всего, не будет ни квика, ни винды.


        1. jvmdude
          05.02.2022 18:34
          +2

          >Наиболее правильно, с минимальными денежными затратами, брать данные через классический квик, и желательно чтобы брокер был не тинькофф.

          Наиболее правильно брать у ММВБ plaza 2 шлюз (4 тыр в месяц всего). Все эти брокеры для хомяков (Тинькоф, Алор) глючат день через день. Какие-то значимые суммы просто страшно заводить.


          1. SkyZion Автор
            05.02.2022 18:36

            Полностью согласен! После того, как проект перерастет стадию младенчества, именно так и сделаю. За консультатцией по плазе если что можно к Вам будет обратиться?


  1. NeverIn
    05.02.2022 17:27
    +1

    Отличный проект, жду развития. И да, уже видно, что он не только ради получения свечей как кто-то написал выше.


    1. SkyZion Автор
      05.02.2022 17:35
      +1

      Спасибо за оценку! Планов - громадьё.


  1. nick_rom0
    05.02.2022 21:25
    +1

    А под какой лицензией проект? Если лицензия не указана, то по-умолчанию вы считаетесь держателем копирайта и скачивать/менять/использовать без вашего явного разрешения ничего нельзя


    1. SkyZion Автор
      05.02.2022 21:33

      Ууууух.... Вы меня поставили в тупик! Я в вопросах лицензирования не очень... Но на словах могу сказать, что пользуйтесь без ограничений, хоть продавайте ????.


      1. nick_rom0
        06.02.2022 01:59
        +2

        Достаточно добавить в проект что-то из этого

        https://choosealicense.com


        1. SkyZion Автор
          06.02.2022 07:34
          +1

          Спасибо!


  1. Kroning
    05.02.2022 21:30
    +1

    У меня на прошлой версии были проблемы - та же история сделок отображалась с задержкой (несколько минут). Интересно, в этой что-то поменялось?


    1. HungryGoblin
      06.02.2022 01:29
      +1

      При подписке?


      1. Kroning
        06.02.2022 15:36

        О какой подписке идёт речь? Там просто rest api возвращает сделки за интервал.


  1. lieff
    05.02.2022 21:39

    А доступны ли у Тинькова не только котировки по акциям, но и фундаментальные даные типа p\e по апи?


    1. SkyZion Автор
      05.02.2022 21:43

      Нет, недоступны! Похоже, мы с Вами мыслим в одном направлении. У меня в планах собирать данные по отчётности и рассчитывать показатели.


      1. foxyrus
        06.02.2022 13:48
        +3

        Можно использовать (для зарубежных инструментов) бесплатный https://finnhub.io/


  1. ermak0ff
    07.02.2022 11:47
    +1

    Вы если что на видео токен спалили. При открытии редактирования конфигурации первую долю секунды отображается незаблюреный токент.


    1. SkyZion Автор
      07.02.2022 14:45

      Добрый день! Спасибо, я ждал этого вопроса. В студии ютюба заблюрил, но видео так и не обновилось, потому перед публикацией я просто перевыпустил токен. Ещё раз спасибо за бдительность.