TINKOFF-INVEST. Разработка торгового робота на JAVA. Часть 2
Введение
Второй шаг делается потому, что сделан первый; второй шаг делается ради третьего (Фэн Цзицай, из книги «Полет души»)
Как же быстро летит время… Прошло почти 2 месяца с момента публикации моей первой статьи о работе с TINKOFF INVEST API — Разработка торгового робота на JAVA. Часть 1, в которой мы начали свое знакомство с инструментарием автоматизации торговли, предоставляемым брокером ТИНЬКОФФ.
В этой части разработаем механизмы для загрузки и хранения биржевых исторических данных, а также рассмотрим некоторые ограничения, с которыми неминуемо столкнется каждый пользователь API и методами их преодоления.
Если нет желания вникать в код и читать статью, то можете сразу мотать к разделу «Демонстрация работы приложения».
Всем нам хочется мгновенного результата, но увы, жизнь устроена несколько сложнее, и как бы мне не хотелось перескочить к финалу и показать рабочую торговую стратегию, затратив на ее разработку минимум времени и сил, я не могу этого сделать, не выполнив подготовительных этапов. Одним из таких этапов является разработка инструментов загрузки и хранения исторических торговых данных. О том, какие инструменты я использовал, с какими трудностями столкнулся в ходе реализации и что получилось в итоге и пойдет речь.
Конфигурация
Стюардесса в салоне нового лайнера объявляет о то, что находится в самолете:
— На первой палубе — багаж, на второй — бар, на третьей — поле для гольфа, на четвертой бассейн.
И добавляет:
— А теперь, господа, пристегнитесь. Сейчас со всей этой фигней мы попробуем взлететь.
В компанию к описанным в первой части компонентам добавляются:
SPRING FRAMEWORK — фреймворк для построения web-приложений;
SPRING DATA — компонент для взаимодействия с БД;
FLYWAY — библиотека для контроля версий БД;
H2 — легковесная СУБД, разработнная на JAVA;
MODELMAPPER — библиотека для маппинга объектов;
SPRING SHELL — инструмент для создания CLI-интерфейса (командная строка).
Таким образом, файл зависимостей maven (pom.xml) приобретает следующий вид:
pom.xml
4.0.0
org.springframework.boot
spring-boot-starter-parent
2.5.7
ru.dsci
stockdock
0.0.1-SNAPSHOT
stockdock
stockdock
11
org.springframework.boot
spring-boot-starter-data-jpa
org.springframework.boot
spring-boot-starter
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-logging
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
ru.tinkoff.invest
openapi-java-sdk-core
0.5.1
ru.tinkoff.invest
openapi-java-sdk-java8
0.5.1
com.squareup.okhttp3
okhttp
4.10.0-RC1
com.h2database
h2
1.4.200
runtime
org.flywaydb
flyway-core
8.2.3
org.springframework.shell
spring-shell-starter
2.0.0.RELEASE
org.modelmapper
modelmapper
3.0.0
org.springframework.boot
spring-boot-maven-plugin
org.projectlombok
lombok
В качестве основы я выбрал его величество SPRING. На данном этапе я планирую использовать СУБД лишь для тестов, поэтому выбрал легковесную H2, более того, она будет использоваться в IN-MEMORY-режиме, а это значит, что база данных будет создаваться при запуске приложения, все данные хранится в оперативной памяти до останова программы. Структура базы данных будет инициализирована с помощью скриптов DDL (Data Definition Language), которые мигрируются в БД механизмами FLYWAY. При таком подходе заменить СУБД не составит труда на любом этапе разработки. SPRING SHELL поможет создать интерфейс командной строки для управления приложением из консоли.
Поговорим немного о настройках. Настроечные параметры среды разработки хранятся в файле application-dev.yml. Сервер приложения будет доступен на порту
application-dev.yml
server:
port: 8800
spring:
output:
ansi:
enabled: detect
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:mydatabase;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
username: sa
password:
jpa:
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
ddl-auto: none
h2:
console:
enabled: true
settings:
web-allow-others: false
path: /h2
logging:
level.ru.dsci.stockdock.* : debug
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread]: %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss.sss} [%thread] %-5level %logger{36}: %msg%n"
file:
name: logs/dev/stockdock.log
path: logs/dev
Структура БД
Есть только одно действительно неистощимое сокровище — это большая библиотека (Пьер Буаст — французский лексикограф)
Торговые стратегии строятся на основе анализа, для анализа необходимы данные, где их получить — понятно из контекста, где сохранить — тоже не должно вызывать вопросов, остался одна нерешенная задача — как хранить? И тут нет правильного ответа, каждый архитектор предложит свое решение.
Для хранения торговой информации я использую следующие таблицы:
instrument_type — тип инструмента (акции, облигации, фонды, валюты);
instrument — инструмент (описание конкретного инструмента);
timeframe — таймфрейм (минута, час, день, и т.д.);
candlestick — свеча (информации о котировках).
Для создания структуры БД я использовал скрипт (V1__init_tables.sql), написанный с помощью DDL операторов, после его загрузки в СУБД получим, готовую к работе базу. Полагаю, дополнительных пояснений здесь не требуется.
Для первоначального наполнения базы данных я также написал скрипт (V2__init_data.sql). С его помощью сохраним в базе данных доступные к загрузке инструменты и таймфреймы.
V1__init_tables.sql
-- INSTRUMENT_TYPE ----------------------------------------
drop table if exists insrtrument_type;
create table instrument_type
(
id serial primary key,
code varchar(256) not null,
name varchar(255)
);
create unique index instrument_type_code_uindex
on instrument_type(code);
-----------------------------------------------------------
-- INSTRUMENT ---------------------------------------------
drop table if exists instrument cascade;
create table instrument
(
id serial primary key,
figi varchar(255) not null,
isin varchar(255),
ticker varchar(255) not null,
currency varchar(255) not null,
increment numeric(19, 4),
name varchar(255),
CHECK (increment >= 0),
lot integer not null,
instrument_type_id varchar(255) not null,
foreign key (instrument_type_id) references instrument_type (id)
);
create unique index instrument_figi_uindex
on instrument (figi);
create index instrument_ticker_index
on instrument (ticker);
create index instrument_isin_index
on instrument(isin);
create index instrument_currency_index
on instrument (currency);
create index instrument_type_id_index
on instrument (instrument_type_id);
-----------------------------------------------------------
-- TIMEFRAME ----------------------------------------------
drop table if exists timeframe cascade;
create table timeframe
(
id serial primary key,
code varchar(64) not null,
name varchar(256)
);
create unique index timeframe_code_uindex
on timeframe(code);
-----------------------------------------------------------
-- CANDLESTICK --------------------------------------------
drop table if exists candlestick;
create table candlestick
(
id serial primary key,
maximum_value numeric(20, 10),
CHECK (maximum_value >= minimum_value),
minimum_value numeric(20, 10),
opened_value numeric(20, 10),
closed_value numeric(20, 10),
volume integer CHECK (volume >= 0),
since timestamp not null,
timeframe_id integer,
instrument_id integer,
foreign key (timeframe_id) references timeframe (id),
foreign key (instrument_id) references instrument (id)
);
create unique index candlestick_ticker_timeframe_since
on candlestick (instrument_id, timeframe_id, since);
-----------------------------------------------------------
V2__init_data.sql
INSERT INTO INSTRUMENT_TYPE (id, code, name)
VALUES (1, 'currency', 'валюта'),
(2, 'stock', 'акция'),
(3, 'bond', 'облигация'),
(4, 'etf', 'биржевой фонд');
INSERT INTO TIMEFRAME (id, code, name)
VALUES (1, 'MIN1', '1 минута'),
(2, 'MIN2', '2 минуты'),
(3, 'MIN5', '5 минут'),
(4, 'MIN10', '10 минут'),
(5, 'MIN15', '15 минут'),
(6, 'MIN30', '30 минут'),
(7, 'HOUR1', '1 час'),
(8, 'DAY1', 'день'),
(9, 'WEEK1', 'неделя'),
(10, 'MON1', 'месяц');
Сущности
Я построю свой луна-парк, с блекджеком и шлюхами! (робот Бендер, мультфильм «Футурама»)
Обычно в проектах SPRING для манипулирования данными используется ORM (Object Relation Model), данная технология позволяет представить строки таблиц БД и их реляционные связи в виде объектов (они же сущности, они же entity).
Для представления таблиц БД insrtrument_type, instrument, timeframe, candlestick я использовал классы InstrumentType, Instrument, Timeframe и Candlestick, соответственно. Эти классы, как и структура БД, не повторяют в точности классы, которые нам предоставляет Tinkoff Invest API, сделано так по ряду причин, основная — это желание не затачиваться на конкретную реализацию.
InstrumentType.java
package ru.dsci.stockdock.models.entities;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
@Data
public class InstrumentType {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Setter(value = AccessLevel.PRIVATE)
private Long id;
private String code;
private String name;
@Override
public String toString() {
return this.code;
}
}
Instrument.java
package ru.dsci.stockdock.models.entities;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;
import javax.persistence.*;
import java.math.BigDecimal;
@Entity
@Data
public class Instrument {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Setter(value = AccessLevel.PRIVATE)
private Long id;
private String figi;
private String isin;
private String ticker;
private String currency;
private String name;
private BigDecimal increment;
private int lot;
@ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.MERGE)
@JoinColumn(name = "instrument_type_id")
private InstrumentType instrumentType;
@Override
public String toString() {
return String.format("%s [%s] (%s)", this.ticker, this.figi, this.instrumentType.getCode());
}
}
Timeframe.java
package ru.dsci.stockdock.models.entities;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
@Data
public class Timeframe {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Setter(value = AccessLevel.PRIVATE)
private Long id;
private String code;
private String name;
@Override
public String toString() {
return this.code;
}
}
Candlestick.java
package ru.dsci.stockdock.models.entities;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;
import ru.dsci.stockdock.core.tools.DateTimeTools;
import javax.persistence.*;
import java.math.BigDecimal;
import java.time.ZonedDateTime;
@Entity
@Data
public class Candlestick {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Setter(value = AccessLevel.PRIVATE)
private Long id;
@ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.MERGE)
@JoinColumn(name = "timeframe_id")
private Timeframe timeframe;
@ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.MERGE)
@JoinColumn(name = "instrument_id")
private Instrument instrument;
private BigDecimal maximumValue;
private BigDecimal minimumValue;
private BigDecimal openedValue;
private BigDecimal closedValue;
private int volume;
private ZonedDateTime since;
@Override
public String toString() {
return String.format("%s [%s] %s: %.4f",
this.instrument.getTicker(),
this.timeframe.getCode(),
DateTimeTools.getTimeFormatted(this.since),
this.closedValue);
}
}
Осталось всего ничего — получить данные у ТИНЬКОФФ, преобразовать их к нашей структуре и сохранить.
Загрузка данных
Взять всё, да и поделить! (Шариков, повесть Михаила Афанасьевича Булгакова «Собачье Сердце»)
Как соединиться с источником данных посредством TINKOFF NVEST API я описывал в первой части, изменений не много, разве, что классы подключения к API (ApiConnector) и предоставления данных (ContextProvider) были переименованы в TcsApiConnector и TcsContextProvider, помимо этого в класс TcsContextProvider был добавлен метод getCandles, назначение которого — загрузка торговой информации по конкретному инструменту.
TcsApiConnector.java
package ru.dsci.stockdock.tcs;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import ru.dsci.stockdock.core.Parameters;
import ru.tinkoff.invest.openapi.OpenApi;
import ru.tinkoff.invest.openapi.model.rest.SandboxRegisterRequest;
import ru.tinkoff.invest.openapi.okhttp.OkHttpOpenApi;
@Slf4j
@Component
public class TcsApiConnector implements AutoCloseable {
private final Parameters parameters;
private OpenApi openApi;
public TcsApiConnector(Parameters parameters) {
this.parameters = parameters;
}
public OpenApi getOpenApi() throws Exception {
if (openApi == null) {
close();
openApi = new OkHttpOpenApi(parameters.getToken(), parameters.isSandBoxMode());
if (openApi.isSandboxMode()) {
openApi.getSandboxContext().performRegistration(new SandboxRegisterRequest()).join();
}
}
return openApi;
}
@Override
public void close() throws Exception {
if (openApi != null) {
openApi.close();
}
}
}
TcsContextProvider.java
package ru.dsci.stockdock.tcs;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Component;
import ru.dsci.stockdock.models.entities.Instrument;
import ru.dsci.stockdock.models.entities.Timeframe;
import ru.tinkoff.invest.openapi.OpenApi;
import ru.tinkoff.invest.openapi.model.rest.CandleResolution;
import ru.tinkoff.invest.openapi.model.rest.Candles;
import ru.tinkoff.invest.openapi.model.rest.MarketInstrumentList;
import java.time.ZonedDateTime;
import java.util.Optional;
@Slf4j
@Component
@AllArgsConstructor
public class TcsContextProvider {
private final TcsApiConnector tcsApiConnector;
private final ModelMapper modelMapper;
public MarketInstrumentList getStocks() throws Exception {
return getOpenApi().getMarketContext().getMarketStocks().join();
}
public MarketInstrumentList getBonds() throws Exception {
return getOpenApi().getMarketContext().getMarketBonds().join();
}
public MarketInstrumentList getEtfs() throws Exception {
return getOpenApi().getMarketContext().getMarketEtfs().join();
}
public MarketInstrumentList getCurrencies() throws Exception {
return getOpenApi().getMarketContext().getMarketCurrencies().join();
}
public Optional getCandles(
Instrument instrument, Timeframe timeframe, ZonedDateTime begPeriod, ZonedDateTime endPeriod)
throws Exception {
return getOpenApi().getMarketContext().getMarketCandles(
instrument.getFigi(),
begPeriod.toOffsetDateTime(),
endPeriod.toOffsetDateTime(),
modelMapper.map(timeframe, CandleResolution.class)).join();
}
private OpenApi getOpenApi() throws Exception {
return tcsApiConnector.getOpenApi();
}
}
Стоит заострить внимание на методе TINKOFF INVEST API getMarketCandles, он возвращает инстанс объекта класса Candles, из которого посредством метода getCandles можно получить коллекцию объектов класса Candle, она содержит необходимы нам свечи.
Candle.java
package ru.tinkoff.invest.openapi.model.rest;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Objects;
public class Candle {
@JsonProperty("figi")
private String figi = null;
@JsonProperty("interval")
private CandleResolution interval = null;
@JsonProperty("o")
private BigDecimal o = null;
@JsonProperty("c")
private BigDecimal c = null;
@JsonProperty("h")
private BigDecimal h = null;
@JsonProperty("l")
private BigDecimal l = null;
@JsonProperty("v")
private Integer v = null;
@JsonProperty("time")
private OffsetDateTime time = null;
public Candle() {
}
public Candle figi(String figi) {
this.figi = figi;
return this;
}
@Schema(
required = true,
description = ""
)
public String getFigi() {
return this.figi;
}
public void setFigi(String figi) {
this.figi = figi;
}
public Candle interval(CandleResolution interval) {
this.interval = interval;
return this;
}
@Schema(
required = true,
description = ""
)
public CandleResolution getInterval() {
return this.interval;
}
public void setInterval(CandleResolution interval) {
this.interval = interval;
}
public Candle o(BigDecimal o) {
this.o = o;
return this;
}
@Schema(
required = true,
description = ""
)
public BigDecimal getO() {
return this.o;
}
public void setO(BigDecimal o) {
this.o = o;
}
public Candle c(BigDecimal c) {
this.c = c;
return this;
}
@Schema(
required = true,
description = ""
)
public BigDecimal getC() {
return this.c;
}
public void setC(BigDecimal c) {
this.c = c;
}
public Candle h(BigDecimal h) {
this.h = h;
return this;
}
@Schema(
required = true,
description = ""
)
public BigDecimal getH() {
return this.h;
}
public void setH(BigDecimal h) {
this.h = h;
}
public Candle l(BigDecimal l) {
this.l = l;
return this;
}
@Schema(
required = true,
description = ""
)
public BigDecimal getL() {
return this.l;
}
public void setL(BigDecimal l) {
this.l = l;
}
public Candle v(Integer v) {
this.v = v;
return this;
}
@Schema(
required = true,
description = ""
)
public Integer getV() {
return this.v;
}
public void setV(Integer v) {
this.v = v;
}
public Candle time(OffsetDateTime time) {
this.time = time;
return this;
}
@Schema(
required = true,
description = "ISO8601"
)
public OffsetDateTime getTime() {
return this.time;
}
public void setTime(OffsetDateTime time) {
this.time = time;
}
public boolean equals(Object o) {
if (this == o) {
return true;
} else if (o != null && this.getClass() == o.getClass()) {
Candle candle = (Candle)o;
return Objects.equals(this.figi, candle.figi) && Objects.equals(this.interval, candle.interval) && Objects.equals(this.o, candle.o) && Objects.equals(this.c, candle.c) && Objects.equals(this.h, candle.h) && Objects.equals(this.l, candle.l) && Objects.equals(this.v, candle.v) && Objects.equals(this.time, candle.time);
} else {
return false;
}
}
public int hashCode() {
return Objects.hash(new Object[]{this.figi, this.interval, this.o, this.c, this.h, this.l, this.v, this.time});
}
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class Candle {\n");
sb.append(" figi: ").append(this.toIndentedString(this.figi)).append("\n");
sb.append(" interval: ").append(this.toIndentedString(this.interval)).append("\n");
sb.append(" o: ").append(this.toIndentedString(this.o)).append("\n");
sb.append(" c: ").append(this.toIndentedString(this.c)).append("\n");
sb.append(" h: ").append(this.toIndentedString(this.h)).append("\n");
sb.append(" l: ").append(this.toIndentedString(this.l)).append("\n");
sb.append(" v: ").append(this.toIndentedString(this.v)).append("\n");
sb.append(" time: ").append(this.toIndentedString(this.time)).append("\n");
sb.append("}");
return sb.toString();
}
private String toIndentedString(Object o) {
return o == null ? "null" : o.toString().replace("\n", "\n ");
}
}
Тиньковский класс Candle и мой Candlestick служат для хранения однотипной информации, но это совсем не одно и то же, соответственно стоит вопрос, как преобразовать Candle в Candlestick? Для этого и других конвертаций я использовал библиотеку ModelMapper. Правила преобразования сущностей определены в классе TcsModelMapper. Теперь необходимые мне преобразования можно выполнить путем вызова метода map (
TcsMapper.java
package ru.dsci.stockdock.tcs;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.modelmapper.Converter;
import org.modelmapper.ModelMapper;
import org.modelmapper.spi.MappingContext;
import org.springframework.stereotype.Component;
import ru.dsci.stockdock.models.entities.Candlestick;
import ru.dsci.stockdock.models.entities.Instrument;
import ru.dsci.stockdock.models.entities.Timeframe;
import ru.dsci.stockdock.services.impl.InstrumentServiceImpl;
import ru.dsci.stockdock.services.impl.InstrumentTypeServiceImpl;
import ru.dsci.stockdock.services.impl.TimeFrameServiceImpl;
import ru.tinkoff.invest.openapi.model.rest.Candle;
import ru.tinkoff.invest.openapi.model.rest.CandleResolution;
import ru.tinkoff.invest.openapi.model.rest.MarketInstrument;
import javax.annotation.PostConstruct;
@Component
@Data
@AllArgsConstructor
public class TcsMapper {
private final ModelMapper modelMapper;
private final InstrumentTypeServiceImpl instrumentTypeService;
private final InstrumentServiceImpl instrumentService;
private final TimeFrameServiceImpl timeFrameService;
@PostConstruct
public void init() {
modelMapper.createTypeMap(CandleResolution.class, Timeframe.class).setConverter(new Converter() {
@Override
public Timeframe convert(MappingContext mappingContext) {
String timeframeCode;
switch (mappingContext.getSource()) {
case _1MIN:
timeframeCode = "MIN1";
break;
case _2MIN:
timeframeCode = "MIN2";
break;
case _3MIN:
timeframeCode = "MIN3";
break;
case _5MIN:
timeframeCode = "MIN5";
break;
case _10MIN:
timeframeCode = "MIN10";
break;
case _15MIN:
timeframeCode = "MIN15";
break;
case _30MIN:
timeframeCode = "MIN30";
break;
case HOUR:
timeframeCode = "HOUR1";
break;
case DAY:
timeframeCode = "DAY1";
break;
case WEEK:
timeframeCode = "WEEK1";
break;
case MONTH:
timeframeCode = "MON1";
break;
default:
timeframeCode = null;
}
return timeFrameService.getByCode(timeframeCode);
}
});
modelMapper.createTypeMap(Timeframe.class, CandleResolution.class).setConverter(new Converter() {
@Override
public CandleResolution convert(MappingContext mappingContext) {
switch (mappingContext.getSource().getCode()) {
case "MIN1":
return CandleResolution._1MIN;
case "MIN2":
return CandleResolution._2MIN;
case "MIN3":
return CandleResolution._3MIN;
case "MIN5":
return CandleResolution._5MIN;
case "MIN10":
return CandleResolution._10MIN;
case "MIN15":
return CandleResolution._15MIN;
case "MIN30":
return CandleResolution._30MIN;
case "HOUR1":
return CandleResolution.HOUR;
case "DAY1":
return CandleResolution.DAY;
case "WEEK1":
return CandleResolution.WEEK;
case "MONTH1":
return CandleResolution.MONTH;
default:
return null;
}
}
});
modelMapper.createTypeMap(Candle.class, Candlestick.class).setConverter(new Converter() {
@Override
public Candlestick convert(MappingContext mappingContext) {
Candle candle = mappingContext.getSource();
Candlestick candlestick = new Candlestick();
candlestick.setTimeframe(modelMapper.map(candle.getInterval(), Timeframe.class));
candlestick.setInstrument(instrumentService.getByFigi(candle.getFigi()));
candlestick.setSince(candle.getTime().toZonedDateTime());
candlestick.setOpenedValue(candle.getO());
candlestick.setClosedValue(candle.getC());
candlestick.setMaximumValue(candle.getH());
candlestick.setMinimumValue(candle.getL());
candlestick.setVolume(candle.getV());
return candlestick;
}
});
modelMapper.createTypeMap(MarketInstrument.class, Instrument.class).setConverter(new Converter() {
@Override
public Instrument convert(MappingContext mappingContext) {
Instrument instrument = new Instrument();
MarketInstrument marketInstrument = mappingContext.getSource();
instrument.setCurrency(marketInstrument.getCurrency().getValue());
instrument.setTicker(marketInstrument.getTicker());
instrument.setFigi(marketInstrument.getFigi());
instrument.setIsin(marketInstrument.getIsin());
instrument.setName(marketInstrument.getName());
instrument.setLot(marketInstrument.getLot());
instrument.setIncrement(marketInstrument.getMinPriceIncrement());
instrument.setInstrumentType(instrumentTypeService.getByCode(marketInstrument.getType().toString()));
return instrument;
}
});
}
}
StockDockConfiguration.java
package ru.dsci.stockdock;
import org.modelmapper.ModelMapper;
import org.modelmapper.convention.MatchingStrategies;
import org.springframework.boot.ApplicationArguments;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import ru.dsci.stockdock.core.Parameters;
import static org.modelmapper.config.Configuration.AccessLevel.PRIVATE;
@Configuration
@ComponentScan
public class StockDockConfiguration {
@Bean
public ModelMapper modelMapper() {
ModelMapper modelMapper = new ModelMapper();
modelMapper.getConfiguration()
.setMatchingStrategy(MatchingStrategies.STRICT)
.setFieldMatchingEnabled(true)
.setSkipNullEnabled(true)
.setFieldAccessLevel(PRIVATE);
return modelMapper;
}
@Bean
public Parameters parameters(ApplicationArguments arguments) {
return new Parameters(arguments.getSourceArgs());
}
}
Итак, почти все готово, пробуем запросить минутные данные по акциям Сбера за 2022 год. И бЯда… бЯда… Получаем примерно такую ошибку ошибку:
2022-02-03 23:18:45 [main]: ru.tinkoff.invest.openapi.exceptions.OpenApiException: [to]: Bad candle interval: from=2021-12-31T21:00:00Z to=2022-02-03T21:00:00Z expected
from 1 minute to 1 day
Так в чем же дело? А вот в чем! Разработчики TINKOFF INVEST API позаботились о снижении нагрузки на свои серверы, как водится, усложнив жизнь своим клиентам.
Ограничения имеют следующий вид:
Тайм фрейм | Максимальный период |
1 минута | 1 день |
2 минты | 1 день |
3 минуты | 1 день |
5 минут | 1 день |
10 минут | 1 день |
15 минут | 1 день |
30 минут | 1 день |
1 час | 7 дней |
1 день | 1 год |
1 неделя | 2 года |
1 месяц | 10 лет |
И что же нам делать, если хочется получить минутные данные за месяц? Я для этих целей разработал инструмент, который разобьет период, превышающий установленный разработчиками, на более мелкие, затем запросить данные порциями.
Для описания интервалов служит класс TimeInterval, описание правил разбиения на периоды содержатся в классе TcsTools, методы для работы с датами и временем я определил в классе DateTimeTools.
TcsTools.java
package ru.dsci.stockdock.tcs;
import ru.dsci.stockdock.core.tools.DateTimeTools;
import ru.dsci.stockdock.core.tools.TimeInterval;
import ru.dsci.stockdock.models.entities.Timeframe;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
public class TcsTools {
public static List splitPeriod (
Timeframe timeFrame, ZonedDateTime begPeriod, ZonedDateTime endPeriod) {
ChronoUnit chronoUnit;
int chronoSize;
switch (timeFrame.getCode()) {
case "MON1":
chronoUnit = ChronoUnit.YEARS;
chronoSize = 10;
break;
case "WEEK1":
chronoUnit = ChronoUnit.YEARS;
chronoSize = 2;
break;
case "DAY1":
chronoUnit = ChronoUnit.YEARS;
chronoSize = 1;
break;
case "HOUR1":
chronoUnit = ChronoUnit.DAYS;
chronoSize = 7;
break;
default:
chronoUnit = ChronoUnit.DAYS;
chronoSize = 1;
}
return DateTimeTools.splitInterval(chronoUnit, chronoSize, begPeriod, endPeriod);
}
}
TimeInteerval.java
package ru.dsci.stockdock.core.tools;
import lombok.Data;
import lombok.NonNull;
import java.time.ZonedDateTime;
@Data
public class TimeInterval {
private ZonedDateTime begInterval;
private ZonedDateTime endInterval;
@Override
public String toString() {
return String.format("%s-%s",
DateTimeTools.getTimeFormatted(begInterval),
DateTimeTools.getTimeFormatted(endInterval));
}
public TimeInterval(@NonNull ZonedDateTime begInterval, @NonNull ZonedDateTime endInterval) {
DateTimeTools.checkInterval(begInterval, endInterval);
this.begInterval = begInterval;
this.endInterval = endInterval;
}
}
DateTimeTools.java
package ru.dsci.stockdock.core.tools;
import java.time.DateTimeException;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.chrono.ChronoZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
public class DateTimeTools {
public static ZoneId DEFAULT_ZONE_ID = ZoneId.of("Europe/Moscow");
public static ZoneId zoneId = DEFAULT_ZONE_ID;
public static final String DATE_PATTERN = "dd.MM.yyyy";
public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter
.ofPattern(DATE_PATTERN)
.withZone(zoneId);
public static final String DATE_TIME_PATTERN = "dd.MM.yyyy HH:mm:ss";
public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter
.ofPattern(DATE_TIME_PATTERN)
.withZone(zoneId);
public static String getTimeFormatted(ZonedDateTime dateTime) {
return dateTime.format(DATE_TIME_FORMATTER);
}
public static String getDateFormatted(ZonedDateTime dateTime) {
return dateTime.format(DATE_FORMATTER);
}
public static void checkInterval(ZonedDateTime begInterval, ZonedDateTime endInterval) {
if (endInterval.isBefore(begInterval))
throw new DateTimeException(String.format("Invalid date interval: %s - %s",
getTimeFormatted(begInterval),
getTimeFormatted(endInterval)));
}
public static List splitInterval(
ChronoUnit chronoUnit, int chronoSize, ZonedDateTime begInterval, ZonedDateTime endInterval) {
checkInterval(begInterval, endInterval);
if (chronoSize <= 0)
throw new DateTimeException(String.format("Invalid interval: %d", chronoSize));
List intervals = new ArrayList<>();
ChronoZonedDateTime[] periodTmp = new ChronoZonedDateTime[2];
periodTmp[0] = begInterval;
while (periodTmp[0].isBefore(endInterval)) {
periodTmp[1] = periodTmp[0].plus(chronoSize, chronoUnit);
if (periodTmp[1].isAfter(endInterval))
periodTmp[1] = endInterval;
intervals.add(new TimeInterval((ZonedDateTime) periodTmp[0], (ZonedDateTime) periodTmp[1]));
periodTmp[0] = periodTmp[0]
.plus(chronoSize, chronoUnit)
.plus(1, ChronoUnit.MICROS);
}
return intervals;
}
public static ZonedDateTime parseDate(String textDate) {
ZonedDateTime date;
try {
String[] dateArray = textDate.split("\\.");
if (dateArray.length < 3)
throw new IllegalArgumentException(String.format("Incorrect date: %s", textDate));
int day = Integer.parseInt(dateArray[0]);
int month = Integer.parseInt(dateArray[1]);
int year = Integer.parseInt(dateArray[2]);
date = ZonedDateTime.of(year, month, day, 0, 0, 0, 0, DEFAULT_ZONE_ID);
} catch (Throwable e) {
throw new DateTimeException(
String.format("Can't parse '%s' into date: %s", textDate, e.getMessage()));
}
return date;
}
}
Работа с базой данных
Искусство революции простое. Главное — занять и ценой каких угодно потерь удержать — телефон, телеграф, железнодорожный станции и мосты. (Владимир Ильич Ленин, в представлении не нуждается)
Для загрузки / выгрузки данных я использовал механизмы SPRING DATA. Все по классике — репозиторий (слой доступа к данным), сервис (слой бизнес-логики), сущность (представляет связанные реляционно записи базы данных в виде объекта).
репозиторий | сервис | сущность |
InstrumentTypeRepository | InstrumentTypeService | InstrumentType |
InstrumentRepository | InstrumentService | Instrument |
TimeframeRepository | TimeframeService | Timeframe |
CandlestickRepository | CandlestickService | Candlestick |
Углубляться в описание кода не вижу смысла, как мне кажется, тут все просто.
InstrumentTypeRepository.java
package ru.dsci.stockdock.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.dsci.stockdock.models.entities.InstrumentType;
@Repository
public interface InstrumentTypeRepository extends JpaRepository {
InstrumentType findByCodeIgnoreCase(String code);
}
InstrumentTypeServiceImpl.java
package ru.dsci.stockdock.services.impl;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import ru.dsci.stockdock.exceptions.EntityNotFoundException;
import ru.dsci.stockdock.models.entities.InstrumentType;
import ru.dsci.stockdock.repositories.InstrumentTypeRepository;
import ru.dsci.stockdock.services.InstrumentTypeService;
import java.util.List;
@Service
@AllArgsConstructor
public class InstrumentTypeServiceImpl implements InstrumentTypeService {
private InstrumentTypeRepository instrumentTypeRepository;
@Override
public List getAll() {
return instrumentTypeRepository.findAll();
}
@Override
public InstrumentType getById(Long id) {
return instrumentTypeRepository
.findById(id)
.orElseThrow(() -> new EntityNotFoundException(InstrumentType.class, id));
}
@Override
public InstrumentType getByCode(String code) {
return instrumentTypeRepository.findByCodeIgnoreCase(code);
}
}
InstrumentRepository.java
package ru.dsci.stockdock.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.dsci.stockdock.models.entities.Instrument;
@Repository
public interface InstrumentRepository extends JpaRepository {
Instrument findByFigiIgnoreCase(String figi);
Instrument findByTickerIgnoreCase(String ticker);
Instrument findByFigiIgnoreCaseOrTickerIgnoreCase(String figi, String ticker);
boolean existsInstrumentByFigiIgnoreCase(String figi);
}
InstrumentServiceImpl.java
package ru.dsci.stockdock.services.impl;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import ru.dsci.stockdock.exceptions.EntityNotFoundException;
import ru.dsci.stockdock.models.entities.Instrument;
import ru.dsci.stockdock.repositories.InstrumentRepository;
import ru.dsci.stockdock.services.InstrumentService;
import javax.transaction.Transactional;
import java.util.List;
@Service
@AllArgsConstructor
public class InstrumentServiceImpl implements InstrumentService {
private InstrumentRepository instrumentRepository;
@Override
public List getAll() {
return instrumentRepository.findAll();
}
@Override
public Instrument getById(Long id) {
return instrumentRepository
.findById(id)
.orElseThrow(() -> new EntityNotFoundException(Instrument.class, id));
}
@Override
public Instrument getByTicker(String ticker) {
return instrumentRepository.findByTickerIgnoreCase(ticker);
}
@Override
public Instrument getByFigiOrTicker(String identifier) {
return instrumentRepository.findByFigiIgnoreCaseOrTickerIgnoreCase(identifier, identifier);
}
@Override
public Instrument getByFigi(String isin) {
return instrumentRepository.findByFigiIgnoreCase(isin);
}
@Override
public void saveAllIfNotExists(List instruments) {
instruments.forEach(this::saveIfNotExists);
}
@Override
@Transactional
public void saveIfNotExists(Instrument instrument) {
if (!instrumentRepository.existsInstrumentByFigiIgnoreCase(instrument.getFigi()))
instrumentRepository.saveAndFlush(instrument);
}
}
TimeframeRepository.java
package ru.dsci.stockdock.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.dsci.stockdock.models.entities.Timeframe;
@Repository
public interface TimeframeRepository extends JpaRepository {
Timeframe findByCodeIgnoreCase(String code);
}
TimeframeServiceImpl.java
package ru.dsci.stockdock.services.impl;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import ru.dsci.stockdock.exceptions.EntityNotFoundException;
import ru.dsci.stockdock.models.entities.Timeframe;
import ru.dsci.stockdock.repositories.TimeframeRepository;
import ru.dsci.stockdock.services.TimeFrameService;
import java.util.List;
@Service
@AllArgsConstructor
public class TimeFrameServiceImpl implements TimeFrameService {
private TimeframeRepository periodRepository;
@Override
public List getAll() {
return periodRepository.findAll();
}
@Override
public Timeframe getById(Long id) {
return periodRepository
.findById(id)
.orElseThrow(() -> new EntityNotFoundException(Timeframe.class, id));
}
@Override
public Timeframe getByCode(String code) {
return periodRepository.findByCodeIgnoreCase(code);
}
}
CandlestickRepository.java
package ru.dsci.stockdock.repositories;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.dsci.stockdock.models.entities.Candlestick;
import ru.dsci.stockdock.models.entities.Instrument;
import ru.dsci.stockdock.models.entities.Timeframe;
import java.time.ZonedDateTime;
import java.util.List;
@Repository
public interface CandlestickRepository extends JpaRepository {
List getCandlestickByInstrumentAndTimeframeAndSinceBetweenOrderBySince(
Instrument instrument, Timeframe timeframe, ZonedDateTime begPeriod, ZonedDateTime endPeriod);
boolean existsByInstrumentAndTimeframeAndSince(Instrument instrument, Timeframe timeframe, ZonedDateTime since);
}
CandlestickServiceImpl.java
package ru.dsci.stockdock.services.impl;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import ru.dsci.stockdock.core.GlobalContext;
import ru.dsci.stockdock.core.tools.DateTimeTools;
import ru.dsci.stockdock.models.entities.Candlestick;
import ru.dsci.stockdock.models.entities.Instrument;
import ru.dsci.stockdock.models.entities.Timeframe;
import ru.dsci.stockdock.repositories.CandlestickRepository;
import ru.dsci.stockdock.services.CandlestickService;
import javax.transaction.Transactional;
import java.time.ZonedDateTime;
import java.util.List;
@Service
@AllArgsConstructor
public class CandlestickServiceImpl implements CandlestickService {
private CandlestickRepository candlestickRepository;
@Override
public Candlestick getById(Long id) {
return candlestickRepository.getById(id);
}
@Override
public List getCandlesticks(Instrument instrument, Timeframe timeframe) {
return getCandlesticks(instrument, timeframe, null, null);
}
@Override
public List getCandlesticks(Instrument instrument, Timeframe timeframe, ZonedDateTime begPeriod) {
return getCandlesticks(instrument, timeframe, begPeriod);
}
@Override
public List getCandlesticks(
Instrument instrument, Timeframe timeframe, ZonedDateTime begPeriod, ZonedDateTime endPeriod) {
if (begPeriod == null)
begPeriod = GlobalContext.BEG_DATE;
if (endPeriod == null)
endPeriod = ZonedDateTime.now();
DateTimeTools.checkInterval(begPeriod, endPeriod);
return candlestickRepository.getCandlestickByInstrumentAndTimeframeAndSinceBetweenOrderBySince(
instrument, timeframe, begPeriod, endPeriod);
}
@Override
@Transactional
public void saveAllIfNotExists(Candlestick candlestick) {
if (!candlestickRepository.existsByInstrumentAndTimeframeAndSince(
candlestick.getInstrument(), candlestick.getTimeframe(), candlestick.getSince()))
candlestickRepository.saveAndFlush(candlestick);
}
}
Entites описаны в разделе сущности.
Интерфейс командной строки
Я вам помогу, ребята. Я буду командовать! (Кар Карыч, мультфильм «Смешарики»)
Сравнительно недавно открыл для себя замечательную библиотеку SPRING SHELL, в чем смысл ее использования? Допустим, разработали мы приложение на SPRING, а дальше? Как управлять его поведением? Как тестировать? Дергать контроллеры через web-интерфейс? Использовать Postman? А что если просто запускать методы сервисов из консоли, подобно тому, как мы работаем с текстовыми интерфейсами в посведневной жизни? Здорово? На мой взгляд, да! Кто-то возразит мне, мол, вот еще, это же дополнительные временные издержки для разработки CLI, и отчасти будут правы. Но что если разработка интеофейса командной строки сведется лишь к написанию аннотаций к существующим методам? С помощью SPRING SHELL можно просто прононотировать методы соответствующим образом, после запуска приложения они будут доступны для вызова из командной строки.
Пока что я ограничился двумя командами:
ui (update instruments) — обновляет интрументы (загружает из TINKOFF INVEST API пул инструментов и сохраняет в нашу базу данных если инструмент отсутствует).
использование:
ui [[-t][
], где где: type_list — список типов (etf, bond, stock, currency) необязательный параметр, разделитель запятая, список заполнятся без пробелов;
-t — необязательный ключ, после которого указывается список инструментов.
примеры:
uc (update candlesticks) — обновляет тороговую информацию (загружает из TINKOFF INVEST API датасет с данными по указанному инструменту, затем сохраняет их в базу данных если данные отсутствуют).
использование:
uc [-i]
[-t] [[-b] ] [[-e] ], где: identifier — идентификатор инструмента (ticker или figi), можно указывать список (разделитель запятая, без пробелов);
timeframe — идентификатор таймфрейма (min1, min2, min5, min10, min15, min30, hour1, day1, week1, mon1), можно указывать список (разделитель запятая, без пробелов);
beg_period — начало периода запроса данных, параметр необязательный, по умолчанию указывается значение, установленное в GlobalContext.BEG_DATE (01.01.2020);
end_period — окончание периода запроса данных, параметр необязательные, по умолчанию указывается текущее время
-i, -t, -b, -e — необязательные ключи для идентификатора, таймфрейма, начала периода и окончения периода, соответственно, их можно указывать если есть необходимость переопределения последовательности параметров.
примеры:
uc tatn, luk day1, hour1 01.01.2020 31.12.2021 — для эмитентов Татнефть и Лукоил обновить дневные и часовые данные за 2021 и 2022 годы;
uc tatn hour1 01.01.2022 — обновить часовые данные по эмитенту Татнефть с 01.01.2022 — по настоящее время.
Также имеются встроенные команды:
help [
] — вызов справки [справки по команде]; stacktrace — вывод на экран стектрейса по последней ошибке;
clear — очищает консоль;
exit, quit — закрывает shell;
script — запускает скрипт из текстового файла.
Результаты выполнения команды help ниже:
help
Built-In Commands
clear: Clear the shell screen.
exit, quit: Exit the shell.
help: Display help about available commands.
script: Read and execute commands from a file.
stacktrace: Display the full stacktrace of the last error.
Cli Processor
uc: update candlesticks
ui: update instruments
help ui
NAME
ui — update instruments
SYNOPSYS
ui [[-t] string]
OPTIONS
-t or --type string
data type to update (instruments [bond, etf, currency, stock])
[Optional, default = ]
help uc
SYNOPSYS
uc [-i] string [[-t] string] [[-b] string] [[-e] string]
OPTIONS
-i or --id string
instrument identifier (ticker or figi)
[Mandatory]
-t or --tf string timeframe (min1, min2, min5, min10, min15, min30, hour1, day1, week1, mon1) [Optional, default = day1]
-b or --bp string the beginning of period [Optional, default =
-e or --ep string the end of period [Optional, default =
Команды текстового интерфейса я вынес в отдельный файл CliProcessor, хотя это и не обязательно, повторюсь, достаточно просто указать соответствующие нотации для существующих методов, SPRING SHELL соберет все воедино без вашего участия.
CliProcessor.java
package ru.dsci.stockdock.core;
import lombok.AllA