투자관리 화면 수정

This commit is contained in:
Macbook
2026-05-31 17:34:26 +09:00
parent d6c2f3e451
commit 4f2d98d4ba
20 changed files with 696 additions and 56 deletions
@@ -18,5 +18,11 @@ public class PaperAllocationBulkRequest {
private Boolean isActive;
private Boolean pinned;
private Integer sortOrder;
private Double buyStage1Krw;
private Double buyStage2Krw;
private Double buyStage3Krw;
private Double sellStage1Krw;
private Double sellStage2Krw;
private Double sellStage3Krw;
}
}
@@ -20,4 +20,12 @@ public class PaperAllocationDto {
private Double evalAmount;
private Double symbolReturnPct;
private Double weightPct;
private Double buyStage1Krw;
private Double buyStage2Krw;
private Double buyStage3Krw;
private Double sellStage1Krw;
private Double sellStage2Krw;
private Double sellStage3Krw;
private Integer buyStageDone;
private Integer sellStageDone;
}
@@ -11,4 +11,10 @@ public class PaperAllocationPatchRequest {
private Boolean pinned;
private Integer sortOrder;
private String koreanName;
private Double buyStage1Krw;
private Double buyStage2Krw;
private Double buyStage3Krw;
private Double sellStage1Krw;
private Double sellStage2Krw;
private Double sellStage3Krw;
}
@@ -48,6 +48,38 @@ public class GcPaperSymbolAllocation {
@Builder.Default
private Integer sortOrder = 0;
@Column(name = "buy_stage1_krw", precision = 20, scale = 2, nullable = false)
@Builder.Default
private BigDecimal buyStage1Krw = BigDecimal.ZERO;
@Column(name = "buy_stage2_krw", precision = 20, scale = 2, nullable = false)
@Builder.Default
private BigDecimal buyStage2Krw = BigDecimal.ZERO;
@Column(name = "buy_stage3_krw", precision = 20, scale = 2, nullable = false)
@Builder.Default
private BigDecimal buyStage3Krw = BigDecimal.ZERO;
@Column(name = "sell_stage1_krw", precision = 20, scale = 2, nullable = false)
@Builder.Default
private BigDecimal sellStage1Krw = BigDecimal.ZERO;
@Column(name = "sell_stage2_krw", precision = 20, scale = 2, nullable = false)
@Builder.Default
private BigDecimal sellStage2Krw = BigDecimal.ZERO;
@Column(name = "sell_stage3_krw", precision = 20, scale = 2, nullable = false)
@Builder.Default
private BigDecimal sellStage3Krw = BigDecimal.ZERO;
@Column(name = "buy_stage_done", nullable = false)
@Builder.Default
private Integer buyStageDone = 0;
@Column(name = "sell_stage_done", nullable = false)
@Builder.Default
private Integer sellStageDone = 0;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -61,6 +61,8 @@ public class PaperAllocationService {
if (item.getIsActive() != null) row.setIsActive(item.getIsActive());
if (item.getPinned() != null) row.setPinned(item.getPinned());
row.setSortOrder(item.getSortOrder() != null ? item.getSortOrder() : order++);
applyStageAmounts(row, item.getBuyStage1Krw(), item.getBuyStage2Krw(), item.getBuyStage3Krw(),
item.getSellStage1Krw(), item.getSellStage2Krw(), item.getSellStage3Krw());
allocRepo.save(row);
}
return listWithMetrics(accountId, null, 0);
@@ -78,10 +80,37 @@ public class PaperAllocationService {
if (req.getPinned() != null) row.setPinned(req.getPinned());
if (req.getSortOrder() != null) row.setSortOrder(req.getSortOrder());
if (req.getKoreanName() != null) row.setKoreanName(req.getKoreanName());
applyStageAmounts(row, req.getBuyStage1Krw(), req.getBuyStage2Krw(), req.getBuyStage3Krw(),
req.getSellStage1Krw(), req.getSellStage2Krw(), req.getSellStage3Krw());
allocRepo.save(row);
return toDto(row, accountId, null, 0);
}
public void resetTradeStages(Long accountId, String symbol) {
allocRepo.findByAccountIdAndSymbol(accountId, symbol).ifPresent(row -> {
PaperTradeStageService.resetTradeStages(row);
allocRepo.save(row);
});
}
public void advanceBuyStageAfterTrade(Long accountId, String symbol) {
allocRepo.findByAccountIdAndSymbol(accountId, symbol).ifPresent(row -> {
PaperTradeStageService.advanceBuyStage(row);
allocRepo.save(row);
});
}
public void advanceSellStageAfterTrade(Long accountId, String symbol) {
allocRepo.findByAccountIdAndSymbol(accountId, symbol).ifPresent(row -> {
PaperTradeStageService.advanceSellStage(row);
allocRepo.save(row);
});
}
public GcPaperSymbolAllocation getOrDefaultEntity(Long accountId, String symbol, BigDecimal defaultMax) {
return getOrDefault(accountId, symbol, defaultMax);
}
public GcPaperSymbolAllocation getOrDefault(Long accountId, String symbol, BigDecimal defaultMax) {
return allocRepo.findByAccountIdAndSymbol(accountId, symbol)
.orElseGet(() -> {
@@ -191,6 +220,29 @@ public class PaperAllocationService {
.evalAmount(eval)
.symbolReturnPct(retPct)
.weightPct(weight)
.buyStage1Krw(stageKrw(a.getBuyStage1Krw()))
.buyStage2Krw(stageKrw(a.getBuyStage2Krw()))
.buyStage3Krw(stageKrw(a.getBuyStage3Krw()))
.sellStage1Krw(stageKrw(a.getSellStage1Krw()))
.sellStage2Krw(stageKrw(a.getSellStage2Krw()))
.sellStage3Krw(stageKrw(a.getSellStage3Krw()))
.buyStageDone(a.getBuyStageDone() != null ? a.getBuyStageDone() : 0)
.sellStageDone(a.getSellStageDone() != null ? a.getSellStageDone() : 0)
.build();
}
private static void applyStageAmounts(GcPaperSymbolAllocation row,
Double b1, Double b2, Double b3,
Double s1, Double s2, Double s3) {
if (b1 != null) row.setBuyStage1Krw(BigDecimal.valueOf(Math.max(0, b1)).setScale(2, RM));
if (b2 != null) row.setBuyStage2Krw(BigDecimal.valueOf(Math.max(0, b2)).setScale(2, RM));
if (b3 != null) row.setBuyStage3Krw(BigDecimal.valueOf(Math.max(0, b3)).setScale(2, RM));
if (s1 != null) row.setSellStage1Krw(BigDecimal.valueOf(Math.max(0, s1)).setScale(2, RM));
if (s2 != null) row.setSellStage2Krw(BigDecimal.valueOf(Math.max(0, s2)).setScale(2, RM));
if (s3 != null) row.setSellStage3Krw(BigDecimal.valueOf(Math.max(0, s3)).setScale(2, RM));
}
private static double stageKrw(BigDecimal v) {
return v != null ? v.doubleValue() : 0;
}
}
@@ -0,0 +1,91 @@
package com.goldenchart.service;
import com.goldenchart.entity.GcPaperSymbolAllocation;
import java.math.BigDecimal;
/**
* 모의투자 자동매매 — 종목별 1~3차 분할 매수·매도 금액·진행 단계.
*/
public final class PaperTradeStageService {
private static final int MAX_STAGES = 3;
private PaperTradeStageService() {}
public static boolean usesStagedTrading(GcPaperSymbolAllocation alloc) {
return sumBuyStages(alloc) > 0 || sumSellStages(alloc) > 0;
}
public static double sumBuyStages(GcPaperSymbolAllocation alloc) {
return stageKrw(alloc.getBuyStage1Krw())
+ stageKrw(alloc.getBuyStage2Krw())
+ stageKrw(alloc.getBuyStage3Krw());
}
public static double sumSellStages(GcPaperSymbolAllocation alloc) {
return stageKrw(alloc.getSellStage1Krw())
+ stageKrw(alloc.getSellStage2Krw())
+ stageKrw(alloc.getSellStage3Krw());
}
/** 다음 매수 단계(1~3) 금액. 없으면 0 */
public static double nextBuyStageKrw(GcPaperSymbolAllocation alloc) {
int done = stageDone(alloc.getBuyStageDone());
int next = done + 1;
if (next > MAX_STAGES) return 0;
return stageAmount(alloc, next, true);
}
/** 다음 매도 단계(1~3) 금액. 없으면 0 */
public static double nextSellStageKrw(GcPaperSymbolAllocation alloc) {
int done = stageDone(alloc.getSellStageDone());
int next = done + 1;
if (next > MAX_STAGES) return 0;
return stageAmount(alloc, next, false);
}
public static void advanceBuyStage(GcPaperSymbolAllocation alloc) {
int done = stageDone(alloc.getBuyStageDone());
if (done < MAX_STAGES) {
alloc.setBuyStageDone(done + 1);
}
}
public static void advanceSellStage(GcPaperSymbolAllocation alloc) {
int done = stageDone(alloc.getSellStageDone());
if (done < MAX_STAGES) {
alloc.setSellStageDone(done + 1);
}
}
public static void resetTradeStages(GcPaperSymbolAllocation alloc) {
alloc.setBuyStageDone(0);
alloc.setSellStageDone(0);
}
/** 매도 수량: 단계 금액 기준, 보유 가용 수량 상한 */
public static double sellQtyForStageKrw(double stageKrw, double availQty, double execPrice) {
if (stageKrw <= 0 || availQty <= 0 || execPrice <= 0) return 0;
double byKrw = stageKrw / execPrice;
return Math.min(availQty, byKrw);
}
private static int stageDone(Integer done) {
if (done == null || done < 0) return 0;
return Math.min(MAX_STAGES, done);
}
private static double stageAmount(GcPaperSymbolAllocation alloc, int stage1Based, boolean buy) {
return switch (stage1Based) {
case 1 -> buy ? stageKrw(alloc.getBuyStage1Krw()) : stageKrw(alloc.getSellStage1Krw());
case 2 -> buy ? stageKrw(alloc.getBuyStage2Krw()) : stageKrw(alloc.getSellStage2Krw());
case 3 -> buy ? stageKrw(alloc.getBuyStage3Krw()) : stageKrw(alloc.getSellStage3Krw());
default -> 0;
};
}
private static double stageKrw(BigDecimal v) {
return v != null ? Math.max(0, v.doubleValue()) : 0;
}
}
@@ -290,41 +290,85 @@ public class PaperTradingService {
.filter(m -> m != null && !m.isBlank())
.findFirst()
.orElse("LONG_ONLY");
if ("LONG_ONLY".equals(positionMode)) {
GcPaperPosition posNow = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElse(null);
double held = posNow != null ? posNow.getQuantity().doubleValue() : 0;
BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null
? account.getInitialCapitalSnapshot()
: (app.getPaperInitialCapital() != null
? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000));
GcPaperSymbolAllocation alloc = allocationService.getOrDefaultEntity(
account.getId(), market, defaultMax);
boolean staged = PaperTradeStageService.usesStagedTrading(alloc);
GcPaperPosition posNow = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElse(null);
double held = posNow != null ? posNow.getQuantity().doubleValue() : 0;
if ("LONG_ONLY".equals(positionMode) && !staged) {
if ("BUY".equals(signalType) && held > 0) return;
if ("SELL".equals(signalType) && held <= 0) return;
}
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
double slip = pct(app.getPaperSlippagePct(), 0);
double minOrder = app.getPaperMinOrderKrw() != null
? app.getPaperMinOrderKrw().doubleValue() : 5000;
if ("BUY".equals(signalType)) {
double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95);
double cash = account.getCashBalance().doubleValue();
double reserved = account.getReservedCash() != null
? account.getReservedCash().doubleValue() : 0;
double budget = (cash - reserved) * budgetPct / 100.0;
double execPrice = price * (1 + slip / 100.0);
double denom = execPrice * (1 + feeRate / 100.0);
if (denom <= 0 || budget < minOrder) return;
double qty = budget / denom;
if (qty * execPrice < minOrder) return;
executeTrade(uid, app, market, "BUY", "market", "STRATEGY",
strategyId, price, qty, null, true);
log.info("[Paper] STRATEGY BUY {} qty≈{} @ {}", market, qty, price);
if (staged) {
if (held <= 0) {
allocationService.resetTradeStages(account.getId(), market);
alloc = allocationService.getOrDefaultEntity(account.getId(), market, defaultMax);
} else if ("LONG_ONLY".equals(positionMode)
&& alloc.getBuyStageDone() != null && alloc.getBuyStageDone() >= 3) {
return;
}
double stageKrw = PaperTradeStageService.nextBuyStageKrw(alloc);
if (stageKrw <= 0) return;
if (stageKrw < minOrder) return;
double execPrice = price * (1 + slip / 100.0);
double denom = execPrice * (1 + feeRate / 100.0);
if (denom <= 0) return;
double qty = stageKrw / denom;
if (qty * execPrice < minOrder) return;
executeTrade(uid, app, market, "BUY", "market", "STRATEGY",
strategyId, price, qty, null, true);
allocationService.advanceBuyStageAfterTrade(account.getId(), market);
log.info("[Paper] STRATEGY BUY (staged) {} qty≈{} @ {}", market, qty, price);
} else {
double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95);
double cash = account.getCashBalance().doubleValue();
double reserved = account.getReservedCash() != null
? account.getReservedCash().doubleValue() : 0;
double budget = (cash - reserved) * budgetPct / 100.0;
double execPrice = price * (1 + slip / 100.0);
double denom = execPrice * (1 + feeRate / 100.0);
if (denom <= 0 || budget < minOrder) return;
double qty = budget / denom;
if (qty * execPrice < minOrder) return;
executeTrade(uid, app, market, "BUY", "market", "STRATEGY",
strategyId, price, qty, null, true);
log.info("[Paper] STRATEGY BUY {} qty≈{} @ {}", market, qty, price);
}
} else {
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElse(null);
if (pos == null || pos.getQuantity().doubleValue() <= 0) return;
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
if (held <= 0) return;
double avail = availableSellQty(account.getId(), market, held);
if (avail <= 0) return;
executeTrade(uid, app, market, "SELL", "market", "STRATEGY",
strategyId, price, avail, null, true);
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, avail, price);
if (staged) {
double stageKrw = PaperTradeStageService.nextSellStageKrw(alloc);
if (stageKrw <= 0) return;
if (stageKrw < minOrder) return;
double execPrice = price * (1 - slip / 100.0);
double qty = PaperTradeStageService.sellQtyForStageKrw(stageKrw, avail, execPrice);
if (qty * execPrice < minOrder) return;
executeTrade(uid, app, market, "SELL", "market", "STRATEGY",
strategyId, price, qty, null, true);
allocationService.advanceSellStageAfterTrade(account.getId(), market);
log.info("[Paper] STRATEGY SELL (staged) {} qty={} @ {}", market, qty, price);
} else {
executeTrade(uid, app, market, "SELL", "market", "STRATEGY",
strategyId, price, avail, null, true);
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, avail, price);
}
}
} catch (Exception e) {
log.warn("[Paper] auto trade failed {} {}: {}", market, signalType, e.getMessage());
@@ -591,6 +635,9 @@ public class PaperTradingService {
.positionQtyAfter(qtyAfter)
.build());
ledgerService.recordSellSettle(account, trade, netProceeds);
if ("STRATEGY".equals(source) && qtyAfter.compareTo(BigDecimal.valueOf(0.00000001)) <= 0) {
allocationService.resetTradeStages(account.getId(), market);
}
return toTradeDto(trade);
}
@@ -0,0 +1,10 @@
-- 종목별 분할 매수·매도 단계 (1~3차) 및 진행 상태
ALTER TABLE gc_paper_symbol_allocation
ADD COLUMN buy_stage1_krw DECIMAL(20, 2) NOT NULL DEFAULT 0 COMMENT '1차 매수 금액',
ADD COLUMN buy_stage2_krw DECIMAL(20, 2) NOT NULL DEFAULT 0 COMMENT '2차 매수 금액',
ADD COLUMN buy_stage3_krw DECIMAL(20, 2) NOT NULL DEFAULT 0 COMMENT '3차 매수 금액',
ADD COLUMN sell_stage1_krw DECIMAL(20, 2) NOT NULL DEFAULT 0 COMMENT '1차 매도 금액',
ADD COLUMN sell_stage2_krw DECIMAL(20, 2) NOT NULL DEFAULT 0 COMMENT '2차 매도 금액',
ADD COLUMN sell_stage3_krw DECIMAL(20, 2) NOT NULL DEFAULT 0 COMMENT '3차 매도 금액',
ADD COLUMN buy_stage_done TINYINT NOT NULL DEFAULT 0 COMMENT '완료한 매수 단계 0~3',
ADD COLUMN sell_stage_done TINYINT NOT NULL DEFAULT 0 COMMENT '완료한 매도 단계 0~3';
@@ -0,0 +1,4 @@
-- V55 TINYINT → Hibernate Integer 매핑과 맞추기 (schema-validation)
ALTER TABLE gc_paper_symbol_allocation
MODIFY COLUMN buy_stage_done INT NOT NULL DEFAULT 0 COMMENT '완료한 매수 단계 0~3',
MODIFY COLUMN sell_stage_done INT NOT NULL DEFAULT 0 COMMENT '완료한 매도 단계 0~3';