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); } + /** + * Возвращает потраченные деньги (нужно, чтобы стратегия не вышла за лимит, поставленный пользователем) + *

    Используется, например, при продаже бумаги стратегией

    + */ private void subtractFromBalance(User user, BigDecimal price) { spentByUser.merge(user, price, BigDecimal::subtract); } -} -record StopRequest(User user, String figi, BigDecimal takeProfit, BigDecimal stopLoss, OrderDirection direction) { + /** + * Добавить новый стоп запрос + */ + private void addStopRequest(StopRequest request) { + if (!openStopRequests.containsKey(request.figi())) { + openStopRequests.put(request.figi(), new ConcurrentLinkedQueue<>()); + } + openStopRequests.get(request.figi()).add(request); + } } diff --git a/src/main/java/ru/unclesema/ttb/service/price/StopRequest.java b/src/main/java/ru/unclesema/ttb/service/price/StopRequest.java new file mode 100644 index 0000000..b005684 --- /dev/null +++ b/src/main/java/ru/unclesema/ttb/service/price/StopRequest.java @@ -0,0 +1,9 @@ +package ru.unclesema.ttb.service.price; + +import ru.tinkoff.piapi.contract.v1.OrderDirection; +import ru.unclesema.ttb.model.User; + +import java.math.BigDecimal; + +record StopRequest(User user, String figi, BigDecimal takeProfit, BigDecimal stopLoss, OrderDirection direction) { +} diff --git a/src/main/java/ru/unclesema/ttb/service/user/UserService.java b/src/main/java/ru/unclesema/ttb/service/user/UserService.java new file mode 100644 index 0000000..0673f0c --- /dev/null +++ b/src/main/java/ru/unclesema/ttb/service/user/UserService.java @@ -0,0 +1,60 @@ +package ru.unclesema.ttb.service.user; + +import ru.unclesema.ttb.model.User; + +import java.util.List; +import java.util.Set; + +/** + * Сервис отвечающий за работу с пользователями. + * + *

    Пользователь становится активным, когда он включает для себя стратегию, и перестаёт быть таким, когда выключает её.

    + */ +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
  • * *

    + * + *

    Реализация взята из репозитория на GitHub

    */ @AllArgsConstructor @NoArgsConstructor diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css index 94d328c..bb677fd 100644 --- a/src/main/resources/static/css/style.css +++ b/src/main/resources/static/css/style.css @@ -117,7 +117,7 @@ form input, form select { margin-top: 5px; } -form button { +button { cursor: pointer; } diff --git a/src/main/resources/templates/error-page.html b/src/main/resources/templates/error-page.html index 8077ec7..10a5f22 100644 --- a/src/main/resources/templates/error-page.html +++ b/src/main/resources/templates/error-page.html @@ -21,7 +21,12 @@

    Произошла ошибка

    на GitHub.

    Текст ошибки:

    - +
    + +