알림 전체닫기 오류 수정
This commit is contained in:
@@ -9,7 +9,7 @@ public final class MenuIds {
|
||||
public static final List<String> ALL = List.of(
|
||||
"dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "backtest", "analysis-report", "notifications", "settings",
|
||||
"settings_general", "settings_chart", "settings_indicators", "settings_backtest",
|
||||
"settings_strategy", "settings_paper", "settings_virtual", "settings_live",
|
||||
"settings_strategy", "settings_trading", "settings_paper", "settings_virtual", "settings_live",
|
||||
"settings_trend-search",
|
||||
"settings_alert", "settings_network", "settings_admin"
|
||||
);
|
||||
|
||||
@@ -240,6 +240,21 @@ public class GcAppSettings {
|
||||
@Column(name = "upbit_secret_key", length = 256)
|
||||
private String upbitSecretKey;
|
||||
|
||||
/**
|
||||
* 미체결 주문 체결 모드.
|
||||
* LIMIT_ONLY : 지정가 조건 충족 시에만 체결 (기본값)
|
||||
* NEXT_TICK : 다음 시세 틱에서 무조건 체결
|
||||
* AUTO_CANCEL : 주문가 대비 paper_order_auto_cancel_pct % 초과 이탈 시 자동 취소
|
||||
*/
|
||||
@Column(name = "paper_order_fill_mode", nullable = false, length = 20)
|
||||
@Builder.Default
|
||||
private String paperOrderFillMode = "LIMIT_ONLY";
|
||||
|
||||
/** AUTO_CANCEL 모드의 허용 이탈폭 (%) */
|
||||
@Column(name = "paper_order_auto_cancel_pct", nullable = false, precision = 8, scale = 4)
|
||||
@Builder.Default
|
||||
private BigDecimal paperOrderAutoCancelPct = BigDecimal.valueOf(1.0);
|
||||
|
||||
/** BACKEND_STOMP | UPBIT_DIRECT */
|
||||
@Column(name = "chart_realtime_source", nullable = false, length = 20)
|
||||
@Builder.Default
|
||||
|
||||
@@ -61,6 +61,14 @@ public class GcPaperOrder {
|
||||
@Column(name = "strategy_id")
|
||||
private Long strategyId;
|
||||
|
||||
/**
|
||||
* 분할매매(staged) 주문 여부.
|
||||
* TRUE 이면 체결(FILLED) 시 allocationService 단계를 자동 진행한다.
|
||||
*/
|
||||
@Column(name = "staged", nullable = false)
|
||||
@Builder.Default
|
||||
private Boolean staged = false;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
@@ -171,6 +171,15 @@ public class AppSettingsService {
|
||||
s.setUpbitSecretKey(secretCrypto.encrypt(v));
|
||||
}
|
||||
}
|
||||
if (d.containsKey("paperOrderFillMode")) {
|
||||
String mode = d.get("paperOrderFillMode").toString().toUpperCase();
|
||||
s.setPaperOrderFillMode(
|
||||
"NEXT_TICK".equals(mode) || "AUTO_CANCEL".equals(mode) ? mode : "LIMIT_ONLY");
|
||||
}
|
||||
if (d.containsKey("paperOrderAutoCancelPct")) {
|
||||
double v = Double.parseDouble(d.get("paperOrderAutoCancelPct").toString());
|
||||
s.setPaperOrderAutoCancelPct(new java.math.BigDecimal(Math.max(0.1, Math.min(50.0, v))));
|
||||
}
|
||||
if (d.containsKey("chartRealtimeSource")) {
|
||||
String src = d.get("chartRealtimeSource").toString().toUpperCase();
|
||||
s.setChartRealtimeSource("UPBIT_DIRECT".equals(src) ? "UPBIT_DIRECT" : "BACKEND_STOMP");
|
||||
@@ -237,6 +246,9 @@ public class AppSettingsService {
|
||||
UpbitApiCredentials creds = resolveUpbitCredentials(s);
|
||||
m.put("upbitAccessKeyMasked", SecretCryptoService.maskForDisplay(creds.accessKey()));
|
||||
m.put("hasUpbitKeys", creds.isComplete());
|
||||
m.put("paperOrderFillMode", s.getPaperOrderFillMode() != null ? s.getPaperOrderFillMode() : "LIMIT_ONLY");
|
||||
m.put("paperOrderAutoCancelPct", s.getPaperOrderAutoCancelPct() != null
|
||||
? s.getPaperOrderAutoCancelPct().doubleValue() : 1.0);
|
||||
m.put("chartRealtimeSource", s.getChartRealtimeSource() != null ? s.getChartRealtimeSource() : "BACKEND_STOMP");
|
||||
m.put("liveAutoTradeBudgetPct", s.getLiveAutoTradeBudgetPct() != null
|
||||
? s.getLiveAutoTradeBudgetPct().doubleValue() : 95);
|
||||
|
||||
@@ -221,7 +221,6 @@ public class PaperTradingService {
|
||||
throw new IllegalArgumentException("가격은 0보다 커야 합니다.");
|
||||
}
|
||||
|
||||
String orderType = resolveOrderType(req);
|
||||
String source = req.getSource() != null ? req.getSource() : "MANUAL";
|
||||
String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit";
|
||||
|
||||
@@ -235,15 +234,11 @@ public class PaperTradingService {
|
||||
throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다.");
|
||||
}
|
||||
|
||||
if ("LIMIT".equals(orderType)) {
|
||||
GcPaperOrder order = createPendingLimitOrder(account, app, req.getMarket(), side, orderKind,
|
||||
source, req.getStrategyId(), price, qty);
|
||||
return PaperPlaceOrderResult.builder().order(toOrderDto(order)).build();
|
||||
}
|
||||
|
||||
PaperTradeDto trade = executeTrade(uid, app, req.getMarket(), side, orderKind, source,
|
||||
req.getStrategyId(), price, qty, null, false);
|
||||
return PaperPlaceOrderResult.builder().trade(trade).build();
|
||||
// 시장가(MARKET)를 포함한 모든 주문을 현재가 기준 지정가 미체결로 생성한다.
|
||||
// 체결은 PaperOrderMatcherService 가 다음 시세 틱에서 처리한다.
|
||||
GcPaperOrder order = createPendingLimitOrder(account, app, req.getMarket(), side, orderKind,
|
||||
source, req.getStrategyId(), price, qty, false);
|
||||
return PaperPlaceOrderResult.builder().order(toOrderDto(order)).build();
|
||||
}
|
||||
|
||||
public PaperOrderDto cancelOrder(Long userId, Long orderId) {
|
||||
@@ -296,24 +291,30 @@ public class PaperTradingService {
|
||||
? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000));
|
||||
GcPaperSymbolAllocation alloc = allocationService.getOrDefaultEntity(
|
||||
account.getId(), market, defaultMax);
|
||||
boolean staged = PaperTradeStageService.usesStagedTrading(alloc);
|
||||
boolean useStaged = 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 ("LONG_ONLY".equals(positionMode) && !useStaged) {
|
||||
if ("BUY".equals(signalType) && held > 0) return;
|
||||
if ("SELL".equals(signalType) && held <= 0) return;
|
||||
}
|
||||
|
||||
// 비분할 모드에서 이미 동일 방향 미체결 주문이 있으면 중복 생성 방지
|
||||
if (!useStaged && hasPendingOrderForSide(account.getId(), market, signalType)) {
|
||||
log.debug("[Paper] 이미 미체결 주문 존재 — 신규 생성 생략 {} {}", market, signalType);
|
||||
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)) {
|
||||
if (staged) {
|
||||
if (useStaged) {
|
||||
if (held <= 0) {
|
||||
allocationService.resetTradeStages(account.getId(), market);
|
||||
alloc = allocationService.getOrDefaultEntity(account.getId(), market, defaultMax);
|
||||
@@ -329,10 +330,10 @@ public class PaperTradingService {
|
||||
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);
|
||||
// 알림 발생 시점 현재가 기준 미체결 주문 생성 (staged=true → 체결 시 단계 진행)
|
||||
createPendingLimitOrder(account, app, market, "BUY", "market", "STRATEGY",
|
||||
strategyId, price, qty, true);
|
||||
log.info("[Paper] STRATEGY BUY (staged) 미체결 생성 {} qty≈{} @ {}", market, qty, price);
|
||||
} else {
|
||||
double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95);
|
||||
double cash = account.getCashBalance().doubleValue();
|
||||
@@ -344,34 +345,36 @@ public class PaperTradingService {
|
||||
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);
|
||||
// 알림 발생 시점 현재가 기준 미체결 주문 생성
|
||||
createPendingLimitOrder(account, app, market, "BUY", "market", "STRATEGY",
|
||||
strategyId, price, qty, false);
|
||||
log.info("[Paper] STRATEGY BUY 미체결 생성 {} qty≈{} @ {}", market, qty, price);
|
||||
}
|
||||
} else {
|
||||
if (held <= 0) return;
|
||||
double avail = availableSellQty(account.getId(), market, held);
|
||||
if (avail <= 0) return;
|
||||
|
||||
if (staged) {
|
||||
if (useStaged) {
|
||||
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);
|
||||
// 알림 발생 시점 현재가 기준 미체결 주문 생성 (staged=true → 체결 시 단계 진행)
|
||||
createPendingLimitOrder(account, app, market, "SELL", "market", "STRATEGY",
|
||||
strategyId, price, qty, true);
|
||||
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);
|
||||
// 알림 발생 시점 현재가 기준 미체결 주문 생성
|
||||
createPendingLimitOrder(account, app, market, "SELL", "market", "STRATEGY",
|
||||
strategyId, price, avail, false);
|
||||
log.info("[Paper] STRATEGY SELL 미체결 생성 {} qty={} @ {}", market, avail, price);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Paper] auto trade failed {} {}: {}", market, signalType, e.getMessage());
|
||||
log.warn("[Paper] auto trade 미체결 생성 실패 {} {}: {}", market, signalType, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,12 +382,51 @@ public class PaperTradingService {
|
||||
GcPaperOrder order = orderRepo.findById(orderId).orElse(null);
|
||||
if (order == null || !"PENDING".equals(order.getStatus())) return;
|
||||
|
||||
boolean fill = false;
|
||||
double limit = order.getLimitPrice() != null ? order.getLimitPrice().doubleValue() : 0;
|
||||
if ("BUY".equals(order.getSide())) {
|
||||
fill = markPrice <= limit;
|
||||
} else if ("SELL".equals(order.getSide())) {
|
||||
fill = markPrice >= limit;
|
||||
|
||||
// ── 체결 모드 조회 ───────────────────────────────────────────────────────
|
||||
GcPaperAccount acctCheck = accountRepo.findById(order.getAccountId()).orElse(null);
|
||||
String fillMode = "LIMIT_ONLY";
|
||||
double autoCancelPct = 1.0;
|
||||
if (acctCheck != null && acctCheck.getUserId() != null) {
|
||||
GcAppSettings appCheck = appSettingsService.getEntity(acctCheck.getUserId(), null);
|
||||
fillMode = appCheck.getPaperOrderFillMode() != null
|
||||
? appCheck.getPaperOrderFillMode() : "LIMIT_ONLY";
|
||||
autoCancelPct = appCheck.getPaperOrderAutoCancelPct() != null
|
||||
? appCheck.getPaperOrderAutoCancelPct().doubleValue() : 1.0;
|
||||
}
|
||||
|
||||
boolean fill = false;
|
||||
boolean autoCancel = false;
|
||||
|
||||
switch (fillMode) {
|
||||
case "NEXT_TICK" -> fill = true; // B. 다음 틱 무조건 체결
|
||||
case "AUTO_CANCEL" -> { // C. 허용폭 초과 시 자동 취소
|
||||
double deviation = limit > 0 ? Math.abs(markPrice - limit) / limit * 100.0 : 0;
|
||||
if (deviation > autoCancelPct) {
|
||||
autoCancel = true;
|
||||
} else {
|
||||
// 허용폭 내이면 지정가 조건 충족 시 체결
|
||||
if ("BUY".equals(order.getSide())) fill = markPrice <= limit;
|
||||
else if ("SELL".equals(order.getSide())) fill = markPrice >= limit;
|
||||
}
|
||||
}
|
||||
default -> { // A. LIMIT_ONLY (기본값)
|
||||
if ("BUY".equals(order.getSide())) fill = markPrice <= limit;
|
||||
else if ("SELL".equals(order.getSide())) fill = markPrice >= limit;
|
||||
}
|
||||
}
|
||||
|
||||
if (autoCancel) {
|
||||
GcPaperAccount acctForCancel = accountRepo.findById(order.getAccountId()).orElse(null);
|
||||
if (acctForCancel != null) {
|
||||
releaseReservation(acctForCancel, order);
|
||||
order.setStatus("CANCELLED");
|
||||
orderRepo.save(order);
|
||||
log.info("[Paper] AUTO_CANCEL 주문 자동취소 orderId={} symbol={} limit={} markPrice={} deviation>{}%",
|
||||
orderId, order.getSymbol(), limit, markPrice, autoCancelPct);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!fill) return;
|
||||
|
||||
@@ -406,6 +448,21 @@ public class PaperTradingService {
|
||||
order.setReservedCash(BigDecimal.ZERO);
|
||||
order.setReservedQty(BigDecimal.ZERO);
|
||||
orderRepo.save(order);
|
||||
|
||||
// staged 주문이면 체결 시점에 분할매매 단계를 진행
|
||||
if (Boolean.TRUE.equals(order.getStaged())) {
|
||||
try {
|
||||
if ("BUY".equals(order.getSide())) {
|
||||
allocationService.advanceBuyStageAfterTrade(order.getAccountId(), order.getSymbol());
|
||||
log.info("[Paper] staged BUY 단계 진행 완료 {} orderId={}", order.getSymbol(), orderId);
|
||||
} else if ("SELL".equals(order.getSide())) {
|
||||
allocationService.advanceSellStageAfterTrade(order.getAccountId(), order.getSymbol());
|
||||
log.info("[Paper] staged SELL 단계 진행 완료 {} orderId={}", order.getSymbol(), orderId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Paper] staged 단계 진행 실패 orderId={}: {}", orderId, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public double resolveMarkPrice(String symbol) {
|
||||
@@ -475,7 +532,7 @@ public class PaperTradingService {
|
||||
private GcPaperOrder createPendingLimitOrder(GcPaperAccount account, GcAppSettings app,
|
||||
String market, String side, String orderKind,
|
||||
String source, Long strategyId,
|
||||
double limitPrice, double qty) {
|
||||
double limitPrice, double qty, boolean staged) {
|
||||
double feeRate = pct(app.getPaperFeeRatePct(), 0.05);
|
||||
double slip = pct(app.getPaperSlippagePct(), 0);
|
||||
double execPrice = "BUY".equals(side)
|
||||
@@ -502,6 +559,7 @@ public class PaperTradingService {
|
||||
.orderKind(orderKind)
|
||||
.source(source)
|
||||
.strategyId(strategyId)
|
||||
.staged(staged)
|
||||
.build());
|
||||
}
|
||||
|
||||
@@ -523,9 +581,16 @@ public class PaperTradingService {
|
||||
.orderKind(orderKind)
|
||||
.source(source)
|
||||
.strategyId(strategyId)
|
||||
.staged(staged)
|
||||
.build());
|
||||
}
|
||||
|
||||
private boolean hasPendingOrderForSide(Long accountId, String symbol, String side) {
|
||||
return orderRepo.findByAccountIdAndStatusOrderByCreatedAtDesc(accountId, "PENDING")
|
||||
.stream()
|
||||
.anyMatch(o -> symbol.equals(o.getSymbol()) && side.equals(o.getSide()));
|
||||
}
|
||||
|
||||
private void releaseReservation(GcPaperAccount account, GcPaperOrder order) {
|
||||
if (order.getReservedCash() != null && order.getReservedCash().signum() > 0) {
|
||||
account.setReservedCash(account.getReservedCash().subtract(order.getReservedCash()).max(BigDecimal.ZERO));
|
||||
@@ -668,15 +733,6 @@ public class PaperTradingService {
|
||||
return avail * pctVal / 100.0;
|
||||
}
|
||||
|
||||
private static String resolveOrderType(PaperOrderRequest req) {
|
||||
if (req.getOrderType() != null) {
|
||||
String t = req.getOrderType().toUpperCase();
|
||||
if ("MARKET".equals(t) || "LIMIT".equals(t)) return t;
|
||||
}
|
||||
String kind = req.getOrderKind() != null ? req.getOrderKind().toLowerCase() : "limit";
|
||||
return "market".equals(kind) ? "MARKET" : "LIMIT";
|
||||
}
|
||||
|
||||
private void upsertPositionBuy(GcPaperAccount account, String market, String koreanName,
|
||||
BigDecimal qty, BigDecimal price) {
|
||||
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- gc_paper_order: staged 자동매매 분할매매 여부 컬럼 추가
|
||||
-- staged=TRUE 이면 체결(FILLED) 시 allocationService 단계 자동 진행
|
||||
ALTER TABLE gc_paper_order
|
||||
ADD COLUMN staged TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT '분할매매 단계 주문 여부 (0=일반, 1=스테이지 진행)';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- gc_app_settings: 미체결 주문 체결 모드 설정
|
||||
-- LIMIT_ONLY : A. 지정가 유지 (현재가 ≤ 지정가(BUY) / ≥ 지정가(SELL)일 때만 체결, 기본값)
|
||||
-- NEXT_TICK : B. 다음 틱 강제 체결 (가격 방향 무관, 시장가와 동일 효과)
|
||||
-- AUTO_CANCEL : C. 슬리피지 허용폭 초과 시 자동 취소 (paper_order_auto_cancel_pct 값 기준)
|
||||
ALTER TABLE gc_app_settings
|
||||
ADD COLUMN paper_order_fill_mode VARCHAR(20) NOT NULL DEFAULT 'LIMIT_ONLY'
|
||||
COMMENT '미체결 주문 체결 모드: LIMIT_ONLY | NEXT_TICK | AUTO_CANCEL',
|
||||
ADD COLUMN paper_order_auto_cancel_pct DECIMAL(8,4) NOT NULL DEFAULT 1.0
|
||||
COMMENT 'AUTO_CANCEL 모드 시 주문가 대비 허용 이탈폭 (%)';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 설정 화면: 매매설정(통합) 카테고리 권한 — settings_paper 와 동일 허용 수준으로 삽입
|
||||
INSERT INTO gc_role_menu_permission (role, menu_id, allowed)
|
||||
SELECT role, 'settings_trading', allowed
|
||||
FROM gc_role_menu_permission
|
||||
WHERE menu_id = 'settings_paper'
|
||||
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed);
|
||||
Reference in New Issue
Block a user