알림 전체닫기 오류 수정

This commit is contained in:
Macbook
2026-06-04 13:00:59 +09:00
parent 5740a9a59d
commit 128b589369
16 changed files with 513 additions and 126 deletions
@@ -9,7 +9,7 @@ public final class MenuIds {
public static final List<String> ALL = List.of( public static final List<String> ALL = List.of(
"dashboard", "chart", "paper", "virtual", "trend-search", "verification-board", "strategy", "strategy-editor", "backtest", "analysis-report", "notifications", "settings", "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_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_trend-search",
"settings_alert", "settings_network", "settings_admin" "settings_alert", "settings_network", "settings_admin"
); );
@@ -240,6 +240,21 @@ public class GcAppSettings {
@Column(name = "upbit_secret_key", length = 256) @Column(name = "upbit_secret_key", length = 256)
private String upbitSecretKey; 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 */ /** BACKEND_STOMP | UPBIT_DIRECT */
@Column(name = "chart_realtime_source", nullable = false, length = 20) @Column(name = "chart_realtime_source", nullable = false, length = 20)
@Builder.Default @Builder.Default
@@ -61,6 +61,14 @@ public class GcPaperOrder {
@Column(name = "strategy_id") @Column(name = "strategy_id")
private Long strategyId; 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) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -171,6 +171,15 @@ public class AppSettingsService {
s.setUpbitSecretKey(secretCrypto.encrypt(v)); 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")) { if (d.containsKey("chartRealtimeSource")) {
String src = d.get("chartRealtimeSource").toString().toUpperCase(); String src = d.get("chartRealtimeSource").toString().toUpperCase();
s.setChartRealtimeSource("UPBIT_DIRECT".equals(src) ? "UPBIT_DIRECT" : "BACKEND_STOMP"); s.setChartRealtimeSource("UPBIT_DIRECT".equals(src) ? "UPBIT_DIRECT" : "BACKEND_STOMP");
@@ -237,6 +246,9 @@ public class AppSettingsService {
UpbitApiCredentials creds = resolveUpbitCredentials(s); UpbitApiCredentials creds = resolveUpbitCredentials(s);
m.put("upbitAccessKeyMasked", SecretCryptoService.maskForDisplay(creds.accessKey())); m.put("upbitAccessKeyMasked", SecretCryptoService.maskForDisplay(creds.accessKey()));
m.put("hasUpbitKeys", creds.isComplete()); 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("chartRealtimeSource", s.getChartRealtimeSource() != null ? s.getChartRealtimeSource() : "BACKEND_STOMP");
m.put("liveAutoTradeBudgetPct", s.getLiveAutoTradeBudgetPct() != null m.put("liveAutoTradeBudgetPct", s.getLiveAutoTradeBudgetPct() != null
? s.getLiveAutoTradeBudgetPct().doubleValue() : 95); ? s.getLiveAutoTradeBudgetPct().doubleValue() : 95);
@@ -221,7 +221,6 @@ public class PaperTradingService {
throw new IllegalArgumentException("가격은 0보다 커야 합니다."); throw new IllegalArgumentException("가격은 0보다 커야 합니다.");
} }
String orderType = resolveOrderType(req);
String source = req.getSource() != null ? req.getSource() : "MANUAL"; String source = req.getSource() != null ? req.getSource() : "MANUAL";
String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit"; String orderKind = req.getOrderKind() != null ? req.getOrderKind() : "limit";
@@ -235,15 +234,11 @@ public class PaperTradingService {
throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다."); throw new IllegalArgumentException("주문 수량을 계산할 수 없습니다.");
} }
if ("LIMIT".equals(orderType)) { // 시장가(MARKET)를 포함한 모든 주문을 현재가 기준 지정가 미체결로 생성한다.
GcPaperOrder order = createPendingLimitOrder(account, app, req.getMarket(), side, orderKind, // 체결은 PaperOrderMatcherService 가 다음 시세 틱에서 처리한다.
source, req.getStrategyId(), price, qty); GcPaperOrder order = createPendingLimitOrder(account, app, req.getMarket(), side, orderKind,
return PaperPlaceOrderResult.builder().order(toOrderDto(order)).build(); source, req.getStrategyId(), price, qty, false);
} 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();
} }
public PaperOrderDto cancelOrder(Long userId, Long orderId) { public PaperOrderDto cancelOrder(Long userId, Long orderId) {
@@ -296,24 +291,30 @@ public class PaperTradingService {
? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000)); ? app.getPaperInitialCapital() : BigDecimal.valueOf(10_000_000));
GcPaperSymbolAllocation alloc = allocationService.getOrDefaultEntity( GcPaperSymbolAllocation alloc = allocationService.getOrDefaultEntity(
account.getId(), market, defaultMax); account.getId(), market, defaultMax);
boolean staged = PaperTradeStageService.usesStagedTrading(alloc); boolean useStaged = PaperTradeStageService.usesStagedTrading(alloc);
GcPaperPosition posNow = positionRepo.findByAccountIdAndSymbol(account.getId(), market) GcPaperPosition posNow = positionRepo.findByAccountIdAndSymbol(account.getId(), market)
.orElse(null); .orElse(null);
double held = posNow != null ? posNow.getQuantity().doubleValue() : 0; 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 ("BUY".equals(signalType) && held > 0) return;
if ("SELL".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 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)) {
if (staged) { if (useStaged) {
if (held <= 0) { if (held <= 0) {
allocationService.resetTradeStages(account.getId(), market); allocationService.resetTradeStages(account.getId(), market);
alloc = allocationService.getOrDefaultEntity(account.getId(), market, defaultMax); alloc = allocationService.getOrDefaultEntity(account.getId(), market, defaultMax);
@@ -329,10 +330,10 @@ public class PaperTradingService {
if (denom <= 0) return; if (denom <= 0) return;
double qty = stageKrw / denom; double qty = stageKrw / denom;
if (qty * execPrice < minOrder) return; if (qty * execPrice < minOrder) return;
executeTrade(uid, app, market, "BUY", "market", "STRATEGY", // 알림 발생 시점 현재가 기준 미체결 주문 생성 (staged=true → 체결 시 단계 진행)
strategyId, price, qty, null, true); createPendingLimitOrder(account, app, market, "BUY", "market", "STRATEGY",
allocationService.advanceBuyStageAfterTrade(account.getId(), market); strategyId, price, qty, true);
log.info("[Paper] STRATEGY BUY (staged) {} qty≈{} @ {}", market, qty, price); log.info("[Paper] STRATEGY BUY (staged) 미체결 생성 {} qty≈{} @ {}", market, qty, price);
} else { } else {
double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95); double budgetPct = pct(app.getPaperAutoTradeBudgetPct(), 95);
double cash = account.getCashBalance().doubleValue(); double cash = account.getCashBalance().doubleValue();
@@ -344,34 +345,36 @@ public class PaperTradingService {
if (denom <= 0 || budget < minOrder) return; if (denom <= 0 || budget < minOrder) return;
double qty = budget / denom; double qty = budget / denom;
if (qty * execPrice < minOrder) return; if (qty * execPrice < minOrder) return;
executeTrade(uid, app, market, "BUY", "market", "STRATEGY", // 알림 발생 시점 현재가 기준 미체결 주문 생성
strategyId, price, qty, null, true); createPendingLimitOrder(account, app, market, "BUY", "market", "STRATEGY",
log.info("[Paper] STRATEGY BUY {} qty≈{} @ {}", market, qty, price); strategyId, price, qty, false);
log.info("[Paper] STRATEGY BUY 미체결 생성 {} qty≈{} @ {}", market, qty, price);
} }
} else { } else {
if (held <= 0) return; if (held <= 0) return;
double avail = availableSellQty(account.getId(), market, held); double avail = availableSellQty(account.getId(), market, held);
if (avail <= 0) return; if (avail <= 0) return;
if (staged) { if (useStaged) {
double stageKrw = PaperTradeStageService.nextSellStageKrw(alloc); double stageKrw = PaperTradeStageService.nextSellStageKrw(alloc);
if (stageKrw <= 0) return; if (stageKrw <= 0) return;
if (stageKrw < minOrder) return; if (stageKrw < minOrder) return;
double execPrice = price * (1 - slip / 100.0); double execPrice = price * (1 - slip / 100.0);
double qty = PaperTradeStageService.sellQtyForStageKrw(stageKrw, avail, execPrice); double qty = PaperTradeStageService.sellQtyForStageKrw(stageKrw, avail, execPrice);
if (qty * execPrice < minOrder) return; if (qty * execPrice < minOrder) return;
executeTrade(uid, app, market, "SELL", "market", "STRATEGY", // 알림 발생 시점 현재가 기준 미체결 주문 생성 (staged=true → 체결 시 단계 진행)
strategyId, price, qty, null, true); createPendingLimitOrder(account, app, market, "SELL", "market", "STRATEGY",
allocationService.advanceSellStageAfterTrade(account.getId(), market); strategyId, price, qty, true);
log.info("[Paper] STRATEGY SELL (staged) {} qty={} @ {}", market, qty, price); log.info("[Paper] STRATEGY SELL (staged) 미체결 생성 {} qty={} @ {}", market, qty, price);
} else { } else {
executeTrade(uid, app, market, "SELL", "market", "STRATEGY", // 알림 발생 시점 현재가 기준 미체결 주문 생성
strategyId, price, avail, null, true); createPendingLimitOrder(account, app, market, "SELL", "market", "STRATEGY",
log.info("[Paper] STRATEGY SELL {} qty={} @ {}", market, avail, price); strategyId, price, avail, false);
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 미체결 생성 실패 {} {}: {}", market, signalType, e.getMessage());
} }
} }
@@ -379,12 +382,51 @@ public class PaperTradingService {
GcPaperOrder order = orderRepo.findById(orderId).orElse(null); GcPaperOrder order = orderRepo.findById(orderId).orElse(null);
if (order == null || !"PENDING".equals(order.getStatus())) return; if (order == null || !"PENDING".equals(order.getStatus())) return;
boolean fill = false;
double limit = order.getLimitPrice() != null ? order.getLimitPrice().doubleValue() : 0; double limit = order.getLimitPrice() != null ? order.getLimitPrice().doubleValue() : 0;
if ("BUY".equals(order.getSide())) {
fill = markPrice <= limit; // ── 체결 모드 조회 ───────────────────────────────────────────────────────
} else if ("SELL".equals(order.getSide())) { GcPaperAccount acctCheck = accountRepo.findById(order.getAccountId()).orElse(null);
fill = markPrice >= limit; 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; if (!fill) return;
@@ -406,6 +448,21 @@ public class PaperTradingService {
order.setReservedCash(BigDecimal.ZERO); order.setReservedCash(BigDecimal.ZERO);
order.setReservedQty(BigDecimal.ZERO); order.setReservedQty(BigDecimal.ZERO);
orderRepo.save(order); 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) { public double resolveMarkPrice(String symbol) {
@@ -475,7 +532,7 @@ public class PaperTradingService {
private GcPaperOrder createPendingLimitOrder(GcPaperAccount account, GcAppSettings app, private GcPaperOrder createPendingLimitOrder(GcPaperAccount account, GcAppSettings app,
String market, String side, String orderKind, String market, String side, String orderKind,
String source, Long strategyId, String source, Long strategyId,
double limitPrice, double qty) { double limitPrice, double qty, boolean staged) {
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 execPrice = "BUY".equals(side) double execPrice = "BUY".equals(side)
@@ -502,6 +559,7 @@ public class PaperTradingService {
.orderKind(orderKind) .orderKind(orderKind)
.source(source) .source(source)
.strategyId(strategyId) .strategyId(strategyId)
.staged(staged)
.build()); .build());
} }
@@ -523,9 +581,16 @@ public class PaperTradingService {
.orderKind(orderKind) .orderKind(orderKind)
.source(source) .source(source)
.strategyId(strategyId) .strategyId(strategyId)
.staged(staged)
.build()); .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) { private void releaseReservation(GcPaperAccount account, GcPaperOrder order) {
if (order.getReservedCash() != null && order.getReservedCash().signum() > 0) { if (order.getReservedCash() != null && order.getReservedCash().signum() > 0) {
account.setReservedCash(account.getReservedCash().subtract(order.getReservedCash()).max(BigDecimal.ZERO)); account.setReservedCash(account.getReservedCash().subtract(order.getReservedCash()).max(BigDecimal.ZERO));
@@ -668,15 +733,6 @@ public class PaperTradingService {
return avail * pctVal / 100.0; 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, private void upsertPositionBuy(GcPaperAccount account, String market, String koreanName,
BigDecimal qty, BigDecimal price) { BigDecimal qty, BigDecimal price) {
GcPaperPosition pos = positionRepo.findByAccountIdAndSymbol(account.getId(), market).orElse(null); 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);
+5 -1
View File
@@ -2028,7 +2028,7 @@ function App() {
onPaperOrderFilled={handlePaperOrderFilled} onPaperOrderFilled={handlePaperOrderFilled}
onOpenSettings={() => { onOpenSettings={() => {
if (!formalLogin) { requireFormalLogin(); return; } if (!formalLogin) { requireFormalLogin(); return; }
setSettingsInitialCategory('paper'); setSettingsInitialCategory('trading');
setMenuPage('settings'); setMenuPage('settings');
}} }}
onGoChart={m => { onGoChart={m => {
@@ -2200,6 +2200,10 @@ function App() {
onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })} onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })}
paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct} paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct}
onPaperAutoTradeBudgetPct={v => saveAppDef({ paperAutoTradeBudgetPct: v })} onPaperAutoTradeBudgetPct={v => saveAppDef({ paperAutoTradeBudgetPct: v })}
paperOrderFillMode={appDefaults.paperOrderFillMode}
onPaperOrderFillMode={v => saveAppDef({ paperOrderFillMode: v })}
paperOrderAutoCancelPct={appDefaults.paperOrderAutoCancelPct}
onPaperOrderAutoCancelPct={v => saveAppDef({ paperOrderAutoCancelPct: v })}
virtualTargetMaxCount={appDefaults.virtualTargetMaxCount} virtualTargetMaxCount={appDefaults.virtualTargetMaxCount}
onVirtualTargetMaxCount={v => saveAppDef({ virtualTargetMaxCount: clampVirtualTargetMax(v) })} onVirtualTargetMaxCount={v => saveAppDef({ virtualTargetMaxCount: clampVirtualTargetMax(v) })}
trendSearchSettings={appDefaults.trendSearchSettings} trendSearchSettings={appDefaults.trendSearchSettings}
+264 -48
View File
@@ -143,6 +143,10 @@ interface SettingsPageProps {
onPaperAutoTradeEnabled?: (v: boolean) => void; onPaperAutoTradeEnabled?: (v: boolean) => void;
paperAutoTradeBudgetPct?: number; paperAutoTradeBudgetPct?: number;
onPaperAutoTradeBudgetPct?: (v: number) => void; onPaperAutoTradeBudgetPct?: (v: number) => void;
paperOrderFillMode?: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL';
onPaperOrderFillMode?: (v: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL') => void;
paperOrderAutoCancelPct?: number;
onPaperOrderAutoCancelPct?: (v: number) => void;
virtualTargetMaxCount?: number; virtualTargetMaxCount?: number;
onVirtualTargetMaxCount?: (v: number) => void; onVirtualTargetMaxCount?: (v: number) => void;
onPaperAccountReset?: () => void; onPaperAccountReset?: () => void;
@@ -250,6 +254,15 @@ const IcVirtual = () => (
<path d="M10 11h2"/> <path d="M10 11h2"/>
</svg> </svg>
); );
const IcTrading = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="5" width="16" height="12" rx="2"/>
<line x1="7" y1="9" x2="15" y2="9"/>
<line x1="7" y1="12" x2="12" y2="12"/>
<path d="M16 2l2 2-2 2"/>
<path d="M6 20l-2-2 2-2"/>
</svg>
);
const IcLive = () => ( const IcLive = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg width="22" height="22" viewBox="0 0 22 22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="9"/> <circle cx="11" cy="11" r="9"/>
@@ -287,8 +300,7 @@ const ALL_CATEGORIES: Category[] = [
{ id: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' }, { id: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' },
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' }, { id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' },
{ id: 'strategy', label: '전략 설정', icon: <IcStrategy />, desc: '투자 전략 기본 파라미터 및 조건 설정' }, { id: 'strategy', label: '전략 설정', icon: <IcStrategy />, desc: '투자 전략 기본 파라미터 및 조건 설정' },
{ id: 'paper', label: '모의투자', icon: <IcPaper />, desc: '모의투자 활성화, 자동매매, 초기 자본·수수료·슬리피지, 계좌 초기화' }, { id: 'trading', label: '매매설정', icon: <IcTrading />, desc: '모의투자·가상매매 통합 설정 — 자동매매, 미체결 주문 처리, 계좌·비용 등' },
{ id: 'virtual', label: '가상매매', icon: <IcVirtual />, desc: '가상매매 화면 투자대상 목록 한도 등 가상매매 전용 설정' },
{ id: 'live', label: '실거래', icon: <IcLive />, desc: '매매 운영 모드, 업비트 API 키, 실거래 자동매매' }, { id: 'live', label: '실거래', icon: <IcLive />, desc: '매매 운영 모드, 업비트 API 키, 실거래 자동매매' },
{ id: 'trend-search', label: '추세검색', icon: <IcTrendSearch />, desc: '상승추세 배점, 합격 점수, 결과 수, 자동갱신·투자대상 자동추가' }, { id: 'trend-search', label: '추세검색', icon: <IcTrendSearch />, desc: '상승추세 배점, 합격 점수, 결과 수, 자동갱신·투자대상 자동추가' },
{ id: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' }, { id: 'alert', label: '알림 설정', icon: <IcAlert />, desc: '가격 알림, 신호 알림, 알림 방식 설정' },
@@ -474,29 +486,226 @@ const PaperTradingSettingsPanel: React.FC<PaperTradingSettingsPanelProps> = ({
</> </>
); );
const VirtualTradingSettingsPanel: React.FC<{ // ── 통합 매매설정 패널 ────────────────────────────────────────────────────────
interface TradingSettingsPanelProps {
paperTradingEnabled?: boolean;
onPaperTradingEnabled?: (v: boolean) => void;
paperInitialCapital?: number;
onPaperInitialCapital?: (v: number) => void;
paperFeeRatePct?: number;
onPaperFeeRatePct?: (v: number) => void;
paperSlippagePct?: number;
onPaperSlippagePct?: (v: number) => void;
paperMinOrderKrw?: number;
onPaperMinOrderKrw?: (v: number) => void;
paperAutoTradeEnabled?: boolean;
onPaperAutoTradeEnabled?: (v: boolean) => void;
paperAutoTradeBudgetPct?: number;
onPaperAutoTradeBudgetPct?: (v: number) => void;
paperOrderFillMode?: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL';
onPaperOrderFillMode?: (v: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL') => void;
paperOrderAutoCancelPct?: number;
onPaperOrderAutoCancelPct?: (v: number) => void;
virtualTargetMaxCount?: number; virtualTargetMaxCount?: number;
onVirtualTargetMaxCount?: (v: number) => void; onVirtualTargetMaxCount?: (v: number) => void;
}> = ({ virtualTargetMaxCount = 20, onVirtualTargetMaxCount }) => ( onPaperAccountReset?: () => void;
<SettingSection title="가상매매"> }
<SettingRow
label="투자대상 목록 최대 개수" const TradingSettingsPanel: React.FC<TradingSettingsPanelProps> = ({
desc="가상매매 화면·추세검색에서 등록할 수 있는 투자대상 종목 수 상한입니다." paperTradingEnabled = true,
> onPaperTradingEnabled,
<input paperInitialCapital = 10_000_000,
type="number" onPaperInitialCapital,
className="stg-input stg-input--num" paperFeeRatePct = 0.05,
value={virtualTargetMaxCount} onPaperFeeRatePct,
min={1} paperSlippagePct = 0,
max={100} onPaperSlippagePct,
step={1} paperMinOrderKrw = 5000,
onChange={e => onVirtualTargetMaxCount?.(Number(e.target.value))} onPaperMinOrderKrw,
/> paperAutoTradeEnabled = false,
</SettingRow> onPaperAutoTradeEnabled,
<p className="stg-hint"> paperAutoTradeBudgetPct = 95,
·· . onPaperAutoTradeBudgetPct,
</p> paperOrderFillMode = 'LIMIT_ONLY',
</SettingSection> onPaperOrderFillMode,
paperOrderAutoCancelPct = 1.0,
onPaperOrderAutoCancelPct,
virtualTargetMaxCount = 20,
onVirtualTargetMaxCount,
onPaperAccountReset,
}) => (
<>
{/* ── 모의투자 ────────────────────────────────────────── */}
<SettingSection title="모의투자">
<SettingRow
label="모의투자 활성화"
desc="켜면 모의투자 화면·실시간 차트 매매 패널·전략 Match 자동매매가 모의 계좌로 체결됩니다."
>
<label className="stg-toggle">
<input type="checkbox" checked={paperTradingEnabled} onChange={e => onPaperTradingEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
</SettingSection>
{/* ── 자동매매 ─────────────────────────────────────────── */}
<SettingSection title="자동매매">
<SettingRow
label="자동매매"
desc="Match 충족 시 모의 계좌로 자동 매수·매도합니다. 모의투자·가상매매 화면 타이틀바에서도 변경할 수 있습니다."
>
<label className="stg-toggle">
<input type="checkbox" checked={paperAutoTradeEnabled} onChange={e => onPaperAutoTradeEnabled?.(e.target.checked)} />
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow
label="자동 매수 예산 (%)"
desc="Match 매수 시 주문가능 현금 중 사용 비율. Match 매도는 해당 종목 보유 수량 전량 매도."
>
<input
type="number"
className="stg-input stg-input--num"
value={paperAutoTradeBudgetPct}
min={1} max={100} step={1}
disabled={!paperAutoTradeEnabled}
onChange={e => onPaperAutoTradeBudgetPct?.(Number(e.target.value))}
/>
</SettingRow>
</SettingSection>
{/* ── 미체결 주문 처리 ─────────────────────────────────── */}
<SettingSection title="미체결 주문 처리">
<SettingRow
label="체결 모드"
desc="수동·자동매매 시 생성된 미체결 주문이 시세 변동 시 어떻게 처리될지 결정합니다."
className="stg-row--block"
>
<div className="stg-radio-row">
<label className="stg-radio-option stg-radio-option--cyan">
<input
type="radio"
name="stg-fill-mode"
value="LIMIT_ONLY"
checked={paperOrderFillMode === 'LIMIT_ONLY'}
onChange={() => onPaperOrderFillMode?.('LIMIT_ONLY')}
/>
<span className="stg-radio-option-text">
<strong>A. ()</strong>
<span>매수: 시세 / 매도: 시세 . </span>
</span>
</label>
<label className="stg-radio-option stg-radio-option--blue">
<input
type="radio"
name="stg-fill-mode"
value="NEXT_TICK"
checked={paperOrderFillMode === 'NEXT_TICK'}
onChange={() => onPaperOrderFillMode?.('NEXT_TICK')}
/>
<span className="stg-radio-option-text">
<strong>B. </strong>
<span> ( )</span>
</span>
</label>
<label className="stg-radio-option stg-radio-option--orange">
<input
type="radio"
name="stg-fill-mode"
value="AUTO_CANCEL"
checked={paperOrderFillMode === 'AUTO_CANCEL'}
onChange={() => onPaperOrderFillMode?.('AUTO_CANCEL')}
/>
<span className="stg-radio-option-text">
<strong>C. </strong>
<span> % . </span>
</span>
</label>
</div>
</SettingRow>
{paperOrderFillMode === 'AUTO_CANCEL' && (
<SettingRow label="자동 취소 허용 이탈폭 (%)" desc="주문가 대비 이 % 이상 가격이 벗어나면 미체결 주문이 자동 취소됩니다.">
<input
type="number"
className="stg-input stg-input--num"
value={paperOrderAutoCancelPct}
min={0.1} max={50} step={0.1}
onChange={e => onPaperOrderAutoCancelPct?.(Number(e.target.value))}
/>
<span className="stg-unit">%</span>
</SettingRow>
)}
</SettingSection>
{/* ── 계좌·비용 ─────────────────────────────────────────── */}
<SettingSection title="계좌·비용">
<SettingRow label="초기 자본 (KRW)" desc="계좌 초기화 시 적용되는 시작 현금입니다.">
<input
type="number"
className="stg-input stg-input--num"
value={paperInitialCapital}
min={100000} step={100000}
onChange={e => onPaperInitialCapital?.(Number(e.target.value))}
/>
</SettingRow>
<SettingRow label="수수료율 (%)" desc="체결 금액 기준 수수료 (업비트 KRW 마켓 기본 0.05% 참고).">
<input
type="number"
className="stg-input stg-input--num"
value={paperFeeRatePct}
min={0} max={1} step={0.01}
onChange={e => onPaperFeeRatePct?.(Number(e.target.value))}
/>
</SettingRow>
<SettingRow label="슬리피지 (%)" desc="매수는 가격 상승, 매도는 가격 하락으로 체결가를 조정합니다.">
<input
type="number"
className="stg-input stg-input--num"
value={paperSlippagePct}
min={0} max={5} step={0.01}
onChange={e => onPaperSlippagePct?.(Number(e.target.value))}
/>
</SettingRow>
<SettingRow label="최소 주문 금액 (KRW)" desc="이 금액 미만 주문은 거부됩니다.">
<input
type="number"
className="stg-input stg-input--num"
value={paperMinOrderKrw}
min={1000} step={1000}
onChange={e => onPaperMinOrderKrw?.(Number(e.target.value))}
/>
</SettingRow>
</SettingSection>
{/* ── 가상매매 ─────────────────────────────────────────── */}
<SettingSection title="가상매매">
<SettingRow
label="투자대상 목록 최대 개수"
desc="가상매매 화면·추세검색에서 등록할 수 있는 투자대상 종목 수 상한입니다."
>
<input
type="number"
className="stg-input stg-input--num"
value={virtualTargetMaxCount}
min={1} max={100} step={1}
onChange={e => onVirtualTargetMaxCount?.(Number(e.target.value))}
/>
</SettingRow>
</SettingSection>
{/* ── 계좌 관리 ─────────────────────────────────────────── */}
<SettingSection title="계좌 관리">
<SettingRow
label="계좌 초기화"
desc="보유 종목·체결·원장·투자금 한도·미체결 주문을 삭제하고 초기 자본으로 현금을 되돌립니다."
>
<button type="button" className="stg-btn stg-btn--danger" onClick={onPaperAccountReset}>
</button>
</SettingRow>
</SettingSection>
</>
); );
const LiveTradingSettingsPanel: React.FC<{ const LiveTradingSettingsPanel: React.FC<{
@@ -1764,6 +1973,10 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onPaperAutoTradeEnabled, onPaperAutoTradeEnabled,
paperAutoTradeBudgetPct = 95, paperAutoTradeBudgetPct = 95,
onPaperAutoTradeBudgetPct, onPaperAutoTradeBudgetPct,
paperOrderFillMode = 'LIMIT_ONLY',
onPaperOrderFillMode,
paperOrderAutoCancelPct = 1.0,
onPaperOrderAutoCancelPct,
virtualTargetMaxCount = 20, virtualTargetMaxCount = 20,
onVirtualTargetMaxCount, onVirtualTargetMaxCount,
onPaperAccountReset, onPaperAccountReset,
@@ -1913,31 +2126,34 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
<AdminSettingsPanel /> <AdminSettingsPanel />
</AdminPasswordGate> </AdminPasswordGate>
); );
case 'paper': return ( case 'trading':
<PaperTradingSettingsPanel case 'paper':
paperTradingEnabled={paperTradingEnabled} case 'virtual':
onPaperTradingEnabled={onPaperTradingEnabled} return (
paperInitialCapital={paperInitialCapital} <TradingSettingsPanel
onPaperInitialCapital={onPaperInitialCapital} paperTradingEnabled={paperTradingEnabled}
paperFeeRatePct={paperFeeRatePct} onPaperTradingEnabled={onPaperTradingEnabled}
onPaperFeeRatePct={onPaperFeeRatePct} paperInitialCapital={paperInitialCapital}
paperSlippagePct={paperSlippagePct} onPaperInitialCapital={onPaperInitialCapital}
onPaperSlippagePct={onPaperSlippagePct} paperFeeRatePct={paperFeeRatePct}
paperMinOrderKrw={paperMinOrderKrw} onPaperFeeRatePct={onPaperFeeRatePct}
onPaperMinOrderKrw={onPaperMinOrderKrw} paperSlippagePct={paperSlippagePct}
paperAutoTradeEnabled={paperAutoTradeEnabled} onPaperSlippagePct={onPaperSlippagePct}
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled} paperMinOrderKrw={paperMinOrderKrw}
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct} onPaperMinOrderKrw={onPaperMinOrderKrw}
onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct} paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperAccountReset={onPaperAccountReset} onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
/> paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
); onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct}
case 'virtual': return ( paperOrderFillMode={paperOrderFillMode}
<VirtualTradingSettingsPanel onPaperOrderFillMode={onPaperOrderFillMode}
virtualTargetMaxCount={virtualTargetMaxCount} paperOrderAutoCancelPct={paperOrderAutoCancelPct}
onVirtualTargetMaxCount={onVirtualTargetMaxCount} onPaperOrderAutoCancelPct={onPaperOrderAutoCancelPct}
/> virtualTargetMaxCount={virtualTargetMaxCount}
); onVirtualTargetMaxCount={onVirtualTargetMaxCount}
onPaperAccountReset={onPaperAccountReset}
/>
);
case 'live': return ( case 'live': return (
<LiveTradingSettingsPanel <LiveTradingSettingsPanel
tradingMode={tradingMode} tradingMode={tradingMode}
@@ -85,6 +85,30 @@ const PaperLeftSettingsTab: React.FC<Props> = ({
onChange={e => patch({ paperAutoTradeBudgetPct: Number(e.target.value) })} onChange={e => patch({ paperAutoTradeBudgetPct: Number(e.target.value) })}
/> />
</SettingsFieldRow> </SettingsFieldRow>
<SettingsFieldRow label="미체결 주문 처리">
<select
className="vtd-trade-meta-value ptd-settings-input ptd-settings-select"
value={defaults.paperOrderFillMode}
onChange={e => patch({ paperOrderFillMode: e.target.value as 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL' })}
>
<option value="LIMIT_ONLY">A. ( )</option>
<option value="NEXT_TICK">B. ( )</option>
<option value="AUTO_CANCEL">C. </option>
</select>
</SettingsFieldRow>
{defaults.paperOrderFillMode === 'AUTO_CANCEL' && (
<SettingsFieldRow label="허용 이탈폭 (%)">
<input
type="number"
className="vtd-trade-meta-value ptd-settings-input"
min={0.1}
max={50}
step={0.1}
value={defaults.paperOrderAutoCancelPct}
onChange={e => patch({ paperOrderAutoCancelPct: Number(e.target.value) })}
/>
</SettingsFieldRow>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -100,8 +100,8 @@ function loadReadIds(): Set<string> {
return new Set(ids ?? []); return new Set(ids ?? []);
} }
function saveReadIds(ids: Set<string>) { function saveReadIds(ids: Set<string>, immediate = false) {
patchUiPreferences({ tradeNotifications: { readIds: [...ids] } }); patchUiPreferences({ tradeNotifications: { readIds: [...ids] } }, immediate);
} }
function loadHiddenIds(): Set<string> { function loadHiddenIds(): Set<string> {
@@ -181,7 +181,10 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
*/ */
if (!opts?.rawServerList) { if (!opts?.rawServerList) {
newlyArrived = items.filter( newlyArrived = items.filter(
item => !item.isRead && !prev.some(p => p.id === item.id), item =>
!item.isRead &&
!readIdsRef.current.has(item.id) && // readIds 에 없는(진짜 새) 알림만
!prev.some(p => p.id === item.id), // 이미 목록에 없는 알림만
); );
} }
@@ -245,43 +248,58 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => { const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => {
const id = makeId(signal); const id = makeId(signal);
/**
* 이전에 읽음 처리(닫기)한 동일 키를 readIds 에서 제거하던 로직을 삭제.
* STOMP 재연결 시 동일 신호가 재수신되어도 사용자가 명시적으로 닫은 알림은
* 다시 열리지 않아야 한다. 새로운 candleTime 을 가진 신호는 키가 달라
* readIds 에 없으므로 정상적으로 미읽음 처리된다.
*/
const item: TradeNotificationItem = {
...signal,
id,
dbId: signal.dbId,
candleType: signal.candleType ?? '1m',
isRead: false,
receivedAt: Date.now(),
};
// 배지(미확인 건수)는 팝업 설정과 무관하게 항상 갱신 /**
* 이미 읽음(닫기) 처리된 알림 ID 이면:
* - allNotifications 에 이미 존재하면 isRead 상태를 건드리지 않는다.
* - 새로운 항목이면 isRead: true 로 조용히 이력에만 추가한다.
* - 토스트 팝업·알림음은 표시하지 않는다.
* STOMP 재연결 시 동일 신호가 재수신되거나 20 초 폴링에서 새로 발견되어도
* 사용자가 명시적으로 닫은 알림은 다시 열리지 않는다.
*/
const alreadyRead = readIdsRef.current.has(id);
setAllNotifications(prev => { setAllNotifications(prev => {
const existing = prev.find(n => n.id === id); const existing = prev.find(n => n.id === id);
if (existing) { if (existing) {
// 읽음 처리된 항목은 isRead 를 false 로 되돌리지 않는다
if (existing.isRead || alreadyRead) return prev;
// 미읽음 항목은 receivedAt 만 갱신
return prev.map(n => return prev.map(n =>
n.id === id n.id === id ? { ...n, isRead: false, receivedAt: Date.now() } : n,
? { ...n, ...item, isRead: false, receivedAt: item.receivedAt }
: n,
); );
} }
const item: TradeNotificationItem = {
...signal,
id,
dbId: signal.dbId,
candleType: signal.candleType ?? '1m',
isRead: alreadyRead,
receivedAt: Date.now(),
};
return [item, ...prev].slice(0, MAX_HISTORY); return [item, ...prev].slice(0, MAX_HISTORY);
}); });
// 이미 읽음 처리된 알림은 팝업·알림음 모두 생략
if (alreadyRead) return;
if (soundEnabled) { if (soundEnabled) {
playTradeAlertSound(alertSoundId); playTradeAlertSound(alertSoundId);
} }
if (!popupEnabled) return; if (!popupEnabled) return;
const toastItem: TradeNotificationItem = {
...signal,
id,
dbId: signal.dbId,
candleType: signal.candleType ?? '1m',
isRead: false,
receivedAt: Date.now(),
};
setToastNotifications(prev => { setToastNotifications(prev => {
const rest = prev.filter(n => n.id !== id); const rest = prev.filter(n => n.id !== id);
return [item, ...rest].slice(0, MAX_TOAST_QUEUE); return [toastItem, ...rest].slice(0, MAX_TOAST_QUEUE);
}); });
}, [popupEnabled, soundEnabled, alertSoundId]); }, [popupEnabled, soundEnabled, alertSoundId]);
@@ -320,19 +338,20 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
const dismissAllToasts = useCallback(() => { const dismissAllToasts = useCallback(() => {
/** /**
* 전체 닫기 = 현재 토스트 + allNotifications 의 모든 미읽음 항목을 읽음 처리. * 전체 닫기 = 토스트 + allNotifications 의 모든 항목을 즉시 읽음 처리.
* 기존에는 toastNotifications 에 있는 항목만 처리해서, 토스트에 없는 미읽음 항목은 * patchUiPreferences immediate=true 로 디바운스 없이 즉시 DB에 반영해
* 다음 refreshHistory 폴링 시 다시 팝업으로 나타나는 문제가 있었다. * 이후 폴링·STOMP 재수신 시에도 readIds 가 항상 최신 상태를 유지한다.
*/ */
const nextRead = new Set(readIdsRef.current);
toastNotificationsRef.current.forEach(n => nextRead.add(n.id));
// allNotifications 는 state 업데이트 전이므로 ref가 아닌 최신 snapshot이 필요.
// setAllNotifications 콜백 안에서 nextRead를 채운 뒤 즉시 저장한다.
setAllNotifications(prev => { setAllNotifications(prev => {
const nextRead = new Set(readIdsRef.current);
// 토스트 항목
toastNotificationsRef.current.forEach(n => nextRead.add(n.id));
// allNotifications 의 미읽음 항목 전체
prev.forEach(n => nextRead.add(n.id)); prev.forEach(n => nextRead.add(n.id));
saveReadIds(nextRead);
readIdsRef.current = nextRead; readIdsRef.current = nextRead;
setReadIds(nextRead); setReadIds(nextRead);
// immediate=true 로 400ms 디바운스 없이 즉시 저장
patchUiPreferences({ tradeNotifications: { readIds: [...nextRead] } }, true);
return prev.map(n => ({ ...n, isRead: true })); return prev.map(n => ({ ...n, isRead: true }));
}); });
setToastNotifications([]); setToastNotifications([]);
+2
View File
@@ -184,6 +184,8 @@ export function resolveAppDefaults(s: AppSettingsDto) {
paperMinOrderKrw: s.paperMinOrderKrw ?? 5000, paperMinOrderKrw: s.paperMinOrderKrw ?? 5000,
paperAutoTradeEnabled: s.paperAutoTradeEnabled ?? false, paperAutoTradeEnabled: s.paperAutoTradeEnabled ?? false,
paperAutoTradeBudgetPct: s.paperAutoTradeBudgetPct ?? 95, paperAutoTradeBudgetPct: s.paperAutoTradeBudgetPct ?? 95,
paperOrderFillMode: (s.paperOrderFillMode ?? 'LIMIT_ONLY') as 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL',
paperOrderAutoCancelPct: s.paperOrderAutoCancelPct ?? 1.0,
virtualTargetMaxCount: clampVirtualTargetMax(s.virtualTargetMaxCount ?? DEFAULT_VIRTUAL_TARGET_MAX), virtualTargetMaxCount: clampVirtualTargetMax(s.virtualTargetMaxCount ?? DEFAULT_VIRTUAL_TARGET_MAX),
tradingMode: s.tradingMode ?? 'PAPER', tradingMode: s.tradingMode ?? 'PAPER',
liveAutoTradeEnabled: s.liveAutoTradeEnabled ?? false, liveAutoTradeEnabled: s.liveAutoTradeEnabled ?? false,
+9
View File
@@ -475,6 +475,15 @@ export interface AppSettingsDto {
paperMinOrderKrw?: number; paperMinOrderKrw?: number;
paperAutoTradeEnabled?: boolean; paperAutoTradeEnabled?: boolean;
paperAutoTradeBudgetPct?: number; paperAutoTradeBudgetPct?: number;
/**
* 미체결 주문 체결 모드
* LIMIT_ONLY : 지정가 조건 충족 시에만 체결 (기본)
* NEXT_TICK : 다음 틱 강제 체결
* AUTO_CANCEL : 허용 이탈폭(paperOrderAutoCancelPct %) 초과 시 자동 취소
*/
paperOrderFillMode?: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL';
/** AUTO_CANCEL 모드 허용 이탈폭 (%) */
paperOrderAutoCancelPct?: number;
/** 가상매매 투자대상 목록 최대 종목 수 */ /** 가상매매 투자대상 목록 최대 종목 수 */
virtualTargetMaxCount?: number; virtualTargetMaxCount?: number;
/** PAPER | LIVE | BOTH */ /** PAPER | LIVE | BOTH */
+3 -2
View File
@@ -9,14 +9,14 @@ export type TopMenuId = MenuPage;
export type SettingsCategoryId = export type SettingsCategoryId =
| 'general' | 'chart' | 'indicators' | 'backtest' | 'strategy' | 'general' | 'chart' | 'indicators' | 'backtest' | 'strategy'
| 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin'; | 'trading' | 'paper' | 'virtual' | 'live' | 'trend-search' | 'alert' | 'network' | 'admin';
export const TOP_MENU_IDS: TopMenuId[] = [ export const TOP_MENU_IDS: TopMenuId[] = [
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'analysis-report', 'notifications', 'settings', 'verification-board', 'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'analysis-report', 'notifications', 'settings', 'verification-board',
]; ];
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
'general', 'chart', 'indicators', 'backtest', 'strategy', 'paper', 'virtual', 'live', 'general', 'chart', 'indicators', 'backtest', 'strategy', 'trading', 'live',
'trend-search', 'alert', 'network', 'admin', 'trend-search', 'alert', 'network', 'admin',
]; ];
@@ -45,6 +45,7 @@ export const MENU_LABELS: Record<string, string> = {
settings_indicators: '설정 · 보조지표', settings_indicators: '설정 · 보조지표',
settings_backtest: '설정 · 백테스팅', settings_backtest: '설정 · 백테스팅',
settings_strategy: '설정 · 전략', settings_strategy: '설정 · 전략',
settings_trading: '설정 · 매매설정',
settings_paper: '설정 · 모의투자', settings_paper: '설정 · 모의투자',
settings_virtual: '설정 · 가상매매', settings_virtual: '설정 · 가상매매',
settings_live: '설정 · 실거래', settings_live: '설정 · 실거래',
+1
View File
@@ -24,6 +24,7 @@ export function isTradingMenuPage(page: string): boolean {
export const TRADING_SETTINGS_CATEGORIES = new Set([ export const TRADING_SETTINGS_CATEGORIES = new Set([
'strategy', 'strategy',
'trading',
'paper', 'paper',
'virtual', 'virtual',
'live', 'live',