diff --git a/build.gradle b/build.gradle index f06e334..d23b569 100644 --- a/build.gradle +++ b/build.gradle @@ -27,9 +27,15 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' - developmentOnly 'org.springframework.boot:spring-boot-devtools' annotationProcessor 'org.projectlombok:lombok' + + developmentOnly 'org.springframework.boot:spring-boot-devtools' + + runtimeOnly 'org.postgresql:postgresql' + runtimeOnly 'com.h2database:h2' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b65098d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: trademill + POSTGRES_USER: tm + POSTGRES_PASSWORD: tm + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data +volumes: + pgdata: {} diff --git a/src/main/java/trademill/apiserver/broker/BrokerGateway.java b/src/main/java/trademill/apiserver/broker/BrokerGateway.java new file mode 100644 index 0000000..3d0ab10 --- /dev/null +++ b/src/main/java/trademill/apiserver/broker/BrokerGateway.java @@ -0,0 +1,12 @@ +package trademill.apiserver.broker; + +import java.math.BigDecimal; + + +// BrokerGateway : '증권사와 어떻게 대화할지' 계약서(포트) 역할 +public interface BrokerGateway { + String placeMarketBuy(String symbol, BigDecimal quantity); + String placeMarketSell(String symbol, BigDecimal quantity); + String placeLimitBuy(String symbol, BigDecimal quantity, BigDecimal price); + String placeLimitSell(String symbol, BigDecimal quantity, BigDecimal price); +} diff --git a/src/main/java/trademill/apiserver/broker/FakeBrokerGateway.java b/src/main/java/trademill/apiserver/broker/FakeBrokerGateway.java new file mode 100644 index 0000000..5d4a45a --- /dev/null +++ b/src/main/java/trademill/apiserver/broker/FakeBrokerGateway.java @@ -0,0 +1,20 @@ +package trademill.apiserver.broker; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.UUID; + +@Profile({"local","dev","default"}) +@Component + +// FakeBrokerGateway : 실제 증권사 호출 대신 로컬 개발/테스트용으로 응답을 꾸며주는 구현체(어댑터) +public class FakeBrokerGateway implements BrokerGateway { + private String id(){ return "SIM-" + UUID.randomUUID(); } + + public String placeMarketBuy(String s, BigDecimal q){ return id(); } + public String placeMarketSell(String s, BigDecimal q){ return id(); } + public String placeLimitBuy(String s, BigDecimal q, BigDecimal p){ return id(); } + public String placeLimitSell(String s, BigDecimal q, BigDecimal p){ return id(); } +} diff --git a/src/main/java/trademill/apiserver/order/Order.java b/src/main/java/trademill/apiserver/order/Order.java new file mode 100644 index 0000000..3397efd --- /dev/null +++ b/src/main/java/trademill/apiserver/order/Order.java @@ -0,0 +1,42 @@ +package trademill.apiserver.order; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "orders") +@Getter @Setter @NoArgsConstructor +public class Order { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; + private String symbol; + + @Enumerated(EnumType.STRING) private OrderSide side; + @Enumerated(EnumType.STRING) private OrderType orderType; + + private BigDecimal quantity; + private BigDecimal price; // MARKET이면 null 가능 + + @Enumerated(EnumType.STRING) private OrderStatus status; + private String brokerOrderId; + + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + @PrePersist + void onCreate() { + createdAt = OffsetDateTime.now(); + updatedAt = createdAt; + if (status == null) status = OrderStatus.PENDING; + } + @PreUpdate + void onUpdate() { updatedAt = OffsetDateTime.now(); } +} diff --git a/src/main/java/trademill/apiserver/order/OrderController.java b/src/main/java/trademill/apiserver/order/OrderController.java new file mode 100644 index 0000000..19c157a --- /dev/null +++ b/src/main/java/trademill/apiserver/order/OrderController.java @@ -0,0 +1,33 @@ +package trademill.apiserver.order; + +import jakarta.validation.Valid; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.annotation.*; +import trademill.apiserver.order.dto.*; + +@RestController +@RequestMapping("/api/v1/orders") +public class OrderController { + private final OrderService service; + public OrderController(OrderService service){ this.service = service; } + + @PostMapping("/buy") + public OrderResponse buy(@Valid @RequestBody PlaceOrderRequest req){ + return OrderResponse.from(service.placeBuy(req)); + } + + @PostMapping("/sell") + public OrderResponse sell(@Valid @RequestBody PlaceOrderRequest req){ + return OrderResponse.from(service.placeSell(req)); + } + + @GetMapping + public Page list(@RequestParam Long userId, + @RequestParam(defaultValue="0") int page, + @RequestParam(defaultValue="20") int size){ + return service.list(userId, PageRequest.of(page, size, Sort.by("id").descending())) + .map(OrderResponse::from); + } +} diff --git a/src/main/java/trademill/apiserver/order/OrderRepository.java b/src/main/java/trademill/apiserver/order/OrderRepository.java new file mode 100644 index 0000000..4b1967e --- /dev/null +++ b/src/main/java/trademill/apiserver/order/OrderRepository.java @@ -0,0 +1,8 @@ +package trademill.apiserver.order; + +import org.springframework.data.domain.*; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderRepository extends JpaRepository { + Page findByUserId(Long userId, Pageable pageable); +} diff --git a/src/main/java/trademill/apiserver/order/OrderService.java b/src/main/java/trademill/apiserver/order/OrderService.java new file mode 100644 index 0000000..4811958 --- /dev/null +++ b/src/main/java/trademill/apiserver/order/OrderService.java @@ -0,0 +1,56 @@ +package trademill.apiserver.order; + +import org.springframework.data.domain.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import trademill.apiserver.broker.BrokerGateway; +import trademill.apiserver.order.dto.PlaceOrderRequest; + +@Service +public class OrderService { + private final OrderRepository orders; + private final BrokerGateway broker; + + public OrderService(OrderRepository orders, BrokerGateway broker) { + this.orders = orders; + this.broker = broker; + } + + @Transactional + public Order placeBuy(PlaceOrderRequest req){ + Order o = baseOf(req, OrderSide.BUY); + String id = switch (req.getOrderType()){ + case MARKET -> broker.placeMarketBuy(req.getSymbol(), req.getQuantity()); + case LIMIT -> broker.placeLimitBuy(req.getSymbol(), req.getQuantity(), req.getPrice()); + }; + o.setBrokerOrderId(id); o.setStatus(OrderStatus.ACCEPTED); + return orders.save(o); + } + + @Transactional + public Order placeSell(PlaceOrderRequest req){ + Order o = baseOf(req, OrderSide.SELL); + String id = switch (req.getOrderType()){ + case MARKET -> broker.placeMarketSell(req.getSymbol(), req.getQuantity()); + case LIMIT -> broker.placeLimitSell(req.getSymbol(), req.getQuantity(), req.getPrice()); + }; + o.setBrokerOrderId(id); o.setStatus(OrderStatus.ACCEPTED); + return orders.save(o); + } + + public Page list(Long userId, Pageable pageable){ + return orders.findByUserId(userId, pageable); + } + + private Order baseOf(PlaceOrderRequest req, OrderSide side){ + Order o = new Order(); + o.setUserId(req.getUserId()); + o.setSymbol(req.getSymbol()); + o.setSide(side); + o.setOrderType(req.getOrderType()); + o.setQuantity(req.getQuantity()); + o.setPrice(req.getPrice()); + o.setStatus(OrderStatus.PENDING); + return o; + } +} diff --git a/src/main/java/trademill/apiserver/order/OrderSide.java b/src/main/java/trademill/apiserver/order/OrderSide.java new file mode 100644 index 0000000..ade2750 --- /dev/null +++ b/src/main/java/trademill/apiserver/order/OrderSide.java @@ -0,0 +1,3 @@ +package trademill.apiserver.order; + +public enum OrderSide {BUY, SELL} diff --git a/src/main/java/trademill/apiserver/order/OrderStatus.java b/src/main/java/trademill/apiserver/order/OrderStatus.java new file mode 100644 index 0000000..46b4257 --- /dev/null +++ b/src/main/java/trademill/apiserver/order/OrderStatus.java @@ -0,0 +1,3 @@ +package trademill.apiserver.order; + +public enum OrderStatus {PENDING, ACCEPTED, PARTIALLY_FILLED, FILLED, REJECTED, CANCELED} diff --git a/src/main/java/trademill/apiserver/order/OrderType.java b/src/main/java/trademill/apiserver/order/OrderType.java new file mode 100644 index 0000000..dd950b8 --- /dev/null +++ b/src/main/java/trademill/apiserver/order/OrderType.java @@ -0,0 +1,3 @@ +package trademill.apiserver.order; + +public enum OrderType {MARKET, LIMIT} diff --git a/src/main/java/trademill/apiserver/order/dto/OrderResponse.java b/src/main/java/trademill/apiserver/order/dto/OrderResponse.java new file mode 100644 index 0000000..7fc0d51 --- /dev/null +++ b/src/main/java/trademill/apiserver/order/dto/OrderResponse.java @@ -0,0 +1,38 @@ +package trademill.apiserver.order.dto; + +import lombok.Data; +import trademill.apiserver.order.*; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +@Data +public class OrderResponse { + private Long id; + private Long userId; + private String symbol; + private OrderSide side; + private OrderType orderType; + private BigDecimal quantity; + private BigDecimal price; + private OrderStatus status; + private String brokerOrderId; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public static OrderResponse from(Order o) { + OrderResponse r = new OrderResponse(); + r.setId(o.getId()); + r.setUserId(o.getUserId()); + r.setSymbol(o.getSymbol()); + r.setSide(o.getSide()); + r.setOrderType(o.getOrderType()); + r.setQuantity(o.getQuantity()); + r.setPrice(o.getPrice()); + r.setStatus(o.getStatus()); + r.setBrokerOrderId(o.getBrokerOrderId()); + r.setCreatedAt(o.getCreatedAt()); + r.setUpdatedAt(o.getUpdatedAt()); + return r; + } +} diff --git a/src/main/java/trademill/apiserver/order/dto/PlaceOrderRequest.java b/src/main/java/trademill/apiserver/order/dto/PlaceOrderRequest.java new file mode 100644 index 0000000..91b47c5 --- /dev/null +++ b/src/main/java/trademill/apiserver/order/dto/PlaceOrderRequest.java @@ -0,0 +1,17 @@ +package trademill.apiserver.order.dto; + +import jakarta.validation.constraints.*; +import lombok.Getter; +import lombok.Setter; +import trademill.apiserver.order.OrderType; + +import java.math.BigDecimal; + +@Getter @Setter +public class PlaceOrderRequest { + @NotNull private Long userId; + @NotBlank private String symbol; + @NotNull private OrderType orderType; // MARKET or LIMIT + @NotNull @DecimalMin("0.00000001") private BigDecimal quantity; + @DecimalMin("0.0") private BigDecimal price; // LIMIT일 때만 필요 +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..ce0bbeb --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,11 @@ +spring: + datasource: + url: jdbc:h2:mem:tm;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1 + username: sa + password: + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: update +server: + port: 8080