Skip to content
Merged

Dev #141

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a07f2ac
add: open-in-view 비활성화 설정 추가 (JpaBaseConfiguration$JpaWebConfiguratio…
caniro Jun 6, 2025
db9685c
chore: Github 메인 페이지 Readme 복원, 불필요 readme 파일 삭제
caniro Jun 6, 2025
fc4d14f
chore: (SonarQube) main 브랜치가 push되면 정적분석 수행되도록 추가
caniro Jun 6, 2025
b44a80a
fix: 계좌 생성 통합테스트 실패 조치
caniro Jun 6, 2025
bd816bc
test: 로그인 컨트롤러 테스트 추가
caniro Jun 6, 2025
c9d0668
test: JWT 필터 단위테스트 추가 및 리팩토링
caniro Jun 6, 2025
85efa12
test: 인증 핸들러 단위테스트 추가 및 리팩토링
caniro Jun 6, 2025
ce10118
test: OAuth 인증 관련 단위테스트 추가 및 리팩토링
caniro Jun 6, 2025
d9919eb
refactor: Wallet Service에서 Account Service 대신 Repository를 참조하도록 하여 순환…
caniro Jun 6, 2025
af25cf1
test: Wallet 통합테스트 추가 및 리팩토링
caniro Jun 6, 2025
bcc4fda
Merge pull request #132 from CleanEngine/config/common
caniro Jun 6, 2025
904c457
Merge pull request #133 from CleanEngine/feat/user
caniro Jun 7, 2025
c2a05a0
feat: 체결 완료 시 알림 기능 추가
caniro Jun 7, 2025
224a004
chore: 접근 제한자 private으로 변경
caniro Jun 7, 2025
ea6dd2b
fix: 봇에 대한 체결알림은 수행되지 않도록 변경
caniro Jun 7, 2025
d864f9e
fix: private 접근제한자 변경 이후 클라이언트에 공백으로 응답되던 문제 조치
caniro Jun 7, 2025
1314884
chore: 봇끼리의 체결은 로깅되지 않도록 변경
caniro Jun 7, 2025
946e6fc
feat: 체결이 연속적으로 처리되도록 로직 변경
caniro Jun 7, 2025
96a23f2
chore: 체결완료 Event 수신 시 TransactionalEventListener로 수신하도록 변경
caniro Jun 7, 2025
091bbe0
chore: 테스트 파일명 변경
caniro Jun 7, 2025
b00f67e
fix: 호가 갱신 로직 순서 변경 (#139)
Junh-b Jun 8, 2025
6238ad7
Merge pull request #138 from CleanEngine/feat/trade-notify
caniro Jun 8, 2025
eece674
Merge pull request #140 from CleanEngine/feat/trade-core
caniro Jun 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/code-analyze-sonarqube.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
- 'src/**'
push:
branches:
- main
- dev
paths:
- 'src/**'
Expand Down
94 changes: 94 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Invest Future
> 만약에 이때(<strong>IF</strong>) 투자했다면 지금 얼마를 벌었을까? <br/>
>
> 항상 저희는 상상을 하곤 합니다. ~~(이 때 돈 넣었으면 지금 4배는 벌었는데)~~<br/>
> 하지만 현실은 돈이 부족하기 떄문에 투자를 하기 쉽지 않습니다. <br/>
> <br/>
> IF에서는 모의투자를 통해 투자에 대한 경험을 쌓고, 투자에 대한 이해를 높일 수 있습니다. <br/>
<br/>
- 배포 URL : https://investfuture.my
<br/><br/>

## 다른 모의투자 서비스와의 차이점
많은 모의투자서비스는 3가지 유형이 있습니다.
1. 턴제 모의투자
2. 실시간 모의투자 (거래가 차트에 반영되지 않음)
3. 자체 시장 모의투자 (거래가 차트에 반영 됨)
<br/>

<table>
<thead>
<tr>
<td align="center"><img width="350" alt="Image" src="https://github.com/user-attachments/assets/5d346565-4660-467b-8df3-e8112b2e0c9c" /></td>
<td align="center"><img width="350" alt="Image" src="https://github.com/user-attachments/assets/466f8d9c-253a-40a3-afad-e46cddbcbdc8" /></td>
<td align="center"><img width="350" alt="Image" src="https://github.com/user-attachments/assets/d116963b-423b-4aee-a416-81e0d479d4c8" /></td>
</tr>
</thead>
<tbody>
<tr>
<th align="center"><strong>시장 개입 여부</strong></th>
<th align="center"><strong>DB 지원 여부</strong></th>
<th align="center"><strong>봇 지원 여부</strong></th>
</tr>
</tbody>
</table>


기존 모의투자 서비스는 실제 거래소의 시세를 지원하면 사용자의 투자 행동이 차트에 반영되지 않거나,<br/>
차트에 반영되지만 실제 시장과는 다른 자체 시장을 형성하는 서비스가 제공되고 있습니다.

Invest Future(<strong>IF</strong>)는 실제 시장의 추세를 따라가고, 사용자의 투자 행동이 차트에 반영되는 모의투자 서비스입니다.


<br/>
<br/>


## How it works?

> 기존 실시간 모의투자 서비스에서 사용자의 투자 행동이 차트에 반영되지 않는 이유는 사용자의 투자 행동이 차트에 반영될경우 시간이 지날수록 실제 시장의 차트와 모의투자 서비스의 차트의 괴리가 커지기 때문입니다. <br/>
> 비유하자면 시간이 지날수록 멀티버스가 발생합니다.

<img width="1348" alt="Image" src="https://github.com/user-attachments/assets/a850a73f-b77e-4765-877b-fb31782ad22c" />
Invest Future(<strong>IF</strong>)는 사용자의 투자 행동으로 서비스의 차트와 실제 시장의 차트의 괴리가 커지면 <u>자체 매수봇과 매도봇이 매수 매도를 하면서 <strong>실제 시장과의 차이를 보간</strong></u>합니다.




## 프로젝트 특징

<img width="2903" alt="Image" src="https://github.com/user-attachments/assets/d74aea6f-c605-48b6-b77b-42cb9be8565c" />

### 세부 로직
<details>
<summary><b> 주문/체결</b></summary>
<blockquote>
<img width="1400" alt="Image" src="https://github.com/user-attachments/assets/8e604703-8bb3-43b3-9d2a-e72ab53a3e6b" />
<p dir="auto"><strong>설명</strong> : </p>
</blockquote>
</details>

<details>
<summary><b> 보간 </b></summary>
<blockquote>
<img width="1200" alt="Image" src="https://github.com/user-attachments/assets/56e01e3a-a878-4921-ae40-692845d7989f" />
<p dir="auto"><strong>설명</strong> : </p>
</blockquote>
</details>

<details>
<summary><b> 차트/DB </b></summary>
<blockquote>
<img width="1500" alt="Image" src="https://github.com/user-attachments/assets/3a1850c1-54c9-46cc-b99d-dc0fbf59cd36" />
<p dir="auto"><strong>설명</strong> : </p>
</blockquote>
</details>



## 1차 구현 [5.2 ~ 5.15]

코인 한 개(트럼프 코인)을 매수 할 수 있습니다.
사용자의 주문에 따라 그래프차트와 호가창, 실시간 체결창에 반영됩니다.

https://github.com/user-attachments/assets/779b77f1-e778-4a53-b37f-d79a2dc187e8
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
Expand All @@ -25,7 +25,7 @@ public class TradeEventHandler {
private final RealTimeDataPrevRateService realTimeDataPrevRateService;
//event로 이벤틀 처리해야한다.
//eventListener는 void로 처리를 해야한다
@EventListener
@TransactionalEventListener
public void handleTradeEvent(TradeExecutedEvent event) {
Trade trade = event.getTrade();
if (trade == null) {
Expand Down
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,6 @@ public void run(ApplicationArguments args) throws Exception {
protected void restoreOrder(Order order){
WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(order.getTicker());
waitingOrders.addOrder(order);
updateOrderBookUsecase.updateOrderBookOnNewOrder(order);
updateOrderBookUsecase.updateOrderBookOnRestored(order);
}
}
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,21 @@ public class OrderBookService implements UpdateOrderBookUsecase, ReadOrderBookUs

@Override
public void updateOrderBookOnNewOrder(Order order) {
addToOrderBook(order, order.getOrderSize());
}

@Override
public void updateOrderBookOnRestored(Order order) {
addToOrderBook(order, order.getRemainingSize());
}

private void addToOrderBook(Order order, double size) {
if(order.getIsMarketOrder()){return;}
String ticker = order.getTicker();
activeOrders(ticker).saveOrder(order);

boolean isBuyOrder = order instanceof BuyOrder;
orderBookDomainService.updateOrderBookOnNewOrder(ticker, isBuyOrder, order.getPrice(), order.getRemainingSize());
orderBookDomainService.updateOrderBookOnNewOrder(ticker, isBuyOrder, order.getPrice(), size);

sendOrderBookUpdated(ticker);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
// TODO 메서드 parameter가 비슷한 스타일이어야 한다.
public interface UpdateOrderBookUsecase {
void updateOrderBookOnNewOrder(Order order);
void updateOrderBookOnRestored(Order order);
void updateOrderBookOnTradeExecuted(String ticker, Long buyOrderId, Long sellOrderId, Double orderSize);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.cleanengine.coin.order.adapter.in.event;
package com.cleanengine.coin.orderbook.infra;

import com.cleanengine.coin.order.application.event.OrderCreated;
import com.cleanengine.coin.orderbook.application.service.UpdateOrderBookUsecase;
import lombok.RequiredArgsConstructor;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;

Expand All @@ -11,6 +12,7 @@
public class SpringOrderCreatedUpdateOrderBookHandler {
private final UpdateOrderBookUsecase updateOrderBookUsecase;

@Order(1)
@TransactionalEventListener
public void handleOrderCreated(OrderCreated event) {
updateOrderBookUsecase.updateOrderBookOnNewOrder(event.order());
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.cleanengine.coin.trade.application;

import com.cleanengine.coin.trade.entity.Trade;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;

import static com.cleanengine.coin.common.CommonValues.BUY_ORDER_BOT_ID;
import static com.cleanengine.coin.common.CommonValues.SELL_ORDER_BOT_ID;

@Slf4j
@Component
public class TradeExecutedNotificationHandler {

private final SimpMessagingTemplate messagingTemplate;

private static final String ASK = "ask"; // 매도
private static final String BID = "bid"; // 매수

public TradeExecutedNotificationHandler(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}

@TransactionalEventListener
public void notifyAfterTradeExecuted(TradeExecutedEvent tradeExecutedEvent) {
Trade trade = tradeExecutedEvent.getTrade();
if (trade == null) {
log.error("체결 알림 실패! trade == null");
return ;
}

Integer sellUserId = trade.getSellUserId();
Integer buyUserId = trade.getBuyUserId();
if (sellUserId == null || buyUserId == null) {
log.error("체결 알림 실패! sellUserId: {}, buyUserId: {}", sellUserId, buyUserId);
return ;
}

if (sellUserId != SELL_ORDER_BOT_ID) {
TradeExecutedNotifyDto soldDto = TradeExecutedNotifyDto.of(trade, ASK);
messagingTemplate.convertAndSend("/topic/tradeNotification/" + sellUserId, soldDto);
}
if (buyUserId != BUY_ORDER_BOT_ID) {
TradeExecutedNotifyDto boughtDto = TradeExecutedNotifyDto.of(trade, BID);
messagingTemplate.convertAndSend("/topic/tradeNotification/" + buyUserId, boughtDto);
}
if (sellUserId != SELL_ORDER_BOT_ID || buyUserId != BUY_ORDER_BOT_ID) {
log.debug("{} 체결 이벤트 구독 : {}원에 {}개, 매수인: {}, 매도인: {}", trade.getTicker(), trade.getPrice(), trade.getSize(), buyUserId, sellUserId );
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.cleanengine.coin.trade.application;

import com.cleanengine.coin.trade.entity.Trade;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
@JsonPropertyOrder({"ticker", "price", "size", "type", "tradedTime"})
public class TradeExecutedNotifyDto {

private String ticker;

private Double price;

private Double size;

private String type;

private LocalDateTime tradedTime;

@Builder
private TradeExecutedNotifyDto(String ticker, Double price, Double size, String type, LocalDateTime tradedTime) {
this.ticker = ticker;
this.price = price;
this.size = size;
this.type = type;
this.tradedTime = tradedTime;
}

public static TradeExecutedNotifyDto of(Trade trade, String type) {
return TradeExecutedNotifyDto.builder()
.ticker(trade.getTicker())
.price(trade.getPrice())
.size(trade.getSize())
.type(type)
.tradedTime(trade.getTradeTime())
.build();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@
import com.cleanengine.coin.user.domain.Wallet;
import com.cleanengine.coin.user.info.application.AccountService;
import com.cleanengine.coin.user.info.application.WalletService;
import jakarta.transaction.Transactional;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

Expand All @@ -33,7 +34,7 @@ public class TradeExecutor {
private final TradeExecutedEventPublisher tradeExecutedEventPublisher;
private final TradeService tradeService;

@Transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void executeTrade(WaitingOrders waitingOrders, TradePair<Order, Order> tradePair, String ticker) {
BuyOrder buyOrder = tradePair.getBuyOrder();
SellOrder sellOrder = tradePair.getSellOrder();
Expand All @@ -48,7 +49,7 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair<Order, Order> tr
tradedPrice = tradeUnitPriceAndSize.tradedPrice();
if (approxEquals(tradedSize, 0.0)) {
log.debug("체결 중단! 체결 시도 수량 : {}, 매수단가 : {}, 매도단가 : {}", tradedSize, buyOrder.getPrice(), sellOrder.getPrice());
return;
return ;
}
this.writeTradingLog(buyOrder, sellOrder);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@

import com.cleanengine.coin.order.domain.Order;
import com.cleanengine.coin.order.domain.spi.WaitingOrders;
import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

Expand All @@ -17,14 +16,25 @@ public class TradeFlowService {

private final TradeMatcher tradeMatcher;
private final TradeExecutor tradeExecutor;
private final WaitingOrdersManager waitingOrdersManager;

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void execMatchAndTrade(String ticker) {
WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker);
WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker);
// TODO : peek() 해온 Order 객체들을 lock -> 체결 도중 취소 방지
Optional<TradePair<Order, Order>> tradePair = tradeMatcher.matchOrders(waitingOrders);
boolean continueProcessing = tradePair.isPresent();

tradePair.ifPresent(orderOrderTradePair -> tradeExecutor.executeTrade(waitingOrders, orderOrderTradePair, ticker));
while (continueProcessing) {
try {
tradeExecutor.executeTrade(waitingOrders, tradePair.get(), ticker);
tradePair = tradeMatcher.matchOrders(waitingOrders);
continueProcessing = tradePair.isPresent();
} catch (Exception e) {
// TODO : 회복 필요
log.error("Error processing trades for {}: {}", ticker, e.getMessage());
continueProcessing = false;
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import com.cleanengine.coin.order.domain.OrderType;
import com.cleanengine.coin.order.domain.SellOrder;
import com.cleanengine.coin.order.domain.spi.WaitingOrders;
import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

Expand All @@ -15,20 +14,10 @@
@Component
public class TradeMatcher {

private final WaitingOrdersManager waitingOrdersManager;

// 1초마다 로깅
private long lastLogTime = 0;
private static final long LOG_INTERVAL = 1000;

public TradeMatcher(WaitingOrdersManager waitingOrdersManager) {
this.waitingOrdersManager = waitingOrdersManager;
}

public WaitingOrders getWaitingOrders(String ticker) {
return waitingOrdersManager.getWaitingOrders(ticker);
}

public Optional<TradePair<Order, Order>> matchOrders(WaitingOrders waitingOrders) { // 반환값 : 체결여부
this.writeQueueLog(waitingOrders);

Expand Down
Loading
Loading