diff --git a/README.md b/README.md
index dbe4cef..1590cc8 100644
--- a/README.md
+++ b/README.md
@@ -85,7 +85,7 @@
3. Добавить уникальное имя стратегии, чтобы выбирать её через UI.
Примеры
-стратегий: [на стакане](https://github.com/UncleSema/ttb/blob/main/src/main/java/ru/unclesema/ttb/strategy/orderbook/OrderBookStrategyImpl.java)
+стратегий: [на стакане](https://github.com/UncleSema/ttb/blob/main/src/main/java/ru/unclesema/ttb/strategy/orderbook/AsksBidsStrategy.java)
,
[на свечах](https://github.com/UncleSema/ttb/blob/main/src/main/java/ru/unclesema/ttb/strategy/rsi/RsiStrategy.java).
diff --git a/src/main/java/ru/unclesema/ttb/ApplicationModule.java b/src/main/java/ru/unclesema/ttb/ApplicationModule.java
index 3b77388..2a9c58c 100644
--- a/src/main/java/ru/unclesema/ttb/ApplicationModule.java
+++ b/src/main/java/ru/unclesema/ttb/ApplicationModule.java
@@ -9,7 +9,7 @@
import org.springframework.stereotype.Component;
import ru.unclesema.ttb.strategy.CandleStrategy;
import ru.unclesema.ttb.strategy.Strategy;
-import ru.unclesema.ttb.strategy.orderbook.OrderBookStrategyImpl;
+import ru.unclesema.ttb.strategy.orderbook.AsksBidsStrategy;
import ru.unclesema.ttb.strategy.rsi.RsiStrategy;
import java.util.List;
@@ -30,7 +30,7 @@ public Object deserializeKey(String key, DeserializationContext ctxt) {
@Bean
public List availableStrategies() {
- return List.of(new OrderBookStrategyImpl(), new RsiStrategy());
+ return List.of(new AsksBidsStrategy(), new RsiStrategy());
}
@Bean
diff --git a/src/main/java/ru/unclesema/ttb/client/InvestClient.java b/src/main/java/ru/unclesema/ttb/client/InvestClient.java
index dd34608..504cec3 100644
--- a/src/main/java/ru/unclesema/ttb/client/InvestClient.java
+++ b/src/main/java/ru/unclesema/ttb/client/InvestClient.java
@@ -11,7 +11,7 @@
import ru.unclesema.ttb.MarketSubscriber;
import ru.unclesema.ttb.model.User;
import ru.unclesema.ttb.model.UserMode;
-import ru.unclesema.ttb.service.AnalyzeService;
+import ru.unclesema.ttb.service.analyze.AnalyzeService;
import ru.unclesema.ttb.utility.Utility;
import java.math.BigDecimal;
diff --git a/src/main/java/ru/unclesema/ttb/config/ExceptionResolver.java b/src/main/java/ru/unclesema/ttb/config/ExceptionResolver.java
new file mode 100644
index 0000000..0b95fb4
--- /dev/null
+++ b/src/main/java/ru/unclesema/ttb/config/ExceptionResolver.java
@@ -0,0 +1,17 @@
+package ru.unclesema.ttb.config;
+
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerExceptionResolver;
+import org.springframework.web.servlet.ModelAndView;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.Map;
+
+@Component
+public class ExceptionResolver implements HandlerExceptionResolver {
+ @Override
+ public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+ return new ModelAndView("error-page", Map.of("msg", ex.getMessage()));
+ }
+}
diff --git a/src/main/java/ru/unclesema/ttb/controller/ApplicationController.java b/src/main/java/ru/unclesema/ttb/controller/ApplicationController.java
index 1ae3232..e800264 100644
--- a/src/main/java/ru/unclesema/ttb/controller/ApplicationController.java
+++ b/src/main/java/ru/unclesema/ttb/controller/ApplicationController.java
@@ -2,7 +2,6 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
-import org.springframework.ui.Model;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -69,10 +68,4 @@ public String sellAll(String accountId) {
service.sellAll(accountId);
return "redirect:/" + accountId;
}
-
- @GetMapping("/app-error")
- public String exceptionHandler(Model model, Exception e) {
- model.addAttribute("msg", e.getMessage());
- return "error-page";
- }
}
diff --git a/src/main/java/ru/unclesema/ttb/service/ApplicationService.java b/src/main/java/ru/unclesema/ttb/service/ApplicationService.java
index f647e52..fece429 100644
--- a/src/main/java/ru/unclesema/ttb/service/ApplicationService.java
+++ b/src/main/java/ru/unclesema/ttb/service/ApplicationService.java
@@ -1,46 +1,18 @@
package ru.unclesema.ttb.service;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-import ru.tinkoff.piapi.contract.v1.*;
-import ru.tinkoff.piapi.core.exception.ApiRuntimeException;
-import ru.unclesema.ttb.MarketSubscriber;
-import ru.unclesema.ttb.client.InvestClient;
+import ru.tinkoff.piapi.contract.v1.Candle;
+import ru.tinkoff.piapi.contract.v1.CandleInterval;
+import ru.tinkoff.piapi.contract.v1.LastPrice;
+import ru.tinkoff.piapi.contract.v1.OrderBook;
import ru.unclesema.ttb.model.NewUserRequest;
import ru.unclesema.ttb.model.User;
-import ru.unclesema.ttb.model.UserMode;
-import ru.unclesema.ttb.strategy.CandleStrategy;
-import ru.unclesema.ttb.strategy.OrderBookStrategy;
-import ru.unclesema.ttb.strategy.Strategy;
-import ru.unclesema.ttb.strategy.StrategyDecision;
-import ru.unclesema.ttb.utility.Utility;
-import java.math.BigDecimal;
import java.time.Instant;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-
-@RequiredArgsConstructor
-@Service
-@Slf4j
-public class ApplicationService {
- private final InvestClient investClient;
- private final UserService userService;
- private final PriceService priceService;
- private final AnalyzeService analyzeService;
- private final List availableStrategies;
- private final ObjectMapper objectMapper;
-
- private static final long OPERATIONS_PERIOD_SECONDS = 30;
- private final Map orderBookSubscriberByUser = new HashMap<>();
- private final Map candlesSubscriberByUser = new HashMap<>();
- private final Map lastPricesSubscriberByUser = new HashMap<>();
- private final Map lastBoughtByUser = new HashMap<>();
+/**
+ * Основной сервис приложения, который отвечает за добавление новых пользователей, создание новых заявок, удаление существующих
+ */
+public interface ApplicationService {
/**
* Метод обрабатывает запрос о новом пользователе, обрабатывая ошибки:
*
@@ -52,139 +24,22 @@ public class ApplicationService {
*
Ошибки обращений к API
*
*/
- public User addNewUser(NewUserRequest request) {
- var strategyParameters = request.getStrategyParameters();
- if (!strategyParameters.containsKey("name")) {
- throw new IllegalArgumentException("Пропущено имя стратегии");
- }
- var name = strategyParameters.get("name");
- strategyParameters.remove("name");
- var strategyStream = availableStrategies.stream().filter(s -> s.getName().equals(name));
- if (strategyStream.count() > 1) {
- throw new IllegalStateException("Найдено несколько стратегий с именем `" + name + "`");
- }
- var strategyOptional = availableStrategies.stream().filter(s -> s.getName().equals(name)).findAny();
- if (strategyOptional.isEmpty()) {
- throw new IllegalArgumentException("Стратегия `" + name + "` не найдена");
- }
- if (request.getToken() == null || request.getToken().isBlank()) {
- throw new IllegalArgumentException("Токен пользователя не может быть пустым");
- }
- var strategyClazz = strategyOptional.get().getClass();
- var strategy = objectMapper.convertValue(strategyParameters, strategyClazz);
- var figis = request.getFigis().stream()
- .distinct()
- .filter(figi -> !figi.isBlank())
- .toList();
- if (request.getMode() == UserMode.MARKET) {
- if (request.getAccountId() == null || request.getAccountId().isBlank()) {
- throw new IllegalArgumentException("Для режима реальной торговли необходимо указать accountId");
- }
- if (userService.contains(request.getAccountId())) {
- throw new IllegalArgumentException("Пользователь с accountId = " + request.getAccountId() + " уже существует");
- }
- }
- try {
- var accountId = investClient.addUser(request.getToken(), request.getAccountId(), request.getMode());
- var user = new User(request.getToken(), request.getMode(), request.getMaxBalance(), accountId, figis, strategy);
- userService.addUser(user);
- return user;
- } catch (ApiRuntimeException e) {
- if (Utility.checkExceptionCode(e, "70001")) {
- log.error("Внутренняя ошибка Invest Api. Вы правильно указали токен?");
- } else {
- log.error("Неизвестная ошибка при создании пользователя", e);
- }
- throw e;
- }
- }
+ User addNewUser(NewUserRequest request);
/**
* Метод добавляет информацию о новом стакане, о чём оповещает все стратегии, использующие стаканы.
*/
- public void addOrderBook(OrderBook orderBook) {
- for (User user : userService.getActiveOrderBookUsers()) {
- OrderBookStrategy strategy = (OrderBookStrategy) user.strategy();
- String figi = orderBook.getFigi();
- StrategyDecision decision = strategy.addOrderBook(orderBook);
- BigDecimal lastPrice = priceService.getLastPrice(user, figi);
- if (canMakeOperationNow(user, lastPrice, figi)) {
- processStrategyDecision(user, figi, Instant.now(), lastPrice, decision);
- }
- }
- }
+ void addOrderBook(OrderBook orderBook);
/**
* Метод добавляет информацию о новой свече, о чём оповещает все стратегии, использующие свечи.
*/
- public void addCandle(Candle candle) {
- for (User user : userService.getActiveCandleUsers()) {
- CandleStrategy strategy = (CandleStrategy) user.strategy();
- String figi = candle.getFigi();
- StrategyDecision decision = strategy.addCandle(candle);
- BigDecimal lastPrice = priceService.getLastPrice(user, figi);
- if (canMakeOperationNow(user, lastPrice, figi)) {
- processStrategyDecision(user, figi, Instant.now(), lastPrice, decision);
- }
- }
- }
+ void addCandle(Candle candle);
/**
* Метод симулирует работу стратегию на заданном временном промежутке.
*/
- public void simulate(User user, Instant from, Instant to, CandleInterval interval) {
- log.info("Запрос просимулировать стратегию для {} с {} по {} с интервалом {}", user, from, to, interval);
- if (user.mode() != UserMode.ANALYZE) {
- throw new IllegalArgumentException("Используйте режим ANALYZE, чтобы симулировать работу стратегии");
- }
- if (!(user.strategy() instanceof CandleStrategy strategy)) {
- throw new UnsupportedOperationException("Чтобы просимулировать на исторических данных работу стратегии, она должна реализовывать CandleStrategy интерфейс");
- }
- var candles = investClient.getCandles(user, from, to, interval).join();
- for (var candle : candles) {
- var decision = strategy.addCandle(candle);
- var figi = candle.getFigi();
- var price = Utility.toBigDecimal(candle.getClose());
- var time = Utility.toInstant(candle.getTime());
- analyzeService.processNewCandle(user, candle);
- priceService.checkStopRequests(Utility.toLastPrice(candle));
- if (canMakeOperationNow(user, time, price, figi)) {
- processStrategyDecision(user, figi, time, Utility.toBigDecimal(candle.getClose()), decision);
- }
- }
- log.info("Симуляция для {} завершена", user);
- priceService.deleteRequestsForUser(user);
- }
-
- /**
- * Метод обрабатывает решение стратегии, в зависимости от которого покупает / продает выбранную бумагу; выставляет takeProfit, stopLoss.
- */
- private void processStrategyDecision(User user, String figi, Instant now, BigDecimal currentPrice, StrategyDecision decision) {
- if (decision == StrategyDecision.NOTHING) return;
- boolean isBuy = decision == StrategyDecision.BUY;
- BigDecimal profit = BigDecimal.valueOf(1 + (isBuy ? 1 : -1) * user.strategy().getTakeProfit() / 100);
- BigDecimal loss = BigDecimal.valueOf(1 - (isBuy ? 1 : -1) * user.strategy().getStopLoss() / 100);
- BigDecimal takeProfit = currentPrice.multiply(profit);
- BigDecimal stopLoss = currentPrice.multiply(loss);
- Instrument instrument = investClient.getInstrument(user, figi);
- if (isBuy) {
- log.info("Стратегия собирается пойти в лонг по бумаге с figi {}.\nЦена покупки: {}.\nTakeProfit: {}.\nStopLoss: {}",
- figi, currentPrice, takeProfit, stopLoss);
- var response = investClient.buyMarket(user, figi, currentPrice).join();
- if (response != null) {
- priceService.processNewOperation(user, figi, takeProfit, currentPrice, stopLoss, OrderDirection.ORDER_DIRECTION_BUY);
- lastBoughtByUser.put(user, now);
- }
- } else if (instrument.getShortEnabledFlag()) {
- log.info("Стратегия собирается пойти в шорт по бумаге с figi {}.\nЦена покупки: {}.\nTakeProfit: {}.\nStopLoss: {}",
- figi, currentPrice, takeProfit, stopLoss);
- var response = investClient.sellMarket(user, figi, currentPrice).join();
- if (response != null) {
- priceService.processNewOperation(user, figi, takeProfit, currentPrice, stopLoss, OrderDirection.ORDER_DIRECTION_SELL);
- lastBoughtByUser.put(user, now);
- }
- }
- }
+ void simulate(User user, Instant from, Instant to, CandleInterval interval);
/**
* Подписывает пользователя на:
@@ -194,114 +49,20 @@ private void processStrategyDecision(User user, String figi, Instant now, BigDec
*
Последние свечи, если стратегия использует свечи
*
*/
- public void enableStrategyForUser(String accountId) {
- log.info("Запрос на включение стратегии от пользователя с accountId = {}", accountId);
- Optional optionalUser = userService.getAllUsers().stream().filter(u -> u.accountId().equals(accountId)).findAny();
- if (optionalUser.isEmpty()) {
- throw new IllegalArgumentException("Не получилось найти пользователя с accountId = " + accountId);
- }
- User user = optionalUser.get();
- if (user.mode() == UserMode.ANALYZE) {
- throw new IllegalArgumentException("В режиме анализа нельзя подписаться на стакан, свечи и последние цены");
- }
- MarketSubscriber subscriber = new MarketSubscriber(this);
- if (user.strategy() instanceof OrderBookStrategy) {
- orderBookSubscriberByUser.computeIfAbsent(user, u -> {
- investClient.subscribe(user, subscriber, InvestClient.InstrumentType.ORDER_BOOK);
- return subscriber;
- });
- }
- if (user.strategy() instanceof CandleStrategy) {
- candlesSubscriberByUser.computeIfAbsent(user, u -> {
- investClient.subscribe(user, subscriber, InvestClient.InstrumentType.CANDLE);
- return subscriber;
- });
- }
- userService.makeUserActive(user);
- lastPricesSubscriberByUser.computeIfAbsent(user, u -> {
- investClient.subscribe(user, subscriber, InvestClient.InstrumentType.LAST_PRICE);
- return subscriber;
- });
- }
+ void enableStrategyForUser(String accountId);
/**
* Метод отменяет все подписки пользователя, делает его `неактивным`.
*/
- public void disableStrategyForUser(String accountId) {
- log.info("Запрос на выключение стратегии от пользователя с accountId = {}", accountId);
- var user = userService.findUserByAccountId(accountId);
- if (orderBookSubscriberByUser.containsKey(user)) {
- investClient.unsubscribe(user, InvestClient.InstrumentType.ORDER_BOOK);
- orderBookSubscriberByUser.remove(user);
- }
- if (candlesSubscriberByUser.containsKey(user)) {
- investClient.unsubscribe(user, InvestClient.InstrumentType.CANDLE);
- candlesSubscriberByUser.remove(user);
- }
- if (lastPricesSubscriberByUser.containsKey(user)) {
- investClient.unsubscribe(user, InvestClient.InstrumentType.LAST_PRICE);
- lastPricesSubscriberByUser.remove(user);
- }
- userService.makeUserInactive(user);
- }
+ void disableStrategyForUser(String accountId);
/**
* Добавление последней цены
*/
- public void addLastPrice(LastPrice lastPrice) {
- priceService.addLastPrice(lastPrice);
- }
-
- /**
- * Проверка на то, что стратегия заданного пользователя может провести операцию по заданной бумаге.
- *
- *
- * На данный момент есть два критерия возможности проведения операции для пользователя:
- *
- *
Время, т.е. пользователь может совершать операции не чаще чем раз в OPERATIONS_PERIOD_SECONDS
- *
Бюджет пользователя: стратегия не может превысить выставленный пользователем лимит
- *
- *
- */
- private boolean canMakeOperationNow(User user, Instant now, BigDecimal price, String figi) {
- var alreadySpent = priceService.getBalance(user);
- var instrument = investClient.getInstrument(user, figi);
- var priceInRubles = priceService.getPriceInRubles(user, price, figi).multiply(BigDecimal.valueOf(instrument.getLot()));
- var newBalance = alreadySpent.add(priceInRubles);
- if (newBalance.compareTo(user.maxBalance()) > 0) {
- log.warn("Недостаточно средств для операции, требуется {} RUB, а осталось {} RUB", priceInRubles, user.maxBalance().subtract(alreadySpent));
- return false;
- }
- return !lastBoughtByUser.containsKey(user) || lastBoughtByUser.get(user).plusSeconds(OPERATIONS_PERIOD_SECONDS).isBefore(now);
- }
-
- /**
- * Проверка на то, что стратегия заданного пользователя может провести операцию по заданной бумаге.
- *
- *
- * На данный момент есть два критерия возможности проведения операции для пользователя:
- *
- *
Время, т.е. пользователь может совершать операции не чаще чем раз в OPERATIONS_PERIOD_SECONDS
- *
Бюджет пользователя: стратегия не может превысить выставленный пользователем лимит
- *
- *
- */
- private boolean canMakeOperationNow(User user, BigDecimal price, String figi) {
- return canMakeOperationNow(user, Instant.now(), price, figi);
- }
+ void addLastPrice(LastPrice lastPrice);
/**
* Продажа всех ценных бумаг пользователя, которые были накоплены стратегией.
*/
- public void sellAll(String accountId) {
- log.info("Запрос на продажу всех активов {}", accountId);
- var user = userService.findUserByAccountId(accountId);
- priceService.deleteRequestsForUser(user);
- priceService.getRemainingInstruments(user).forEach((figi, quantity) -> {
- var instrument = investClient.getInstrument(user, figi);
- if (!instrument.getInstrumentType().equalsIgnoreCase("currency")) {
- investClient.sellMarket(user, figi, quantity, priceService.getLastPrice(user, figi)).join();
- }
- });
- }
+ void sellAll(String accountId);
}
diff --git a/src/main/java/ru/unclesema/ttb/service/ApplicationServiceImpl.java b/src/main/java/ru/unclesema/ttb/service/ApplicationServiceImpl.java
new file mode 100644
index 0000000..c29989e
--- /dev/null
+++ b/src/main/java/ru/unclesema/ttb/service/ApplicationServiceImpl.java
@@ -0,0 +1,323 @@
+package ru.unclesema.ttb.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import ru.tinkoff.piapi.contract.v1.*;
+import ru.tinkoff.piapi.core.exception.ApiRuntimeException;
+import ru.unclesema.ttb.MarketSubscriber;
+import ru.unclesema.ttb.client.InvestClient;
+import ru.unclesema.ttb.model.NewUserRequest;
+import ru.unclesema.ttb.model.User;
+import ru.unclesema.ttb.model.UserMode;
+import ru.unclesema.ttb.service.analyze.AnalyzeService;
+import ru.unclesema.ttb.service.price.PriceService;
+import ru.unclesema.ttb.service.user.UserService;
+import ru.unclesema.ttb.strategy.CandleStrategy;
+import ru.unclesema.ttb.strategy.OrderBookStrategy;
+import ru.unclesema.ttb.strategy.Strategy;
+import ru.unclesema.ttb.strategy.StrategyDecision;
+import ru.unclesema.ttb.utility.Utility;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Основной сервис приложения, который отвечает за добавление новых пользователей, создание новых заявок, удаление существующих
+ */
+@RequiredArgsConstructor
+@Service
+@Slf4j
+public class ApplicationServiceImpl implements ApplicationService {
+ private final InvestClient investClient;
+ private final UserService userService;
+ private final PriceService priceService;
+ private final AnalyzeService analyzeService;
+ private final List availableStrategies;
+ private final ObjectMapper objectMapper;
+
+ private static final long OPERATIONS_PERIOD_SECONDS = 30;
+ private final Map orderBookSubscriberByUser = new HashMap<>();
+ private final Map candlesSubscriberByUser = new HashMap<>();
+ private final Map lastPricesSubscriberByUser = new HashMap<>();
+ private final Map lastBoughtByUser = new ConcurrentHashMap<>();
+
+ /**
+ * Метод обрабатывает запрос о новом пользователе, обрабатывая ошибки:
+ *
+ *
Параметры стратегии, пришедшие с UI, должны содержать имя стратегии
+ *
Стратегия с заданным именем должна существовать ровно одна
+ *
Токен пользователя не должен быть пустым
+ *
Если mode == MARKET, то accountId не должен быть пустым
+ *
Пользователя с заданным accountId не должно существовать
+ *
Ошибки обращений к API
+ *
+ */
+ @Override
+ public User addNewUser(NewUserRequest request) {
+ var strategyParameters = request.getStrategyParameters();
+ if (!strategyParameters.containsKey("name")) {
+ throw new IllegalArgumentException("Пропущено имя стратегии");
+ }
+ var name = strategyParameters.get("name");
+ strategyParameters.remove("name");
+ var strategyStream = availableStrategies.stream().filter(s -> s.getName().equals(name));
+ if (strategyStream.count() > 1) {
+ throw new IllegalStateException("Найдено несколько стратегий с именем `" + name + "`");
+ }
+ var strategyOptional = availableStrategies.stream().filter(s -> s.getName().equals(name)).findAny();
+ if (strategyOptional.isEmpty()) {
+ throw new IllegalArgumentException("Стратегия `" + name + "` не найдена");
+ }
+ if (request.getToken() == null || request.getToken().isBlank()) {
+ throw new IllegalArgumentException("Токен пользователя не может быть пустым");
+ }
+ var strategyClazz = strategyOptional.get().getClass();
+ var strategy = objectMapper.convertValue(strategyParameters, strategyClazz);
+ var figis = request.getFigis().stream()
+ .distinct()
+ .filter(figi -> !figi.isBlank())
+ .toList();
+ if (request.getMode() == UserMode.MARKET) {
+ if (request.getAccountId() == null || request.getAccountId().isBlank()) {
+ throw new IllegalArgumentException("Для режима реальной торговли необходимо указать accountId");
+ }
+ if (userService.contains(request.getAccountId())) {
+ throw new IllegalArgumentException("Пользователь с accountId = " + request.getAccountId() + " уже существует");
+ }
+ }
+ try {
+ var accountId = investClient.addUser(request.getToken(), request.getAccountId(), request.getMode());
+ var user = new User(request.getToken(), request.getMode(), request.getMaxBalance(), accountId, figis, strategy);
+ userService.addUser(user);
+ return user;
+ } catch (ApiRuntimeException e) {
+ if (Utility.checkExceptionCode(e, "70001")) {
+ log.error("Внутренняя ошибка Invest Api. Вы правильно указали токен?");
+ } else {
+ log.error("Неизвестная ошибка при создании пользователя", e);
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Метод добавляет информацию о новом стакане, о чём оповещает все стратегии, использующие стаканы.
+ */
+ @Override
+ public void addOrderBook(OrderBook orderBook) {
+ for (User user : userService.getActiveOrderBookUsers()) {
+ OrderBookStrategy strategy = (OrderBookStrategy) user.strategy();
+ String figi = orderBook.getFigi();
+ StrategyDecision decision = strategy.addOrderBook(orderBook);
+ BigDecimal lastPrice = priceService.getLastPrice(user, figi);
+ if (canMakeOperationNow(user, lastPrice, figi)) {
+ processStrategyDecision(user, figi, Instant.now(), lastPrice, decision);
+ }
+ }
+ }
+
+ /**
+ * Метод добавляет информацию о новой свече, о чём оповещает все стратегии, использующие свечи.
+ */
+ @Override
+ public void addCandle(Candle candle) {
+ for (User user : userService.getActiveCandleUsers()) {
+ CandleStrategy strategy = (CandleStrategy) user.strategy();
+ String figi = candle.getFigi();
+ StrategyDecision decision = strategy.addCandle(candle);
+ BigDecimal lastPrice = priceService.getLastPrice(user, figi);
+ if (canMakeOperationNow(user, lastPrice, figi)) {
+ processStrategyDecision(user, figi, Instant.now(), lastPrice, decision);
+ }
+ }
+ }
+
+ /**
+ * Метод симулирует работу стратегию на заданном временном промежутке.
+ */
+ @Override
+ public void simulate(User user, Instant from, Instant to, CandleInterval interval) {
+ log.info("Запрос просимулировать стратегию для {} с {} по {} с интервалом {}", user, from, to, interval);
+ if (user.mode() != UserMode.ANALYZE) {
+ throw new IllegalArgumentException("Используйте режим ANALYZE, чтобы симулировать работу стратегии");
+ }
+ if (!(user.strategy() instanceof CandleStrategy strategy)) {
+ throw new UnsupportedOperationException("Чтобы просимулировать на исторических данных работу стратегии, она должна реализовывать CandleStrategy интерфейс");
+ }
+ var candles = investClient.getCandles(user, from, to, interval).join();
+ for (var candle : candles) {
+ var decision = strategy.addCandle(candle);
+ var figi = candle.getFigi();
+ var price = Utility.toBigDecimal(candle.getClose());
+ var time = Utility.toInstant(candle.getTime());
+ analyzeService.processNewCandle(user, candle);
+ priceService.checkStopRequests(Utility.toLastPrice(candle));
+ if (canMakeOperationNow(user, time, price, figi)) {
+ processStrategyDecision(user, figi, time, Utility.toBigDecimal(candle.getClose()), decision);
+ }
+ }
+ log.info("Симуляция для {} завершена", user);
+ priceService.deleteRequestsForUser(user);
+ }
+
+ /**
+ * Метод обрабатывает решение стратегии, в зависимости от которого покупает / продает выбранную бумагу; выставляет takeProfit, stopLoss.
+ */
+ private void processStrategyDecision(User user, String figi, Instant now, BigDecimal currentPrice, StrategyDecision decision) {
+ if (decision == StrategyDecision.NOTHING) return;
+ boolean isBuy = decision == StrategyDecision.BUY;
+ BigDecimal profit = BigDecimal.valueOf(1 + (isBuy ? 1 : -1) * user.strategy().getTakeProfit() / 100);
+ BigDecimal loss = BigDecimal.valueOf(1 - (isBuy ? 1 : -1) * user.strategy().getStopLoss() / 100);
+ BigDecimal takeProfit = currentPrice.multiply(profit);
+ BigDecimal stopLoss = currentPrice.multiply(loss);
+ Instrument instrument = investClient.getInstrument(user, figi);
+ if (isBuy) {
+ log.info("Стратегия собирается пойти в лонг по бумаге с figi {}.\nЦена покупки: {}.\nTakeProfit: {}.\nStopLoss: {}",
+ figi, currentPrice, takeProfit, stopLoss);
+ var response = investClient.buyMarket(user, figi, currentPrice).join();
+ if (response != null) {
+ priceService.processNewOperation(user, figi, takeProfit, currentPrice, stopLoss, OrderDirection.ORDER_DIRECTION_BUY);
+ lastBoughtByUser.put(user, now);
+ }
+ } else if (instrument.getShortEnabledFlag()) {
+ log.info("Стратегия собирается пойти в шорт по бумаге с figi {}.\nЦена покупки: {}.\nTakeProfit: {}.\nStopLoss: {}",
+ figi, currentPrice, takeProfit, stopLoss);
+ var response = investClient.sellMarket(user, figi, currentPrice).join();
+ if (response != null) {
+ priceService.processNewOperation(user, figi, takeProfit, currentPrice, stopLoss, OrderDirection.ORDER_DIRECTION_SELL);
+ lastBoughtByUser.put(user, now);
+ }
+ }
+ }
+
+ /**
+ * Подписывает пользователя на:
+ *
+ *
Последние цены на выбранные инструменты
+ *
Последний стакан, если стратегия использует стакан
+ *
Последние свечи, если стратегия использует свечи
+ *
+ */
+ @Override
+ public void enableStrategyForUser(String accountId) {
+ log.info("Запрос на включение стратегии от пользователя с accountId = {}", accountId);
+ Optional optionalUser = userService.getAllUsers().stream().filter(u -> u.accountId().equals(accountId)).findAny();
+ if (optionalUser.isEmpty()) {
+ throw new IllegalArgumentException("Не получилось найти пользователя с accountId = " + accountId);
+ }
+ User user = optionalUser.get();
+ if (user.mode() == UserMode.ANALYZE) {
+ throw new IllegalArgumentException("В режиме анализа нельзя подписаться на стакан, свечи и последние цены");
+ }
+ MarketSubscriber subscriber = new MarketSubscriber(this);
+ if (user.strategy() instanceof OrderBookStrategy) {
+ orderBookSubscriberByUser.computeIfAbsent(user, u -> {
+ investClient.subscribe(user, subscriber, InvestClient.InstrumentType.ORDER_BOOK);
+ return subscriber;
+ });
+ }
+ if (user.strategy() instanceof CandleStrategy) {
+ candlesSubscriberByUser.computeIfAbsent(user, u -> {
+ investClient.subscribe(user, subscriber, InvestClient.InstrumentType.CANDLE);
+ return subscriber;
+ });
+ }
+ userService.makeUserActive(user);
+ lastPricesSubscriberByUser.computeIfAbsent(user, u -> {
+ investClient.subscribe(user, subscriber, InvestClient.InstrumentType.LAST_PRICE);
+ return subscriber;
+ });
+ }
+
+ /**
+ * Метод отменяет все подписки пользователя, делает его `неактивным`.
+ */
+ @Override
+ public void disableStrategyForUser(String accountId) {
+ log.info("Запрос на выключение стратегии от пользователя с accountId = {}", accountId);
+ var user = userService.findUserByAccountId(accountId);
+ if (orderBookSubscriberByUser.containsKey(user)) {
+ investClient.unsubscribe(user, InvestClient.InstrumentType.ORDER_BOOK);
+ orderBookSubscriberByUser.remove(user);
+ }
+ if (candlesSubscriberByUser.containsKey(user)) {
+ investClient.unsubscribe(user, InvestClient.InstrumentType.CANDLE);
+ candlesSubscriberByUser.remove(user);
+ }
+ if (lastPricesSubscriberByUser.containsKey(user)) {
+ investClient.unsubscribe(user, InvestClient.InstrumentType.LAST_PRICE);
+ lastPricesSubscriberByUser.remove(user);
+ }
+ userService.makeUserInactive(user);
+ }
+
+ /**
+ * Добавление последней цены
+ */
+ @Override
+ public void addLastPrice(LastPrice lastPrice) {
+ priceService.addLastPrice(lastPrice);
+ }
+
+ /**
+ * Проверка на то, что стратегия заданного пользователя может провести операцию по заданной бумаге.
+ *
+ *
+ * На данный момент есть два критерия возможности проведения операции для пользователя:
+ *
+ *
Время, т.е. пользователь может совершать операции не чаще чем раз в OPERATIONS_PERIOD_SECONDS
+ *
Бюджет пользователя: стратегия не может превысить выставленный пользователем лимит
+ *
+ *
+ */
+ private boolean canMakeOperationNow(User user, Instant now, BigDecimal price, String figi) {
+ var alreadySpent = priceService.getBalance(user);
+ var instrument = investClient.getInstrument(user, figi);
+ var priceInRubles = priceService.getPriceInRubles(user, price, figi).multiply(BigDecimal.valueOf(instrument.getLot()));
+ var newBalance = alreadySpent.add(priceInRubles);
+ if (newBalance.compareTo(user.maxBalance()) > 0) {
+ log.debug("Недостаточно средств для операции, требуется {} RUB, а осталось {} RUB", priceInRubles, user.maxBalance().subtract(alreadySpent));
+ return false;
+ }
+ log.debug("Нельзя провести операцию сейчас: операцию можно провести только раз в {} секунд", OPERATIONS_PERIOD_SECONDS);
+ return !lastBoughtByUser.containsKey(user) || lastBoughtByUser.get(user).plusSeconds(OPERATIONS_PERIOD_SECONDS).isBefore(now);
+ }
+
+ /**
+ * Проверка на то, что стратегия заданного пользователя может провести операцию по заданной бумаге.
+ *
+ *
+ * На данный момент есть два критерия возможности проведения операции для пользователя:
+ *
+ *
Время, т.е. пользователь может совершать операции не чаще чем раз в OPERATIONS_PERIOD_SECONDS
+ *
Бюджет пользователя: стратегия не может превысить выставленный пользователем лимит
+ *
+ *
+ */
+ private boolean canMakeOperationNow(User user, BigDecimal price, String figi) {
+ return canMakeOperationNow(user, Instant.now(), price, figi);
+ }
+
+ /**
+ * Продажа всех ценных бумаг пользователя, которые были накоплены стратегией.
+ */
+ @Override
+ public void sellAll(String accountId) {
+ log.info("Запрос на продажу всех активов {}", accountId);
+ var user = userService.findUserByAccountId(accountId);
+ priceService.deleteRequestsForUser(user);
+ priceService.getRemainingInstruments(user).forEach((figi, quantity) -> {
+ var instrument = investClient.getInstrument(user, figi);
+ if (!instrument.getInstrumentType().equalsIgnoreCase("currency")) {
+ investClient.sellMarket(user, figi, quantity, priceService.getLastPrice(user, figi)).join();
+ }
+ });
+ }
+}
diff --git a/src/main/java/ru/unclesema/ttb/service/analyze/AnalyzeService.java b/src/main/java/ru/unclesema/ttb/service/analyze/AnalyzeService.java
new file mode 100644
index 0000000..f5bf10e
--- /dev/null
+++ b/src/main/java/ru/unclesema/ttb/service/analyze/AnalyzeService.java
@@ -0,0 +1,35 @@
+package ru.unclesema.ttb.service.analyze;
+
+import ru.tinkoff.piapi.contract.v1.Candle;
+import ru.tinkoff.piapi.contract.v1.LastPrice;
+import ru.tinkoff.piapi.contract.v1.Operation;
+import ru.tinkoff.piapi.contract.v1.OrderDirection;
+import ru.unclesema.ttb.model.User;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * Сервис, отвечающий за режим анализа.
+ */
+public interface AnalyzeService {
+ /**
+ * Метод добавляет новую операцию, совершенную во время симуляции работы стратегии
+ */
+ void processOrder(User user, String figi, long quantity, BigDecimal price, OrderDirection direction);
+
+ /**
+ * @return Совершённые стратегией операции
+ */
+ List getOperations(User user);
+
+ /**
+ * @return Цену последней добавленной свечи
+ */
+ LastPrice getLastPrice(User user, String figi);
+
+ /**
+ * Добавить новую свечу
+ */
+ void processNewCandle(User user, Candle candle);
+}
diff --git a/src/main/java/ru/unclesema/ttb/service/AnalyzeService.java b/src/main/java/ru/unclesema/ttb/service/analyze/AnalyzeServiceImpl.java
similarity index 92%
rename from src/main/java/ru/unclesema/ttb/service/AnalyzeService.java
rename to src/main/java/ru/unclesema/ttb/service/analyze/AnalyzeServiceImpl.java
index cf0668c..6f81ce0 100644
--- a/src/main/java/ru/unclesema/ttb/service/AnalyzeService.java
+++ b/src/main/java/ru/unclesema/ttb/service/analyze/AnalyzeServiceImpl.java
@@ -1,4 +1,4 @@
-package ru.unclesema.ttb.service;
+package ru.unclesema.ttb.service.analyze;
import com.google.protobuf.Timestamp;
import org.springframework.stereotype.Service;
@@ -16,7 +16,7 @@
* Сервис, отвечающий за режим анализа.
*/
@Service
-public class AnalyzeService {
+public class AnalyzeServiceImpl implements AnalyzeService {
private final Map> operations = new HashMap<>();
private final Map> lastPriceByUser = new HashMap<>();
private final Map timeByUser = new HashMap<>();
@@ -24,6 +24,7 @@ public class AnalyzeService {
/**
* Метод добавляет новую операцию, совершенную во время симуляции работы стратегии
*/
+ @Override
public void processOrder(User user, String figi, long quantity, BigDecimal price, OrderDirection direction) {
OperationType operationType = (direction == OrderDirection.ORDER_DIRECTION_BUY ? OperationType.OPERATION_TYPE_BUY : OperationType.OPERATION_TYPE_SELL);
operations.computeIfAbsent(user, u -> new ArrayList<>());
@@ -39,8 +40,9 @@ public void processOrder(User user, String figi, long quantity, BigDecimal price
}
/**
- * @return Соверщенные стратегией операции
+ * @return Совершённые стратегией операции
*/
+ @Override
public List getOperations(User user) {
return operations.getOrDefault(user, List.of());
}
@@ -48,6 +50,7 @@ public List getOperations(User user) {
/**
* @return Цену последней добавленной свечи
*/
+ @Override
public LastPrice getLastPrice(User user, String figi) {
return lastPriceByUser
.getOrDefault(user, Map.of())
@@ -57,6 +60,7 @@ public LastPrice getLastPrice(User user, String figi) {
/**
* Метод добавляет новую свечу
*/
+ @Override
public void processNewCandle(User user, Candle candle) {
addLastPrice(user, Utility.toLastPrice(candle));
addTimestampForUser(user, candle.getTime());
diff --git a/src/main/java/ru/unclesema/ttb/service/front/FrontService.java b/src/main/java/ru/unclesema/ttb/service/front/FrontService.java
index 483e539..301f61c 100644
--- a/src/main/java/ru/unclesema/ttb/service/front/FrontService.java
+++ b/src/main/java/ru/unclesema/ttb/service/front/FrontService.java
@@ -1,190 +1,105 @@
package ru.unclesema.ttb.service.front;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
import ru.tinkoff.piapi.contract.v1.Instrument;
import ru.tinkoff.piapi.contract.v1.MoneyValue;
import ru.tinkoff.piapi.contract.v1.Operation;
import ru.tinkoff.piapi.contract.v1.OperationType;
-import ru.unclesema.ttb.client.InvestClient;
import ru.unclesema.ttb.model.User;
-import ru.unclesema.ttb.service.PriceService;
-import ru.unclesema.ttb.service.UserService;
import ru.unclesema.ttb.strategy.CandleStrategy;
import ru.unclesema.ttb.strategy.Strategy;
-import ru.unclesema.ttb.utility.Utility;
import java.math.BigDecimal;
import java.time.LocalDateTime;
-import java.time.ZoneId;
-import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.stream.Collectors;
/**
* Сервис для работы с UI
*/
-@Service
-@RequiredArgsConstructor
-@Slf4j
-public class FrontService {
- private final List availableStrategies;
- private final List availableCandleStrategies;
- private final PriceService priceService;
- private final UserService userService;
- private final InvestClient investClient;
-
+public interface FrontService {
/**
* @return всех существующих пользователей
*/
- public List getAllUsers() {
- return userService.getAllUsers();
- }
+ List getAllUsers();
/**
* @return пользователь с заданным accountId
* @throws IllegalArgumentException, если пользователь не найден
*/
- public User findUser(String accountId) {
- return userService.findUserByAccountId(accountId);
- }
+ User findUser(String accountId);
/**
* @return имя заданного инструмента
*/
- public String getInstrumentName(User user, String figi) {
- return getInstrument(user, figi).getName();
- }
+ String getInstrumentName(User user, String figi);
/**
* @return заданный инструмент
*/
- public Instrument getInstrument(User user, String figi) {
- return investClient.getInstrument(user, figi);
- }
+ Instrument getInstrument(User user, String figi);
/**
* Конвертирует LastPrice в String
*/
- public String lastPriceToString(User user, BigDecimal quantity, String figi) {
- String currency = getInstrument(user, figi).getCurrency();
- BigDecimal lastPrice = priceService.getLastPrice(user, figi);
- return lastPrice.multiply(quantity).doubleValue() + " " + currency.toUpperCase();
- }
+ String lastPriceToString(User user, BigDecimal quantity, String figi);
/**
* Конвертирует MoneyValue в String
*/
- public String moneyValueToString(User user, String figi, MoneyValue value) {
- Instrument instrument = getInstrument(user, figi);
- return Utility.toBigDecimal(value).doubleValue() + " " + instrument.getCurrency().toUpperCase();
- }
+ String moneyValueToString(User user, String figi, MoneyValue value);
/**
* @return отчёт по стратегии
*/
- public StrategyStatement getStatement(User user) {
- Map benefitByCurrency = new HashMap<>();
- // Обрабатываем ещё не проданные бумаги
- for (var entry : priceService.getRemainingInstruments(user).entrySet()) {
- var instrument = investClient.getInstrument(user, entry.getKey());
- var amount = entry.getValue();
- var benefit = benefitByCurrency.getOrDefault(instrument.getCurrency(), BigDecimal.ZERO);
- benefit = benefit.add(priceService.getLastPrice(user, instrument.getFigi()).multiply(BigDecimal.valueOf(amount)));
- benefitByCurrency.put(instrument.getCurrency(), benefit);
- }
- var operations = getOperations(user);
- // Обрабатываем уже совершённые операции
- for (var op : operations) {
- if (op.getInstrumentType().equalsIgnoreCase("currency")) continue;
- var instrument = getInstrument(user, op.getFigi());
- var benefit = benefitByCurrency.getOrDefault(instrument.getCurrency(), BigDecimal.ZERO);
- var payment = Utility.toBigDecimal(op.getPayment()).abs();
- if (op.getOperationType() == OperationType.OPERATION_TYPE_BUY) {
- benefit = benefit.subtract(payment);
- } else if (op.getOperationType() == OperationType.OPERATION_TYPE_SELL) {
- benefit = benefit.add(payment);
- } else if (op.getOperationType() == OperationType.OPERATION_TYPE_BROKER_FEE) {
- benefit = benefit.subtract(payment);
- } else {
- log.error("Неизвестная операция {}", op.getOperationType());
- }
- benefitByCurrency.put(instrument.getCurrency(), benefit);
- }
- return new StrategyStatement(benefitByCurrency, operations);
- }
+ StrategyStatement getStatement(User user);
/**
* Конвертирует заработанные стратегией средства в String
*/
- public String printBenefits(User user) {
- var statement = getStatement(user);
- var benefitByCurrency = statement.benefitByCurrency();
- if (benefitByCurrency.isEmpty()) {
- return "пока ничего :(";
- }
- return benefitByCurrency.entrySet()
- .stream()
- .map(entry -> entry.getValue().doubleValue() + " " + entry.getKey().toUpperCase())
- .collect(Collectors.joining(", "));
- }
+ String printBenefits(User user);
/**
* Конвертирует OperationType в String
*/
- public String operationTypeToString(OperationType operationType) {
- if (operationType == OperationType.OPERATION_TYPE_BUY) {
- return "Покупка";
- }
- if (operationType == OperationType.OPERATION_TYPE_SELL) {
- return "Продажа";
- }
- if (operationType == OperationType.OPERATION_TYPE_INPUT) {
- return "Пополнение";
- }
- if (operationType == OperationType.OPERATION_TYPE_OUTPUT) {
- return "Снятие";
- }
- if (operationType == OperationType.OPERATION_TYPE_BROKER_FEE) {
- return "Комиссия брокера";
- }
- return operationType.name();
- }
+ String operationTypeToString(OperationType operationType);
/**
* @return возвращает дату операции
*/
- public LocalDateTime getDate(Operation op) {
- return LocalDateTime.ofInstant(Utility.toInstant(op.getDate()), ZoneId.systemDefault());
- }
+ LocalDateTime getDate(Operation op);
- public Map getRemainingInstruments(User user) {
- return priceService.getRemainingInstruments(user);
- }
+ /**
+ * Получение купленных, но ещё не проданных стратегией, бумаг.
+ */
+ Map getRemainingInstruments(User user);
- public boolean isActive(User user) {
- return userService.isActive(user);
- }
+ /**
+ * Проверка на то, что пользователь активный
+ */
+ boolean isActive(User user);
- public List getAvailableStrategies() {
- return availableStrategies;
- }
+ /**
+ * Получить доступные для выбора стратегии.
+ */
+ List getAvailableStrategies();
- public List getAvailableCandleStrategies() {
- return availableCandleStrategies;
- }
+ /**
+ * Получить доступные для выбора стратегии, использующие свечи.
+ */
+ List getAvailableCandleStrategies();
- public List getOperations(User user) {
- return investClient.getOperations(user);
- }
+ /**
+ * Получить все операции, произведенных с начала работы приложения по текущий момент.
+ */
+ List getOperations(User user);
- public BigDecimal getBalance(User user) {
- return priceService.getBalance(user);
- }
+ /**
+ * Получить количество потраченных стратегией рублей.
+ */
+ BigDecimal getBalance(User user);
- public boolean contains(String accountId) {
- return userService.contains(accountId);
- }
+ /**
+ * Проверка на то, что пользователь с данным accountId существует.
+ */
+ boolean contains(String accountId);
}
diff --git a/src/main/java/ru/unclesema/ttb/service/front/FrontServiceImpl.java b/src/main/java/ru/unclesema/ttb/service/front/FrontServiceImpl.java
new file mode 100644
index 0000000..dedc908
--- /dev/null
+++ b/src/main/java/ru/unclesema/ttb/service/front/FrontServiceImpl.java
@@ -0,0 +1,228 @@
+package ru.unclesema.ttb.service.front;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import ru.tinkoff.piapi.contract.v1.Instrument;
+import ru.tinkoff.piapi.contract.v1.MoneyValue;
+import ru.tinkoff.piapi.contract.v1.Operation;
+import ru.tinkoff.piapi.contract.v1.OperationType;
+import ru.unclesema.ttb.client.InvestClient;
+import ru.unclesema.ttb.model.User;
+import ru.unclesema.ttb.service.price.PriceService;
+import ru.unclesema.ttb.service.user.UserService;
+import ru.unclesema.ttb.strategy.CandleStrategy;
+import ru.unclesema.ttb.strategy.Strategy;
+import ru.unclesema.ttb.utility.Utility;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Сервис для работы с UI
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class FrontServiceImpl implements FrontService {
+ private final List availableStrategies;
+ private final List availableCandleStrategies;
+ private final PriceService priceService;
+ private final UserService userService;
+ private final InvestClient investClient;
+
+ /**
+ * @return всех существующих пользователей
+ */
+ @Override
+ public List getAllUsers() {
+ return userService.getAllUsers();
+ }
+
+ /**
+ * @return пользователь с заданным accountId
+ * @throws IllegalArgumentException, если пользователь не найден
+ */
+ @Override
+ public User findUser(String accountId) {
+ return userService.findUserByAccountId(accountId);
+ }
+
+ /**
+ * @return имя заданного инструмента
+ */
+ @Override
+ public String getInstrumentName(User user, String figi) {
+ return getInstrument(user, figi).getName();
+ }
+
+ /**
+ * @return заданный инструмент
+ */
+ @Override
+ public Instrument getInstrument(User user, String figi) {
+ return investClient.getInstrument(user, figi);
+ }
+
+ /**
+ * Конвертирует LastPrice в String
+ */
+ @Override
+ public String lastPriceToString(User user, BigDecimal quantity, String figi) {
+ String currency = getInstrument(user, figi).getCurrency();
+ BigDecimal lastPrice = priceService.getLastPrice(user, figi);
+ return lastPrice.multiply(quantity).doubleValue() + " " + currency.toUpperCase();
+ }
+
+ /**
+ * Конвертирует MoneyValue в String
+ */
+ @Override
+ public String moneyValueToString(User user, String figi, MoneyValue value) {
+ Instrument instrument = getInstrument(user, figi);
+ return Utility.toBigDecimal(value).abs().doubleValue() + " " + instrument.getCurrency().toUpperCase();
+ }
+
+ /**
+ * @return отчёт по стратегии
+ */
+ @Override
+ public StrategyStatement getStatement(User user) {
+ Map benefitByCurrency = new HashMap<>();
+ // Обрабатываем купленные, но ещё не проданные бумаги
+ for (var entry : priceService.getRemainingInstruments(user).entrySet()) {
+ var instrument = investClient.getInstrument(user, entry.getKey());
+ var amount = entry.getValue();
+ var benefit = benefitByCurrency.getOrDefault(instrument.getCurrency(), BigDecimal.ZERO);
+ benefit = benefit.add(priceService.getLastPrice(user, instrument.getFigi()).multiply(BigDecimal.valueOf(amount)));
+ benefitByCurrency.put(instrument.getCurrency(), benefit);
+ }
+ var operations = getOperations(user);
+ // Обрабатываем уже совершённые операции
+ for (var op : operations) {
+ if (op.getInstrumentType().equalsIgnoreCase("currency")) continue;
+ var instrument = getInstrument(user, op.getFigi());
+ var benefit = benefitByCurrency.getOrDefault(instrument.getCurrency(), BigDecimal.ZERO);
+ var payment = Utility.toBigDecimal(op.getPayment()).abs();
+ if (op.getOperationType() == OperationType.OPERATION_TYPE_BUY) {
+ benefit = benefit.subtract(payment);
+ } else if (op.getOperationType() == OperationType.OPERATION_TYPE_SELL) {
+ benefit = benefit.add(payment);
+ } else if (op.getOperationType() == OperationType.OPERATION_TYPE_BROKER_FEE) {
+ benefit = benefit.subtract(payment);
+ } else {
+ log.error("Неизвестная операция {}", op.getOperationType());
+ }
+ benefitByCurrency.put(instrument.getCurrency(), benefit);
+ }
+ return new StrategyStatement(benefitByCurrency, operations);
+ }
+
+ /**
+ * Конвертирует заработанные стратегией средства в String
+ */
+ @Override
+ public String printBenefits(User user) {
+ var statement = getStatement(user);
+ var benefitByCurrency = statement.benefitByCurrency();
+ if (benefitByCurrency.isEmpty()) {
+ return "пока ничего :(";
+ }
+ return benefitByCurrency.entrySet()
+ .stream()
+ .map(entry -> entry.getValue().doubleValue() + " " + entry.getKey().toUpperCase())
+ .collect(Collectors.joining(", "));
+ }
+
+ /**
+ * Конвертирует OperationType в String
+ */
+ @Override
+ public String operationTypeToString(OperationType operationType) {
+ if (operationType == OperationType.OPERATION_TYPE_BUY) {
+ return "Покупка";
+ }
+ if (operationType == OperationType.OPERATION_TYPE_SELL) {
+ return "Продажа";
+ }
+ if (operationType == OperationType.OPERATION_TYPE_INPUT) {
+ return "Пополнение";
+ }
+ if (operationType == OperationType.OPERATION_TYPE_OUTPUT) {
+ return "Снятие";
+ }
+ if (operationType == OperationType.OPERATION_TYPE_BROKER_FEE) {
+ return "Комиссия брокера";
+ }
+ return operationType.name();
+ }
+
+ /**
+ * @return возвращает дату операции
+ */
+ @Override
+ public LocalDateTime getDate(Operation op) {
+ return LocalDateTime.ofInstant(Utility.toInstant(op.getDate()), ZoneId.systemDefault());
+ }
+
+ /**
+ * Получение купленных, но ещё не проданных стратегией, бумаг.
+ */
+ @Override
+ public Map getRemainingInstruments(User user) {
+ return priceService.getRemainingInstruments(user);
+ }
+
+ /**
+ * Проверка на то, что пользователь активный
+ */
+ @Override
+ public boolean isActive(User user) {
+ return userService.isActive(user);
+ }
+
+ /**
+ * Получить доступные для выбора стратегии.
+ */
+ @Override
+ public List getAvailableStrategies() {
+ return availableStrategies;
+ }
+
+ /**
+ * Получить доступные для выбора стратегии, использующие свечи.
+ */
+ @Override
+ public List getAvailableCandleStrategies() {
+ return availableCandleStrategies;
+ }
+
+ /**
+ * Получить все операции, произведенных с начала работы приложения по текущий момент.
+ */
+ @Override
+ public List getOperations(User user) {
+ return investClient.getOperations(user);
+ }
+
+ /**
+ * Получить количество потраченных стратегией рублей.
+ */
+ @Override
+ public BigDecimal getBalance(User user) {
+ return priceService.getBalance(user);
+ }
+
+ /**
+ * Проверка на то, что пользователь с данным accountId существует.
+ */
+ @Override
+ public boolean contains(String accountId) {
+ return userService.contains(accountId);
+ }
+}
diff --git a/src/main/java/ru/unclesema/ttb/service/price/PriceService.java b/src/main/java/ru/unclesema/ttb/service/price/PriceService.java
new file mode 100644
index 0000000..446fa6c
--- /dev/null
+++ b/src/main/java/ru/unclesema/ttb/service/price/PriceService.java
@@ -0,0 +1,64 @@
+package ru.unclesema.ttb.service.price;
+
+import ru.tinkoff.piapi.contract.v1.LastPrice;
+import ru.tinkoff.piapi.contract.v1.OrderDirection;
+import ru.unclesema.ttb.model.User;
+
+import java.math.BigDecimal;
+import java.util.Map;
+
+/**
+ * Сервис, отвечающий за работу с балансом / последними ценами / stop запросами
+ */
+public interface PriceService {
+ /**
+ * Метод ищет последнюю цену среди добавленных (если не находит, отправляет запрос к api).
+ */
+ BigDecimal getLastPrice(User user, String figi);
+
+ /**
+ * Метод находит все инструменты, которые ещё не были проданы
+ *
+ *
Метод смотрит на последние операции пользователя, храня Map и прибавляя 1 к инструменту, в случае покупки, и вычитая 1 иначе.
+ *
+ * Другие возможные реализации:
+ *
+ *
Portfolio, метод будет делать запрос к api, кэшируя его. Такая реализация не очень хороша из-за `несинхронизованности` с операциями,
+ * которые используются в UI.
+ *
LastTrades, сервис подпишется на LastTrades, по которым будет считать оставшиеся инструменты. Такая реализация не очень хороша,
+ * из-за того, что подписаться на LastTrades можно только при торговле на бирже
+ *
+ *
+ */
+ Map getRemainingInstruments(User user);
+
+ /**
+ * Добавить последнюю цену.
+ */
+ void addLastPrice(LastPrice price);
+
+ /**
+ * Метод обрабатывает новую операцию и добавляет стоп запросы
+ */
+ void processNewOperation(User user, String figi, BigDecimal takeProfit, BigDecimal price, BigDecimal stopLoss, OrderDirection direction);
+
+ /**
+ * Метод перебирает все стоп запросы, выставляя на биржу нужные.
+ */
+ void checkStopRequests(LastPrice price);
+
+ /**
+ * Удаляет все стоп запросы для пользователя
+ */
+ void deleteRequestsForUser(User user);
+
+ /**
+ * Переводит указанную цену в рубли
+ */
+ BigDecimal getPriceInRubles(User user, BigDecimal price, String figi);
+
+ /**
+ * Возвращает количество рублей, потраченных стратегией
+ */
+ BigDecimal getBalance(User user);
+}
diff --git a/src/main/java/ru/unclesema/ttb/service/PriceService.java b/src/main/java/ru/unclesema/ttb/service/price/PriceServiceImpl.java
similarity index 83%
rename from src/main/java/ru/unclesema/ttb/service/PriceService.java
rename to src/main/java/ru/unclesema/ttb/service/price/PriceServiceImpl.java
index 987add3..17eb2fd 100644
--- a/src/main/java/ru/unclesema/ttb/service/PriceService.java
+++ b/src/main/java/ru/unclesema/ttb/service/price/PriceServiceImpl.java
@@ -1,4 +1,4 @@
-package ru.unclesema.ttb.service;
+package ru.unclesema.ttb.service.price;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -10,6 +10,7 @@
import ru.unclesema.ttb.client.InvestClient;
import ru.unclesema.ttb.model.User;
import ru.unclesema.ttb.model.UserMode;
+import ru.unclesema.ttb.service.analyze.AnalyzeService;
import ru.unclesema.ttb.utility.Utility;
import java.math.BigDecimal;
@@ -26,7 +27,7 @@
@Service
@RequiredArgsConstructor
@Slf4j
-public class PriceService {
+public class PriceServiceImpl implements PriceService {
private final InvestClient client;
private final AnalyzeService analyzeService;
@@ -37,6 +38,7 @@ public class PriceService {
/**
* Метод ищет последнюю цену среди добавленных (если не находит, отправляет запрос к api).
*/
+ @Override
public BigDecimal getLastPrice(User user, String figi) {
if (figi.equalsIgnoreCase("FG0000000000")) {
return BigDecimal.ONE;
@@ -65,6 +67,7 @@ public BigDecimal getLastPrice(User user, String figi) {
*
*
*/
+ @Override
public Map getRemainingInstruments(User user) {
List operations = client.getOperations(user);
Map remainingInstruments = new HashMap<>();
@@ -86,6 +89,10 @@ public Map getRemainingInstruments(User user) {
return remainingInstruments;
}
+ /**
+ * Добавить последнюю цену.
+ */
+ @Override
public void addLastPrice(LastPrice price) {
lastPriceByFigi.put(price.getFigi(), Utility.toBigDecimal(price.getPrice()));
checkStopRequests(price);
@@ -94,6 +101,7 @@ public void addLastPrice(LastPrice price) {
/**
* Метод обрабатывает новую операцию и добавляет стоп запросы
*/
+ @Override
public void processNewOperation(User user, String figi, BigDecimal takeProfit, BigDecimal price, BigDecimal stopLoss, OrderDirection direction) {
var stopRequestDirection = direction == OrderDirection.ORDER_DIRECTION_BUY ? OrderDirection.ORDER_DIRECTION_SELL : OrderDirection.ORDER_DIRECTION_BUY;
var instrument = client.getInstrument(user, figi);
@@ -104,6 +112,7 @@ public void processNewOperation(User user, String figi, BigDecimal takeProfit, B
/**
* Метод перебирает все стоп запросы, выставляя на биржу нужные.
*/
+ @Override
public void checkStopRequests(LastPrice price) {
if (!openStopRequests.containsKey(price.getFigi())) return;
var requests = openStopRequests.get(price.getFigi());
@@ -111,7 +120,9 @@ public void checkStopRequests(LastPrice price) {
requests.removeIf(request -> {
var user = request.user();
var figi = request.figi();
+ var instrument = client.getInstrument(user, figi);
if (request.direction() == OrderDirection.ORDER_DIRECTION_SELL) {
+ // Сработала стоп заявка для позиции в лонг
BigDecimal sellPrice;
if (request.takeProfit().compareTo(lastPrice) <= 0) {
sellPrice = request.takeProfit();
@@ -123,10 +134,11 @@ public void checkStopRequests(LastPrice price) {
log.info("Сработала стоп-заяка для {}, продажа по цене {}", figi, sellPrice);
var response = client.sellMarket(user, figi, sellPrice).join();
if (response != null) {
- subtractFromBalance(user, getPriceInRubles(user, sellPrice, figi));
+ subtractFromBalance(user, getPriceInRubles(user, sellPrice, figi).multiply(BigDecimal.valueOf(instrument.getLot())));
}
return response != null;
} else if (request.direction() == OrderDirection.ORDER_DIRECTION_BUY) {
+ // Сработала стоп заявка для позиции в шорт
BigDecimal buyPrice;
if (request.takeProfit().compareTo(lastPrice) >= 0) {
buyPrice = request.takeProfit();
@@ -138,7 +150,7 @@ public void checkStopRequests(LastPrice price) {
log.info("Сработала стоп-заяка для {}, покупка по цене {}", figi, buyPrice);
var response = client.buyMarket(user, figi, buyPrice).join();
if (response != null) {
- subtractFromBalance(user, getPriceInRubles(user, buyPrice, figi));
+ subtractFromBalance(user, getPriceInRubles(user, buyPrice, figi).multiply(BigDecimal.valueOf(instrument.getLot())));
}
return response != null;
}
@@ -146,6 +158,10 @@ public void checkStopRequests(LastPrice price) {
});
}
+ /**
+ * Удаляет все стоп запросы для пользователя
+ */
+ @Override
public void deleteRequestsForUser(User user) {
log.info("Удаление всех запросов для пользователя {}", user);
for (String figi : user.figis()) {
@@ -157,6 +173,10 @@ public void deleteRequestsForUser(User user) {
}
}
+ /**
+ * Переводит указанную цену в рубли
+ */
+ @Override
public BigDecimal getPriceInRubles(User user, BigDecimal price, String figi) {
String currency = client.getInstrument(user, figi).getCurrency();
if (currency.equalsIgnoreCase("rub")) {
@@ -170,26 +190,39 @@ public BigDecimal getPriceInRubles(User user, BigDecimal price, String figi) {
return lastCurrencyPrice.multiply(price);
}
+ /**
+ * Возвращает количество рублей, потраченных стратегией
+ */
+ @Override
public BigDecimal getBalance(User user) {
return spentByUser.getOrDefault(user, BigDecimal.ZERO);
}
- private void addStopRequest(StopRequest request) {
- if (!openStopRequests.containsKey(request.figi())) {
- openStopRequests.put(request.figi(), new ConcurrentLinkedQueue<>());
- }
- openStopRequests.get(request.figi()).add(request);
- }
-
+ /**
+ * Добавляет указанное количество рублей к уже потраченным (нужно, чтобы стратегия не вышла за лимит, поставленный пользователем)
+ *
+ *
Используется, например, при покупке бумаги стратегией
+ */
private void addToBalance(User user, BigDecimal price) {
spentByUser.merge(user, price, BigDecimal::add);
}
+ /**
+ * Возвращает потраченные деньги (нужно, чтобы стратегия не вышла за лимит, поставленный пользователем)
+ *
Используется, например, при продаже бумаги стратегией
Пользователь становится активным, когда он включает для себя стратегию, и перестаёт быть таким, когда выключает её.
+ */
+public interface UserService {
+ /**
+ * Добавить нового пользователя
+ */
+ void addUser(User user);
+
+ /**
+ * Получить всех пользователей
+ */
+ List getAllUsers();
+
+ /**
+ * Получить пользователя по AccountID
+ *
+ * @throws IllegalArgumentException если пользователь не найден
+ */
+ User findUserByAccountId(String accountId);
+
+ /**
+ * Проверка на то, что пользователь активный
+ */
+ boolean isActive(User user);
+
+ /**
+ * Сделать пользователя активным
+ */
+ void makeUserActive(User user);
+
+ /**
+ * Сделать пользователя неактивным
+ */
+ void makeUserInactive(User user);
+
+ /**
+ * Получить всех активных пользователей, у которых стратегия использует свечи.
+ */
+ Set getActiveCandleUsers();
+
+ /**
+ * Получить всех активных пользователей, у которых стратегия использует стакан.
+ */
+ Set getActiveOrderBookUsers();
+
+ /**
+ * Проверка на то, что пользователь с данным accountId существует.
+ */
+ boolean contains(String accountId);
+}
diff --git a/src/main/java/ru/unclesema/ttb/service/UserService.java b/src/main/java/ru/unclesema/ttb/service/user/UserServiceImpl.java
similarity index 54%
rename from src/main/java/ru/unclesema/ttb/service/UserService.java
rename to src/main/java/ru/unclesema/ttb/service/user/UserServiceImpl.java
index 7b7ddbb..0edab8a 100644
--- a/src/main/java/ru/unclesema/ttb/service/UserService.java
+++ b/src/main/java/ru/unclesema/ttb/service/user/UserServiceImpl.java
@@ -1,4 +1,4 @@
-package ru.unclesema.ttb.service;
+package ru.unclesema.ttb.service.user;
import org.springframework.stereotype.Service;
import ru.unclesema.ttb.model.User;
@@ -9,24 +9,39 @@
import java.util.List;
import java.util.Set;
+/**
+ * Сервис отвечающий за работу с пользователями.
+ *
+ *
Пользователь становится активным, когда он включает для себя стратегию, и перестаёт быть таким, когда выключает её.
+ */
@Service
-public class UserService {
+public class UserServiceImpl implements UserService {
private final Set allUsers = new HashSet<>();
private final Set activeCandleUsers = new HashSet<>();
private final Set activeOrderBookUsers = new HashSet<>();
+ /**
+ * Добавить нового пользователя
+ */
+ @Override
public void addUser(User user) {
allUsers.add(user);
}
- public void removeUser(User user) {
- allUsers.remove(user);
- }
-
+ /**
+ * Получить всех пользователей
+ */
+ @Override
public List getAllUsers() {
return allUsers.stream().toList();
}
+ /**
+ * Получить пользователя по AccountID
+ *
+ * @throws IllegalArgumentException если пользователь не найден
+ */
+ @Override
public User findUserByAccountId(String accountId) {
var optionalUser = allUsers.stream().filter(user -> user.accountId().equals(accountId)).findAny();
if (optionalUser.isEmpty()) {
@@ -35,10 +50,18 @@ public User findUserByAccountId(String accountId) {
return optionalUser.get();
}
+ /**
+ * Проверка на то, что пользователь активный
+ */
+ @Override
public boolean isActive(User user) {
return activeCandleUsers.contains(user) || activeOrderBookUsers.contains(user);
}
+ /**
+ * Сделать пользователя активным
+ */
+ @Override
public void makeUserActive(User user) {
if (user.strategy() instanceof OrderBookStrategy) {
activeOrderBookUsers.add(user);
@@ -48,19 +71,35 @@ public void makeUserActive(User user) {
}
}
+ /**
+ * Сделать пользователя неактивным
+ */
+ @Override
public void makeUserInactive(User user) {
activeCandleUsers.remove(user);
activeOrderBookUsers.remove(user);
}
+ /**
+ * Получить всех активных пользователей, у которых стратегия использует свечи.
+ */
+ @Override
public Set getActiveCandleUsers() {
return activeCandleUsers;
}
+ /**
+ * Получить всех активных пользователей, у которых стратегия использует стакан.
+ */
+ @Override
public Set getActiveOrderBookUsers() {
return activeOrderBookUsers;
}
+ /**
+ * Проверка на то, что пользователь с данным accountId существует.
+ */
+ @Override
public boolean contains(String accountId) {
var optionalUser = allUsers.stream().filter(user -> user.accountId().equals(accountId)).findAny();
return optionalUser.isPresent();
diff --git a/src/main/java/ru/unclesema/ttb/strategy/orderbook/OrderBookStrategyImpl.java b/src/main/java/ru/unclesema/ttb/strategy/orderbook/AsksBidsStrategy.java
similarity index 61%
rename from src/main/java/ru/unclesema/ttb/strategy/orderbook/OrderBookStrategyImpl.java
rename to src/main/java/ru/unclesema/ttb/strategy/orderbook/AsksBidsStrategy.java
index 09d99ae..44fc7ac 100644
--- a/src/main/java/ru/unclesema/ttb/strategy/orderbook/OrderBookStrategyImpl.java
+++ b/src/main/java/ru/unclesema/ttb/strategy/orderbook/AsksBidsStrategy.java
@@ -3,29 +3,34 @@
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
-import ru.tinkoff.piapi.contract.v1.Order;
import ru.tinkoff.piapi.contract.v1.OrderBook;
import ru.unclesema.ttb.strategy.OrderBookStrategy;
import ru.unclesema.ttb.strategy.StrategyDecision;
import java.util.Map;
+/**
+ * Реализация стратегии, работающей со стаканом.
+ *
+ *
Стратегия использует параметр asksBidsRatio: если отношение запросов на покупку больше запросов на продажу в
+ * asksBidsRatio раз, то стратегия покупает бумагу, если меньше в asksBidsRatio раз, то продает.
+ */
@AllArgsConstructor
@NoArgsConstructor
@Getter
-public class OrderBookStrategyImpl implements OrderBookStrategy {
+public class AsksBidsStrategy implements OrderBookStrategy {
private double takeProfit = 0.5;
private double stopLoss = 1;
private double asksBidsRatio = 2;
@Override
public StrategyDecision addOrderBook(OrderBook orderBook) {
- long asks = 0;
- long bids = 0;
- for (Order order : orderBook.getAsksList()) {
+ var asks = 0L;
+ var bids = 0L;
+ for (var order : orderBook.getAsksList()) {
asks += order.getQuantity();
}
- for (Order order : orderBook.getBidsList()) {
+ for (var order : orderBook.getBidsList()) {
bids += order.getQuantity();
}
if (asks > asksBidsRatio * bids) {
diff --git a/src/main/java/ru/unclesema/ttb/strategy/rsi/RsiStrategy.java b/src/main/java/ru/unclesema/ttb/strategy/rsi/RsiStrategy.java
index 6f8b9ac..bbc4c6e 100644
--- a/src/main/java/ru/unclesema/ttb/strategy/rsi/RsiStrategy.java
+++ b/src/main/java/ru/unclesema/ttb/strategy/rsi/RsiStrategy.java
@@ -25,6 +25,8 @@
*
lowerRsiThreshold — нижняя граница RSI (в %), сигнализирующая поход в short