투자관리 화면 수정
This commit is contained in:
@@ -18,5 +18,11 @@ public class PaperAllocationBulkRequest {
|
|||||||
private Boolean isActive;
|
private Boolean isActive;
|
||||||
private Boolean pinned;
|
private Boolean pinned;
|
||||||
private Integer sortOrder;
|
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 evalAmount;
|
||||||
private Double symbolReturnPct;
|
private Double symbolReturnPct;
|
||||||
private Double weightPct;
|
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 Boolean pinned;
|
||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
private String koreanName;
|
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
|
@Builder.Default
|
||||||
private Integer sortOrder = 0;
|
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)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ public class PaperAllocationService {
|
|||||||
if (item.getIsActive() != null) row.setIsActive(item.getIsActive());
|
if (item.getIsActive() != null) row.setIsActive(item.getIsActive());
|
||||||
if (item.getPinned() != null) row.setPinned(item.getPinned());
|
if (item.getPinned() != null) row.setPinned(item.getPinned());
|
||||||
row.setSortOrder(item.getSortOrder() != null ? item.getSortOrder() : order++);
|
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);
|
allocRepo.save(row);
|
||||||
}
|
}
|
||||||
return listWithMetrics(accountId, null, 0);
|
return listWithMetrics(accountId, null, 0);
|
||||||
@@ -78,10 +80,37 @@ public class PaperAllocationService {
|
|||||||
if (req.getPinned() != null) row.setPinned(req.getPinned());
|
if (req.getPinned() != null) row.setPinned(req.getPinned());
|
||||||
if (req.getSortOrder() != null) row.setSortOrder(req.getSortOrder());
|
if (req.getSortOrder() != null) row.setSortOrder(req.getSortOrder());
|
||||||
if (req.getKoreanName() != null) row.setKoreanName(req.getKoreanName());
|
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);
|
allocRepo.save(row);
|
||||||
return toDto(row, accountId, null, 0);
|
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) {
|
public GcPaperSymbolAllocation getOrDefault(Long accountId, String symbol, BigDecimal defaultMax) {
|
||||||
return allocRepo.findByAccountIdAndSymbol(accountId, symbol)
|
return allocRepo.findByAccountIdAndSymbol(accountId, symbol)
|
||||||
.orElseGet(() -> {
|
.orElseGet(() -> {
|
||||||
@@ -191,6 +220,29 @@ public class PaperAllocationService {
|
|||||||
.evalAmount(eval)
|
.evalAmount(eval)
|
||||||
.symbolReturnPct(retPct)
|
.symbolReturnPct(retPct)
|
||||||
.weightPct(weight)
|
.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();
|
.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())
|
.filter(m -> m != null && !m.isBlank())
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse("LONG_ONLY");
|
.orElse("LONG_ONLY");
|
||||||
if ("LONG_ONLY".equals(positionMode)) {
|
BigDecimal defaultMax = account.getInitialCapitalSnapshot() != null
|
||||||
GcPaperPosition posNow = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
|
? account.getInitialCapitalSnapshot()
|
||||||
.orElse(null);
|
: (app.getPaperInitialCapital() != null
|
||||||
double held = posNow != null ? posNow.getQuantity().doubleValue() : 0;
|
? 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 ("BUY".equals(signalType) && held > 0) return;
|
||||||
if ("SELL".equals(signalType) && held <= 0) return;
|
if ("SELL".equals(signalType) && held <= 0) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
|
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
|
||||||
double slip = pct(app.getPaperSlippagePct(), 0);
|
double slip = pct(app.getPaperSlippagePct(), 0);
|
||||||
double minOrder = app.getPaperMinOrderKrw() != null
|
double minOrder = app.getPaperMinOrderKrw() != null
|
||||||
? app.getPaperMinOrderKrw().doubleValue() : 5000;
|
? app.getPaperMinOrderKrw().doubleValue() : 5000;
|
||||||
|
|
||||||
if ("BUY".equals(signalType)) {
|
if ("BUY".equals(signalType)) {
|
||||||
double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95);
|
if (staged) {
|
||||||
double cash = account.getCashBalance().doubleValue();
|
if (held <= 0) {
|
||||||
double reserved = account.getReservedCash() != null
|
allocationService.resetTradeStages(account.getId(), market);
|
||||||
? account.getReservedCash().doubleValue() : 0;
|
alloc = allocationService.getOrDefaultEntity(account.getId(), market, defaultMax);
|
||||||
double budget = (cash - reserved) * budgetPct / 100.0;
|
} else if ("LONG_ONLY".equals(positionMode)
|
||||||
double execPrice = price * (1 + slip / 100.0);
|
&& alloc.getBuyStageDone() != null && alloc.getBuyStageDone() >= 3) {
|
||||||
double denom = execPrice * (1 + feeRate / 100.0);
|
return;
|
||||||
if (denom <= 0 || budget < minOrder) return;
|
}
|
||||||
double qty = budget / denom;
|
double stageKrw = PaperTradeStageService.nextBuyStageKrw(alloc);
|
||||||
if (qty * execPrice < minOrder) return;
|
if (stageKrw <= 0) return;
|
||||||
executeTrade(uid, app, market, "BUY", "market", "STRATEGY",
|
if (stageKrw < minOrder) return;
|
||||||
strategyId, price, qty, null, true);
|
double execPrice = price * (1 + slip / 100.0);
|
||||||
log.info("[Paper] STRATEGY BUY {} qty≈{} @ {}", market, qty, price);
|
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 {
|
} else {
|
||||||
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
|
if (held <= 0) return;
|
||||||
.orElse(null);
|
double avail = availableSellQty(account.getId(), market, held);
|
||||||
if (pos == null || pos.getQuantity().doubleValue() <= 0) return;
|
|
||||||
double avail = availableSellQty(account.getId(), market, pos.getQuantity().doubleValue());
|
|
||||||
if (avail <= 0) return;
|
if (avail <= 0) return;
|
||||||
executeTrade(uid, app, market, "SELL", "market", "STRATEGY",
|
|
||||||
strategyId, price, avail, null, true);
|
if (staged) {
|
||||||
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, avail, price);
|
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) {
|
} catch (Exception e) {
|
||||||
log.warn("[Paper] auto trade failed {} {}: {}", market, signalType, e.getMessage());
|
log.warn("[Paper] auto trade failed {} {}: {}", market, signalType, e.getMessage());
|
||||||
@@ -591,6 +635,9 @@ public class PaperTradingService {
|
|||||||
.positionQtyAfter(qtyAfter)
|
.positionQtyAfter(qtyAfter)
|
||||||
.build());
|
.build());
|
||||||
ledgerService.recordSellSettle(account, trade, netProceeds);
|
ledgerService.recordSellSettle(account, trade, netProceeds);
|
||||||
|
if ("STRATEGY".equals(source) && qtyAfter.compareTo(BigDecimal.valueOf(0.00000001)) <= 0) {
|
||||||
|
allocationService.resetTradeStages(account.getId(), market);
|
||||||
|
}
|
||||||
return toTradeDto(trade);
|
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';
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.goldenchart.service;
|
||||||
|
|
||||||
|
import com.goldenchart.entity.GcPaperSymbolAllocation;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class PaperTradeStageServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stagedBuyAdvancesBySignal() {
|
||||||
|
GcPaperSymbolAllocation a = GcPaperSymbolAllocation.builder()
|
||||||
|
.buyStage1Krw(BigDecimal.valueOf(100_000))
|
||||||
|
.buyStage2Krw(BigDecimal.valueOf(200_000))
|
||||||
|
.buyStageDone(0)
|
||||||
|
.build();
|
||||||
|
assertTrue(PaperTradeStageService.usesStagedTrading(a));
|
||||||
|
assertEquals(100_000, PaperTradeStageService.nextBuyStageKrw(a));
|
||||||
|
PaperTradeStageService.advanceBuyStage(a);
|
||||||
|
assertEquals(200_000, PaperTradeStageService.nextBuyStageKrw(a));
|
||||||
|
assertEquals(0, PaperTradeStageService.nextSellStageKrw(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetAfterFlatPosition() {
|
||||||
|
GcPaperSymbolAllocation a = GcPaperSymbolAllocation.builder()
|
||||||
|
.buyStageDone(2)
|
||||||
|
.sellStageDone(1)
|
||||||
|
.build();
|
||||||
|
PaperTradeStageService.resetTradeStages(a);
|
||||||
|
assertEquals(0, a.getBuyStageDone());
|
||||||
|
assertEquals(0, a.getSellStageDone());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sellQtyCappedByAvailable() {
|
||||||
|
double qty = PaperTradeStageService.sellQtyForStageKrw(500_000, 0.5, 100_000);
|
||||||
|
assertEquals(0.5, qty, 1e-9);
|
||||||
|
double partial = PaperTradeStageService.sellQtyForStageKrw(500_000, 0.001, 100_000);
|
||||||
|
assertEquals(0.001, partial, 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,6 +104,8 @@ const PaperTradingPage: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
|
||||||
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
|
||||||
|
/** 우측 거래내역 목록에서 선택한 체결 행 */
|
||||||
|
const [selectedTradeId, setSelectedTradeId] = useState<number | null>(null);
|
||||||
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
const orderAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -185,10 +187,14 @@ const PaperTradingPage: React.FC<Props> = ({
|
|||||||
return coerceFiniteNumber(p?.quantity) ?? 0;
|
return coerceFiniteNumber(p?.quantity) ?? 0;
|
||||||
}, [s?.positions, selectedMarket]);
|
}, [s?.positions, selectedMarket]);
|
||||||
|
|
||||||
const investmentTargets = useMemo(
|
/** 가상매매 투자대상 순서·종목과 일치 (allocations는 sync 부가 정보) */
|
||||||
() => s?.allocations ?? [],
|
const investmentTargets = useMemo(() => {
|
||||||
[s?.allocations],
|
const allocs = s?.allocations ?? [];
|
||||||
);
|
const bySymbol = new Map(allocs.map(a => [a.symbol, a]));
|
||||||
|
return virtualTargets
|
||||||
|
.map(t => bySymbol.get(t.market))
|
||||||
|
.filter((a): a is NonNullable<typeof a> => a != null);
|
||||||
|
}, [s?.allocations, virtualTargets]);
|
||||||
|
|
||||||
const selectedAlloc = useMemo(
|
const selectedAlloc = useMemo(
|
||||||
() => investmentTargets.find(a => a.symbol === selectedMarket),
|
() => investmentTargets.find(a => a.symbol === selectedMarket),
|
||||||
@@ -236,17 +242,29 @@ const PaperTradingPage: React.FC<Props> = ({
|
|||||||
openingPrice: selectedTicker.openingPrice,
|
openingPrice: selectedTicker.openingPrice,
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
const handleSelectMarket = useCallback((market: string) => {
|
const applyMarketAndOrderPrice = useCallback((market: string, price?: number | null) => {
|
||||||
setSelectedMarket(market);
|
setSelectedMarket(market);
|
||||||
const price = tickers?.get(market)?.tradePrice;
|
const fillPrice = price != null && price > 0 ? price : tickers?.get(market)?.tradePrice;
|
||||||
if (price != null && price > 0) {
|
if (fillPrice != null && fillPrice > 0) {
|
||||||
const seq = Date.now();
|
const seq = Date.now();
|
||||||
setFillBuy({ market, price, side: 'buy', seq });
|
setFillBuy({ market, price: fillPrice, side: 'buy', seq });
|
||||||
setFillSell({ market, price, side: 'sell', seq: seq + 1 });
|
setFillSell({ market, price: fillPrice, side: 'sell', seq: seq + 1 });
|
||||||
}
|
}
|
||||||
setRightTab('trade');
|
|
||||||
}, [tickers]);
|
}, [tickers]);
|
||||||
|
|
||||||
|
const handleSelectMarket = useCallback((market: string) => {
|
||||||
|
setSelectedTradeId(null);
|
||||||
|
applyMarketAndOrderPrice(market);
|
||||||
|
setRightTab('trade');
|
||||||
|
}, [applyMarketAndOrderPrice]);
|
||||||
|
|
||||||
|
/** 거래내역 클릭 — 차트·매매 가격만 반영, 우측 탭은 거래내역 유지 */
|
||||||
|
const handleTradeHistorySelect = useCallback((trade: PaperTradeDto) => {
|
||||||
|
setSelectedTradeId(trade.id);
|
||||||
|
applyMarketAndOrderPrice(trade.symbol, trade.price);
|
||||||
|
setCenterTab('chart');
|
||||||
|
}, [applyMarketAndOrderPrice]);
|
||||||
|
|
||||||
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
||||||
setRightTab('trade');
|
setRightTab('trade');
|
||||||
const side = rowType === 'bid' ? 'buy' : 'sell';
|
const side = rowType === 'bid' ? 'buy' : 'sell';
|
||||||
@@ -314,11 +332,15 @@ const PaperTradingPage: React.FC<Props> = ({
|
|||||||
<PaperLeftStrategyTab
|
<PaperLeftStrategyTab
|
||||||
market={selectedMarket}
|
market={selectedMarket}
|
||||||
summary={s}
|
summary={s}
|
||||||
|
virtualTargets={virtualTargets}
|
||||||
|
tickers={tickers}
|
||||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
onMarketSelect={handleSelectMarket}
|
onMarketSelect={handleSelectMarket}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PaperLeftSettingsTab
|
<PaperLeftSettingsTab
|
||||||
|
selectedMarket={selectedMarket}
|
||||||
|
allocations={s?.allocations ?? []}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
onSettingsSaved={() => void loadData()}
|
onSettingsSaved={() => void loadData()}
|
||||||
onAccountReset={() => { setSummary(null); setTrades([]); void loadData(); }}
|
onAccountReset={() => { setSummary(null); setTrades([]); void loadData(); }}
|
||||||
@@ -441,7 +463,8 @@ const PaperTradingPage: React.FC<Props> = ({
|
|||||||
trades={trades}
|
trades={trades}
|
||||||
strategyNames={strategyNames}
|
strategyNames={strategyNames}
|
||||||
tickers={tickers}
|
tickers={tickers}
|
||||||
onSelectMarket={handleSelectMarket}
|
selectedTradeId={selectedTradeId}
|
||||||
|
onSelectTrade={handleTradeHistorySelect}
|
||||||
emptyText="체결 내역이 없습니다."
|
emptyText="체결 내역이 없습니다."
|
||||||
className="ptd-trade-history--fill"
|
className="ptd-trade-history--fill"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -115,6 +115,14 @@ const PaperAllocationTable: React.FC<Props> = ({
|
|||||||
{ret >= 0 ? '+' : ''}{ret.toFixed(2)}%
|
{ret >= 0 ? '+' : ''}{ret.toFixed(2)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{(row.buyStage1Krw || row.buyStage2Krw || row.sellStage1Krw) ? (
|
||||||
|
<div className="vtd-target-strategy-field ptd-alloc-stage-badge">
|
||||||
|
<span className="vtd-target-strategy-label">분할</span>
|
||||||
|
<span className="vtd-trade-meta-value">
|
||||||
|
매수 {row.buyStageDone ?? 0}/3 · 매도 {row.sellStageDone ?? 0}/3
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useAppSettings } from '../../hooks/useAppSettings';
|
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||||
import { resetPaperAccount } from '../../utils/backendApi';
|
import { resetPaperAccount, type PaperAllocationDto } from '../../utils/backendApi';
|
||||||
import PaperSplitPanel from './PaperSplitPanel';
|
import PaperSplitPanel from './PaperSplitPanel';
|
||||||
|
import PaperSymbolStageSettings from './PaperSymbolStageSettings';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
selectedMarket?: string;
|
||||||
|
allocations?: PaperAllocationDto[];
|
||||||
onSettingsSaved?: () => void;
|
onSettingsSaved?: () => void;
|
||||||
onAccountReset?: () => void;
|
onAccountReset?: () => void;
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
@@ -16,8 +19,18 @@ const SettingsFieldRow: React.FC<{ label: string; children: React.ReactNode }> =
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset, onOpenSettings }) => {
|
const PaperLeftSettingsTab: React.FC<Props> = ({
|
||||||
|
selectedMarket,
|
||||||
|
allocations = [],
|
||||||
|
onSettingsSaved,
|
||||||
|
onAccountReset,
|
||||||
|
onOpenSettings,
|
||||||
|
}) => {
|
||||||
const { defaults, save, isLoaded } = useAppSettings();
|
const { defaults, save, isLoaded } = useAppSettings();
|
||||||
|
const selectedAlloc = useMemo(
|
||||||
|
() => allocations.find(a => a.symbol === selectedMarket),
|
||||||
|
[allocations, selectedMarket],
|
||||||
|
);
|
||||||
|
|
||||||
if (!isLoaded) {
|
if (!isLoaded) {
|
||||||
return <p className="ptd-muted">설정 로딩…</p>;
|
return <p className="ptd-muted">설정 로딩…</p>;
|
||||||
@@ -78,7 +91,17 @@ const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset
|
|||||||
);
|
);
|
||||||
|
|
||||||
const bottom = (
|
const bottom = (
|
||||||
<div className="ptd-settings-panel ptd-settings-panel--account">
|
<div className="ptd-settings-panel ptd-settings-panel--account ptd-settings-panel--stages">
|
||||||
|
{selectedMarket ? (
|
||||||
|
<PaperSymbolStageSettings
|
||||||
|
symbol={selectedMarket}
|
||||||
|
allocation={selectedAlloc}
|
||||||
|
onSaved={onSettingsSaved}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="ptd-muted ptd-item-card-empty">중앙 투자금 관리에서 종목을 선택하면 분할 매매 단계를 설정할 수 있습니다.</p>
|
||||||
|
)}
|
||||||
|
<div className="ptd-settings-panel-divider" aria-hidden />
|
||||||
<div className="vtd-target-item ptd-item-card ptd-settings-card">
|
<div className="vtd-target-item ptd-item-card ptd-settings-card">
|
||||||
<div className="ptd-item-card-metrics">
|
<div className="ptd-item-card-metrics">
|
||||||
<SettingsFieldRow label="초기 자본 (KRW)">
|
<SettingsFieldRow label="초기 자본 (KRW)">
|
||||||
@@ -135,7 +158,7 @@ const PaperLeftSettingsTab: React.FC<Props> = ({ onSettingsSaved, onAccountReset
|
|||||||
<PaperSplitPanel
|
<PaperSplitPanel
|
||||||
className="ptd-split-panel--left"
|
className="ptd-split-panel--left"
|
||||||
topTitle="운영 · 자동매매"
|
topTitle="운영 · 자동매매"
|
||||||
bottomTitle="계좌 · 비용"
|
bottomTitle="종목별 분할 매매 · 계좌"
|
||||||
top={top}
|
top={top}
|
||||||
bottom={bottom}
|
bottom={bottom}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
loadStrategies,
|
loadStrategies,
|
||||||
loadLiveStrategySettings,
|
loadLiveStrategySettings,
|
||||||
@@ -6,13 +6,18 @@ import {
|
|||||||
type PaperSummaryDto,
|
type PaperSummaryDto,
|
||||||
type StrategyDto,
|
type StrategyDto,
|
||||||
} from '../../utils/backendApi';
|
} from '../../utils/backendApi';
|
||||||
import { loadVirtualSession } from '../../utils/virtualTradingStorage';
|
import { loadVirtualSession, type VirtualTargetItem } from '../../utils/virtualTradingStorage';
|
||||||
|
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
|
||||||
|
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||||
import { fmtKrw } from '../TradeOrderPanel';
|
import { fmtKrw } from '../TradeOrderPanel';
|
||||||
import PaperSplitPanel from './PaperSplitPanel';
|
import PaperSplitPanel from './PaperSplitPanel';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
market: string;
|
market: string;
|
||||||
summary: PaperSummaryDto | null;
|
summary: PaperSummaryDto | null;
|
||||||
|
/** 가상매매 투자대상 — 보유 목록은 이 종목만 표시 */
|
||||||
|
virtualTargets?: VirtualTargetItem[];
|
||||||
|
tickers?: Map<string, TickerData>;
|
||||||
paperAutoTradeEnabled?: boolean;
|
paperAutoTradeEnabled?: boolean;
|
||||||
onMarketSelect?: (market: string) => void;
|
onMarketSelect?: (market: string) => void;
|
||||||
}
|
}
|
||||||
@@ -20,6 +25,8 @@ interface Props {
|
|||||||
const PaperLeftStrategyTab: React.FC<Props> = ({
|
const PaperLeftStrategyTab: React.FC<Props> = ({
|
||||||
market,
|
market,
|
||||||
summary,
|
summary,
|
||||||
|
virtualTargets = [],
|
||||||
|
tickers,
|
||||||
paperAutoTradeEnabled = false,
|
paperAutoTradeEnabled = false,
|
||||||
onMarketSelect,
|
onMarketSelect,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -91,7 +98,23 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const positions = summary?.positions ?? [];
|
const targetMarkets = useMemo(
|
||||||
|
() => new Set(virtualTargets.map(t => t.market)),
|
||||||
|
[virtualTargets],
|
||||||
|
);
|
||||||
|
|
||||||
|
const positions = useMemo(() => {
|
||||||
|
const all = (summary?.positions ?? []).filter(p => p.quantity > 0);
|
||||||
|
if (targetMarkets.size === 0) return [];
|
||||||
|
const order = virtualTargets.map(t => t.market);
|
||||||
|
return all
|
||||||
|
.filter(p => targetMarkets.has(p.symbol))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ia = order.indexOf(a.symbol);
|
||||||
|
const ib = order.indexOf(b.symbol);
|
||||||
|
return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
|
||||||
|
});
|
||||||
|
}, [summary?.positions, targetMarkets, virtualTargets]);
|
||||||
|
|
||||||
const bottom = (
|
const bottom = (
|
||||||
<div className="ptd-item-card-section ptd-holdings-card-section">
|
<div className="ptd-item-card-section ptd-holdings-card-section">
|
||||||
@@ -99,14 +122,21 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
|||||||
<span>보유 목록</span>
|
<span>보유 목록</span>
|
||||||
<span className="vtd-target-count">{positions.length}종목</span>
|
<span className="vtd-target-count">{positions.length}종목</span>
|
||||||
</div>
|
</div>
|
||||||
{positions.length === 0 ? (
|
{targetMarkets.size === 0 ? (
|
||||||
<p className="ptd-muted ptd-item-card-empty">보유 종목이 없습니다.</p>
|
<p className="ptd-muted ptd-item-card-empty">가상매매 투자대상이 없습니다. 가상투자에서 종목을 추가하세요.</p>
|
||||||
|
) : positions.length === 0 ? (
|
||||||
|
<p className="ptd-muted ptd-item-card-empty">투자대상 종목 중 보유 수량이 있는 종목이 없습니다.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="ptd-item-card-scroll">
|
<div className="ptd-item-card-scroll">
|
||||||
<div className="vtd-target-list ptd-item-card-list">
|
<div className="vtd-target-list ptd-item-card-list">
|
||||||
{positions.map(p => {
|
{positions.map(p => {
|
||||||
const code = p.symbol.replace(/^KRW-/, '');
|
const ticker = tickers?.get(p.symbol);
|
||||||
const name = p.koreanName || code;
|
const vt = virtualTargets.find(t => t.market === p.symbol);
|
||||||
|
const { koreanName: ko, englishName: en } = resolveVirtualTargetNames(
|
||||||
|
p.symbol,
|
||||||
|
p.koreanName ?? vt?.koreanName ?? ticker?.koreanName,
|
||||||
|
vt?.englishName,
|
||||||
|
);
|
||||||
const up = (p.profitLoss ?? 0) >= 0;
|
const up = (p.profitLoss ?? 0) >= 0;
|
||||||
const selected = market === p.symbol;
|
const selected = market === p.symbol;
|
||||||
const retPct = p.profitLossPct;
|
const retPct = p.profitLossPct;
|
||||||
@@ -132,8 +162,8 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<div className="vtd-target-item-main">
|
<div className="vtd-target-item-main">
|
||||||
<div className="vtd-target-names">
|
<div className="vtd-target-names">
|
||||||
<span className="vtd-target-ko">{name}</span>
|
<span className="vtd-target-ko">{ko}</span>
|
||||||
<span className="vtd-target-en">{code}/KRW</span>
|
<span className="vtd-target-en">{en}</span>
|
||||||
</div>
|
</div>
|
||||||
{p.profitLoss != null && (
|
{p.profitLoss != null && (
|
||||||
<div className="vtd-target-item-actions">
|
<div className="vtd-target-item-actions">
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { PaperAllocationDto } from '../../utils/backendApi';
|
||||||
|
import { patchPaperAllocation } from '../../utils/backendApi';
|
||||||
|
import { fmtKrw } from '../TradeOrderPanel';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
symbol: string;
|
||||||
|
allocation?: PaperAllocationDto | null;
|
||||||
|
onSaved?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StageForm = {
|
||||||
|
buy1: string;
|
||||||
|
buy2: string;
|
||||||
|
buy3: string;
|
||||||
|
sell1: string;
|
||||||
|
sell2: string;
|
||||||
|
sell3: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toForm(row?: PaperAllocationDto | null): StageForm {
|
||||||
|
const n = (v?: number) => (v != null && v > 0 ? String(Math.round(v)) : '');
|
||||||
|
return {
|
||||||
|
buy1: n(row?.buyStage1Krw),
|
||||||
|
buy2: n(row?.buyStage2Krw),
|
||||||
|
buy3: n(row?.buyStage3Krw),
|
||||||
|
sell1: n(row?.sellStage1Krw),
|
||||||
|
sell2: n(row?.sellStage2Krw),
|
||||||
|
sell3: n(row?.sellStage3Krw),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKrw(s: string): number {
|
||||||
|
const v = Number(String(s).replace(/,/g, '').trim());
|
||||||
|
return Number.isFinite(v) && v > 0 ? Math.round(v) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaperSymbolStageSettings: React.FC<Props> = ({ symbol, allocation, onSaved }) => {
|
||||||
|
const [form, setForm] = useState<StageForm>(() => toForm(allocation));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setForm(toForm(allocation));
|
||||||
|
setMsg(null);
|
||||||
|
}, [symbol, allocation?.id, allocation?.buyStage1Krw, allocation?.sellStage1Krw]);
|
||||||
|
|
||||||
|
const save = useCallback(async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
await patchPaperAllocation(symbol, {
|
||||||
|
buyStage1Krw: parseKrw(form.buy1),
|
||||||
|
buyStage2Krw: parseKrw(form.buy2),
|
||||||
|
buyStage3Krw: parseKrw(form.buy3),
|
||||||
|
sellStage1Krw: parseKrw(form.sell1),
|
||||||
|
sellStage2Krw: parseKrw(form.sell2),
|
||||||
|
sellStage3Krw: parseKrw(form.sell3),
|
||||||
|
});
|
||||||
|
setMsg('저장되었습니다.');
|
||||||
|
onSaved?.();
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : '저장 실패');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [symbol, form, onSaved]);
|
||||||
|
|
||||||
|
const name = allocation?.koreanName || symbol.replace(/^KRW-/, '');
|
||||||
|
const buyDone = allocation?.buyStageDone ?? 0;
|
||||||
|
const sellDone = allocation?.sellStageDone ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vtd-target-item ptd-item-card ptd-settings-card ptd-stage-settings">
|
||||||
|
<div className="ptd-stage-settings-head">
|
||||||
|
<span className="vtd-target-ko">{name}</span>
|
||||||
|
<span className="vtd-target-en">{symbol}</span>
|
||||||
|
</div>
|
||||||
|
<p className="ptd-muted ptd-stage-settings-hint">
|
||||||
|
자동매매 시그널마다 다음 단계 금액만 체결합니다. 매수·매도 금액을 0으로 두면 해당 차수는 건너뜁니다.
|
||||||
|
</p>
|
||||||
|
<div className="ptd-stage-progress">
|
||||||
|
<span>진행: 매수 {buyDone}/3 · 매도 {sellDone}/3</span>
|
||||||
|
{(buyDone > 0 || sellDone > 0) && (
|
||||||
|
<span className="ptd-muted"> (전량 매도 시 매수·매도 단계 초기화)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ptd-stage-grid">
|
||||||
|
<div className="ptd-stage-col">
|
||||||
|
<span className="ptd-stage-col-title">매수 단계 (KRW)</span>
|
||||||
|
{(['buy1', 'buy2', 'buy3'] as const).map((key, i) => (
|
||||||
|
<label key={key} className="vtd-target-strategy-field ptd-stage-field">
|
||||||
|
<span className="vtd-target-strategy-label">{i + 1}차 매수</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="vtd-trade-meta-value ptd-settings-input"
|
||||||
|
min={0}
|
||||||
|
step={10000}
|
||||||
|
value={form[key]}
|
||||||
|
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="ptd-stage-col">
|
||||||
|
<span className="ptd-stage-col-title">매도 단계 (KRW)</span>
|
||||||
|
{(['sell1', 'sell2', 'sell3'] as const).map((key, i) => (
|
||||||
|
<label key={key} className="vtd-target-strategy-field ptd-stage-field">
|
||||||
|
<span className="vtd-target-strategy-label">{i + 1}차 매도</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="vtd-trade-meta-value ptd-settings-input"
|
||||||
|
min={0}
|
||||||
|
step={10000}
|
||||||
|
value={form[key]}
|
||||||
|
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{allocation?.maxInvestKrw != null && allocation.maxInvestKrw > 0 && (
|
||||||
|
<p className="ptd-muted ptd-stage-sum">
|
||||||
|
매수 합계 {fmtKrw(
|
||||||
|
parseKrw(form.buy1) + parseKrw(form.buy2) + parseKrw(form.buy3),
|
||||||
|
)} · 종목 한도 {fmtKrw(allocation.maxInvestKrw)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bps-btn bps-btn--primary ptd-stage-save"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중…' : '단계 금액 저장'}
|
||||||
|
</button>
|
||||||
|
{msg && <p className={`ptd-stage-msg${msg.includes('실패') ? ' ptd-stage-msg--err' : ''}`}>{msg}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaperSymbolStageSettings;
|
||||||
@@ -27,6 +27,10 @@ interface Props {
|
|||||||
tickers?: Map<string, TickerData>;
|
tickers?: Map<string, TickerData>;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
selectedTradeId?: number | null;
|
||||||
|
/** 투자관리 — 체결 행 전체 전달(탭 유지·선택 표시) */
|
||||||
|
onSelectTrade?: (trade: PaperTradeDto) => void;
|
||||||
|
/** 가상투자 등 — 종목만 (기존 동작) */
|
||||||
onSelectMarket?: (market: string) => void;
|
onSelectMarket?: (market: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +40,8 @@ const PaperTradeHistoryList: React.FC<Props> = ({
|
|||||||
tickers,
|
tickers,
|
||||||
emptyText = '거래 내역이 없습니다.',
|
emptyText = '거래 내역이 없습니다.',
|
||||||
className = '',
|
className = '',
|
||||||
|
selectedTradeId = null,
|
||||||
|
onSelectTrade,
|
||||||
onSelectMarket,
|
onSelectMarket,
|
||||||
}) => (
|
}) => (
|
||||||
<div className={`ptd-trade-history vtd-trade-history${className ? ` ${className}` : ''}`}>
|
<div className={`ptd-trade-history vtd-trade-history${className ? ` ${className}` : ''}`}>
|
||||||
@@ -59,7 +65,12 @@ const PaperTradeHistoryList: React.FC<Props> = ({
|
|||||||
? (strategyNames[t.strategyId] ?? `#${t.strategyId}`)
|
? (strategyNames[t.strategyId] ?? `#${t.strategyId}`)
|
||||||
: '—';
|
: '—';
|
||||||
const isBuy = t.side === 'BUY';
|
const isBuy = t.side === 'BUY';
|
||||||
const clickable = Boolean(onSelectMarket);
|
const clickable = Boolean(onSelectTrade || onSelectMarket);
|
||||||
|
const selected = selectedTradeId != null && selectedTradeId === t.id;
|
||||||
|
const handleActivate = () => {
|
||||||
|
if (onSelectTrade) onSelectTrade(t);
|
||||||
|
else onSelectMarket?.(t.symbol);
|
||||||
|
};
|
||||||
const sideCls = isBuy ? 'vtd-trade-item--buy' : 'vtd-trade-item--sell';
|
const sideCls = isBuy ? 'vtd-trade-item--buy' : 'vtd-trade-item--sell';
|
||||||
const colorCls = isBuy ? 'vtd-target-up' : 'vtd-target-dn';
|
const colorCls = isBuy ? 'vtd-target-up' : 'vtd-target-dn';
|
||||||
|
|
||||||
@@ -71,14 +82,16 @@ const PaperTradeHistoryList: React.FC<Props> = ({
|
|||||||
'vtd-trade-item',
|
'vtd-trade-item',
|
||||||
sideCls,
|
sideCls,
|
||||||
clickable ? 'vtd-trade-item--click' : '',
|
clickable ? 'vtd-trade-item--click' : '',
|
||||||
|
selected ? 'vtd-trade-item--selected' : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
role={clickable ? 'button' : undefined}
|
role={clickable ? 'button' : undefined}
|
||||||
tabIndex={clickable ? 0 : undefined}
|
tabIndex={clickable ? 0 : undefined}
|
||||||
onClick={clickable ? () => onSelectMarket!(t.symbol) : undefined}
|
aria-pressed={clickable ? selected : undefined}
|
||||||
|
onClick={clickable ? handleActivate : undefined}
|
||||||
onKeyDown={clickable ? e => {
|
onKeyDown={clickable ? e => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSelectMarket!(t.symbol);
|
handleActivate();
|
||||||
}
|
}
|
||||||
} : undefined}
|
} : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -582,6 +582,74 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ptd-settings-panel--stages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-settings-panel-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: color-mix(in srgb, var(--border, #2a2e39) 80%, transparent);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-stage-settings-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-stage-settings-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-stage-progress {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text2, #787b86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-stage-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-stage-col-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2, #787b86);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-stage-field {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-stage-save {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-stage-msg {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent, #3f7ef5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-stage-msg--err {
|
||||||
|
color: var(--danger, #ef5350);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptd-alloc-stage-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text2, #787b86);
|
||||||
|
}
|
||||||
|
|
||||||
.ptd-split-panel--left .ptd-split-card-body:has(.ptd-settings-panel) {
|
.ptd-split-panel--left .ptd-split-card-body:has(.ptd-settings-panel) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -575,6 +575,11 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vtd-trade-item--selected {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent, #3f7ef5);
|
||||||
|
background: color-mix(in srgb, var(--accent, #3f7ef5) 12%, var(--se-panel-card-bg, #1e222d));
|
||||||
|
}
|
||||||
|
|
||||||
.vtd-trade-item--buy {
|
.vtd-trade-item--buy {
|
||||||
border-color: color-mix(in srgb, var(--gc-trade-buy) 22%, var(--se-border));
|
border-color: color-mix(in srgb, var(--gc-trade-buy) 22%, var(--se-border));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -625,6 +625,14 @@ export interface PaperAllocationDto {
|
|||||||
evalAmount?: number;
|
evalAmount?: number;
|
||||||
symbolReturnPct?: number;
|
symbolReturnPct?: number;
|
||||||
weightPct?: number;
|
weightPct?: number;
|
||||||
|
buyStage1Krw?: number;
|
||||||
|
buyStage2Krw?: number;
|
||||||
|
buyStage3Krw?: number;
|
||||||
|
sellStage1Krw?: number;
|
||||||
|
sellStage2Krw?: number;
|
||||||
|
sellStage3Krw?: number;
|
||||||
|
buyStageDone?: number;
|
||||||
|
sellStageDone?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaperSummaryDto {
|
export interface PaperSummaryDto {
|
||||||
@@ -727,6 +735,12 @@ export interface PaperAllocationBulkItem {
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
|
buyStage1Krw?: number;
|
||||||
|
buyStage2Krw?: number;
|
||||||
|
buyStage3Krw?: number;
|
||||||
|
sellStage1Krw?: number;
|
||||||
|
sellStage2Krw?: number;
|
||||||
|
sellStage3Krw?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaperOrderRequest {
|
export interface PaperOrderRequest {
|
||||||
@@ -794,7 +808,9 @@ export async function putPaperAllocationsBulk(items: PaperAllocationBulkItem[]):
|
|||||||
|
|
||||||
export async function patchPaperAllocation(
|
export async function patchPaperAllocation(
|
||||||
symbol: string,
|
symbol: string,
|
||||||
body: Partial<Pick<PaperAllocationDto, 'maxInvestKrw' | 'budgetPct' | 'strategyId' | 'isActive' | 'pinned' | 'sortOrder' | 'koreanName'>>,
|
body: Partial<Pick<PaperAllocationDto,
|
||||||
|
| 'maxInvestKrw' | 'budgetPct' | 'strategyId' | 'isActive' | 'pinned' | 'sortOrder' | 'koreanName'
|
||||||
|
| 'buyStage1Krw' | 'buyStage2Krw' | 'buyStage3Krw' | 'sellStage1Krw' | 'sellStage2Krw' | 'sellStage3Krw'>>,
|
||||||
): Promise<PaperAllocationDto | null> {
|
): Promise<PaperAllocationDto | null> {
|
||||||
return request<PaperAllocationDto>(`/paper/allocations/${encodeURIComponent(symbol)}`, {
|
return request<PaperAllocationDto>(`/paper/allocations/${encodeURIComponent(symbol)}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
+13
-3
@@ -276,14 +276,24 @@ trading_mode:
|
|||||||
| 5 | `live_strategy_check = true` **OR** 해당 `market`에 `is_live_check=true` 설정 존재 |
|
| 5 | `live_strategy_check = true` **OR** 해당 `market`에 `is_live_check=true` 설정 존재 |
|
||||||
| 6 | `trading_mode`가 PAPER 또는 BOTH (`TradingExecutionService` 단계) |
|
| 6 | `trading_mode`가 PAPER 또는 BOTH (`TradingExecutionService` 단계) |
|
||||||
|
|
||||||
**포지션 모드 `LONG_ONLY` (실제 계좌 보유 기준):**
|
**포지션 모드 `LONG_ONLY` (분할 매매 미설정 시):**
|
||||||
|
|
||||||
| 시그널 | 스킵 조건 |
|
| 시그널 | 스킵 조건 |
|
||||||
|--------|-----------|
|
|--------|-----------|
|
||||||
| BUY | 해당 종목 **이미 보유** (`quantity > 0`) |
|
| BUY | 해당 종목 **이미 보유** (`quantity > 0`) |
|
||||||
| SELL | 해당 종목 **미보유** |
|
| SELL | 해당 종목 **미보유** |
|
||||||
|
|
||||||
**매수 금액 계산:**
|
**분할 매매 (종목별 1~3차, `gc_paper_symbol_allocation`):**
|
||||||
|
|
||||||
|
- 투자관리 **설정** 탭 → 선택 종목: `buy_stage1~3_krw`, `sell_stage1~3_krw` (KRW)
|
||||||
|
- 매수·매도 단계 금액 중 **하나라도 > 0** 이면 분할 모드 (`PaperTradeStageService.usesStagedTrading`)
|
||||||
|
- **BUY 시그널마다** 다음 매수 차수 금액만 체결 (`buy_stage_done` 0→1→2→3)
|
||||||
|
- **SELL 시그널마다** 다음 매도 차수 금액만큼 수량 체결 (`sell_stage_done` 증가)
|
||||||
|
- **전량 매도** 후 `buy_stage_done`·`sell_stage_done` **0으로 초기화**
|
||||||
|
- 분할 모드에서는 보유 중에도 2·3차 **추가 매수** 가능 (LONG_ONLY 일괄 매수 스킵 해제)
|
||||||
|
- 단계 금액이 모두 0이면 기존 방식(가용현금 % 일괄 매수·전량 매도)
|
||||||
|
|
||||||
|
**매수 금액 (분할 미설정 시):**
|
||||||
|
|
||||||
```text
|
```text
|
||||||
budget = (cash_balance - reserved_cash) × paper_auto_trade_budget_pct / 100
|
budget = (cash_balance - reserved_cash) × paper_auto_trade_budget_pct / 100
|
||||||
@@ -296,7 +306,7 @@ qty = budget / (execPrice × (1 + fee%))
|
|||||||
| 7 | `budget < paper_min_order_krw` |
|
| 7 | `budget < paper_min_order_krw` |
|
||||||
| 8 | `qty × execPrice < min_order` |
|
| 8 | `qty × execPrice < min_order` |
|
||||||
|
|
||||||
**매도:** 보유 수량 전량(가용 수량, 미체결 매도 예약 제외)
|
**매도 (분할 미설정):** 보유 수량 전량(가용 수량, 미체결 매도 예약 제외)
|
||||||
|
|
||||||
**체결:** `executeTrade(..., source="STRATEGY", autoTrade=true)`
|
**체결:** `executeTrade(..., source="STRATEGY", autoTrade=true)`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user