알림 전체닫기 오류 수정

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(
"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);
+5 -1
View File
@@ -2028,7 +2028,7 @@ function App() {
onPaperOrderFilled={handlePaperOrderFilled}
onOpenSettings={() => {
if (!formalLogin) { requireFormalLogin(); return; }
setSettingsInitialCategory('paper');
setSettingsInitialCategory('trading');
setMenuPage('settings');
}}
onGoChart={m => {
@@ -2200,6 +2200,10 @@ function App() {
onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })}
paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct}
onPaperAutoTradeBudgetPct={v => saveAppDef({ paperAutoTradeBudgetPct: v })}
paperOrderFillMode={appDefaults.paperOrderFillMode}
onPaperOrderFillMode={v => saveAppDef({ paperOrderFillMode: v })}
paperOrderAutoCancelPct={appDefaults.paperOrderAutoCancelPct}
onPaperOrderAutoCancelPct={v => saveAppDef({ paperOrderAutoCancelPct: v })}
virtualTargetMaxCount={appDefaults.virtualTargetMaxCount}
onVirtualTargetMaxCount={v => saveAppDef({ virtualTargetMaxCount: clampVirtualTargetMax(v) })}
trendSearchSettings={appDefaults.trendSearchSettings}
+264 -48
View File
@@ -143,6 +143,10 @@ interface SettingsPageProps {
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;
onVirtualTargetMaxCount?: (v: number) => void;
onPaperAccountReset?: () => void;
@@ -250,6 +254,15 @@ const IcVirtual = () => (
<path d="M10 11h2"/>
</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 = () => (
<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"/>
@@ -287,8 +300,7 @@ const ALL_CATEGORIES: Category[] = [
{ id: 'indicators', label: '보조지표 설정', icon: <IcIndicators />, desc: '지표 추가 Main 탭 보조지표 기본 파라미터·색상·기준선' },
{ id: 'backtest', label: '백테스팅', icon: <IcBacktest />, desc: '백테스팅 실행 옵션, 자본·비용·리스크 관리 설정' },
{ id: 'strategy', label: '전략 설정', icon: <IcStrategy />, desc: '투자 전략 기본 파라미터 및 조건 설정' },
{ id: 'paper', label: '모의투자', icon: <IcPaper />, desc: '모의투자 활성화, 자동매매, 초기 자본·수수료·슬리피지, 계좌 초기화' },
{ id: 'virtual', label: '가상매매', icon: <IcVirtual />, desc: '가상매매 화면 투자대상 목록 한도 등 가상매매 전용 설정' },
{ id: 'trading', label: '매매설정', icon: <IcTrading />, desc: '모의투자·가상매매 통합 설정 — 자동매매, 미체결 주문 처리, 계좌·비용 등' },
{ id: 'live', label: '실거래', icon: <IcLive />, desc: '매매 운영 모드, 업비트 API 키, 실거래 자동매매' },
{ id: 'trend-search', label: '추세검색', icon: <IcTrendSearch />, 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;
onVirtualTargetMaxCount?: (v: number) => void;
}> = ({ virtualTargetMaxCount = 20, onVirtualTargetMaxCount }) => (
<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>
<p className="stg-hint">
·· .
</p>
</SettingSection>
onPaperAccountReset?: () => void;
}
const TradingSettingsPanel: React.FC<TradingSettingsPanelProps> = ({
paperTradingEnabled = true,
onPaperTradingEnabled,
paperInitialCapital = 10_000_000,
onPaperInitialCapital,
paperFeeRatePct = 0.05,
onPaperFeeRatePct,
paperSlippagePct = 0,
onPaperSlippagePct,
paperMinOrderKrw = 5000,
onPaperMinOrderKrw,
paperAutoTradeEnabled = false,
onPaperAutoTradeEnabled,
paperAutoTradeBudgetPct = 95,
onPaperAutoTradeBudgetPct,
paperOrderFillMode = 'LIMIT_ONLY',
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<{
@@ -1764,6 +1973,10 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onPaperAutoTradeEnabled,
paperAutoTradeBudgetPct = 95,
onPaperAutoTradeBudgetPct,
paperOrderFillMode = 'LIMIT_ONLY',
onPaperOrderFillMode,
paperOrderAutoCancelPct = 1.0,
onPaperOrderAutoCancelPct,
virtualTargetMaxCount = 20,
onVirtualTargetMaxCount,
onPaperAccountReset,
@@ -1913,31 +2126,34 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
<AdminSettingsPanel />
</AdminPasswordGate>
);
case 'paper': return (
<PaperTradingSettingsPanel
paperTradingEnabled={paperTradingEnabled}
onPaperTradingEnabled={onPaperTradingEnabled}
paperInitialCapital={paperInitialCapital}
onPaperInitialCapital={onPaperInitialCapital}
paperFeeRatePct={paperFeeRatePct}
onPaperFeeRatePct={onPaperFeeRatePct}
paperSlippagePct={paperSlippagePct}
onPaperSlippagePct={onPaperSlippagePct}
paperMinOrderKrw={paperMinOrderKrw}
onPaperMinOrderKrw={onPaperMinOrderKrw}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct}
onPaperAccountReset={onPaperAccountReset}
/>
);
case 'virtual': return (
<VirtualTradingSettingsPanel
virtualTargetMaxCount={virtualTargetMaxCount}
onVirtualTargetMaxCount={onVirtualTargetMaxCount}
/>
);
case 'trading':
case 'paper':
case 'virtual':
return (
<TradingSettingsPanel
paperTradingEnabled={paperTradingEnabled}
onPaperTradingEnabled={onPaperTradingEnabled}
paperInitialCapital={paperInitialCapital}
onPaperInitialCapital={onPaperInitialCapital}
paperFeeRatePct={paperFeeRatePct}
onPaperFeeRatePct={onPaperFeeRatePct}
paperSlippagePct={paperSlippagePct}
onPaperSlippagePct={onPaperSlippagePct}
paperMinOrderKrw={paperMinOrderKrw}
onPaperMinOrderKrw={onPaperMinOrderKrw}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
onPaperAutoTradeBudgetPct={onPaperAutoTradeBudgetPct}
paperOrderFillMode={paperOrderFillMode}
onPaperOrderFillMode={onPaperOrderFillMode}
paperOrderAutoCancelPct={paperOrderAutoCancelPct}
onPaperOrderAutoCancelPct={onPaperOrderAutoCancelPct}
virtualTargetMaxCount={virtualTargetMaxCount}
onVirtualTargetMaxCount={onVirtualTargetMaxCount}
onPaperAccountReset={onPaperAccountReset}
/>
);
case 'live': return (
<LiveTradingSettingsPanel
tradingMode={tradingMode}
@@ -85,6 +85,30 @@ const PaperLeftSettingsTab: React.FC<Props> = ({
onChange={e => patch({ paperAutoTradeBudgetPct: Number(e.target.value) })}
/>
</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>
@@ -100,8 +100,8 @@ function loadReadIds(): Set<string> {
return new Set(ids ?? []);
}
function saveReadIds(ids: Set<string>) {
patchUiPreferences({ tradeNotifications: { readIds: [...ids] } });
function saveReadIds(ids: Set<string>, immediate = false) {
patchUiPreferences({ tradeNotifications: { readIds: [...ids] } }, immediate);
}
function loadHiddenIds(): Set<string> {
@@ -181,7 +181,10 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
*/
if (!opts?.rawServerList) {
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 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 => {
const existing = prev.find(n => n.id === id);
if (existing) {
// 읽음 처리된 항목은 isRead 를 false 로 되돌리지 않는다
if (existing.isRead || alreadyRead) return prev;
// 미읽음 항목은 receivedAt 만 갱신
return prev.map(n =>
n.id === id
? { ...n, ...item, isRead: false, receivedAt: item.receivedAt }
: n,
n.id === id ? { ...n, isRead: false, receivedAt: Date.now() } : 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);
});
// 이미 읽음 처리된 알림은 팝업·알림음 모두 생략
if (alreadyRead) return;
if (soundEnabled) {
playTradeAlertSound(alertSoundId);
}
if (!popupEnabled) return;
const toastItem: TradeNotificationItem = {
...signal,
id,
dbId: signal.dbId,
candleType: signal.candleType ?? '1m',
isRead: false,
receivedAt: Date.now(),
};
setToastNotifications(prev => {
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]);
@@ -320,19 +338,20 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
const dismissAllToasts = useCallback(() => {
/**
* 전체 닫기 = 현재 토스트 + allNotifications 의 모든 미읽음 항목을 읽음 처리.
* 기존에는 toastNotifications 에 있는 항목만 처리해서, 토스트에 없는 미읽음 항목은
* 다음 refreshHistory 폴링 시 다시 팝업으로 나타나는 문제가 있었다.
* 전체 닫기 = 토스트 + allNotifications 의 모든 항목을 즉시 읽음 처리.
* patchUiPreferences immediate=true 로 디바운스 없이 즉시 DB에 반영해
* 이후 폴링·STOMP 재수신 시에도 readIds 가 항상 최신 상태를 유지한다.
*/
const nextRead = new Set(readIdsRef.current);
toastNotificationsRef.current.forEach(n => nextRead.add(n.id));
// allNotifications 는 state 업데이트 전이므로 ref가 아닌 최신 snapshot이 필요.
// setAllNotifications 콜백 안에서 nextRead를 채운 뒤 즉시 저장한다.
setAllNotifications(prev => {
const nextRead = new Set(readIdsRef.current);
// 토스트 항목
toastNotificationsRef.current.forEach(n => nextRead.add(n.id));
// allNotifications 의 미읽음 항목 전체
prev.forEach(n => nextRead.add(n.id));
saveReadIds(nextRead);
readIdsRef.current = nextRead;
setReadIds(nextRead);
// immediate=true 로 400ms 디바운스 없이 즉시 저장
patchUiPreferences({ tradeNotifications: { readIds: [...nextRead] } }, true);
return prev.map(n => ({ ...n, isRead: true }));
});
setToastNotifications([]);
+2
View File
@@ -184,6 +184,8 @@ export function resolveAppDefaults(s: AppSettingsDto) {
paperMinOrderKrw: s.paperMinOrderKrw ?? 5000,
paperAutoTradeEnabled: s.paperAutoTradeEnabled ?? false,
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),
tradingMode: s.tradingMode ?? 'PAPER',
liveAutoTradeEnabled: s.liveAutoTradeEnabled ?? false,
+9
View File
@@ -475,6 +475,15 @@ export interface AppSettingsDto {
paperMinOrderKrw?: number;
paperAutoTradeEnabled?: boolean;
paperAutoTradeBudgetPct?: number;
/**
* 미체결 주문 체결 모드
* LIMIT_ONLY : 지정가 조건 충족 시에만 체결 (기본)
* NEXT_TICK : 다음 틱 강제 체결
* AUTO_CANCEL : 허용 이탈폭(paperOrderAutoCancelPct %) 초과 시 자동 취소
*/
paperOrderFillMode?: 'LIMIT_ONLY' | 'NEXT_TICK' | 'AUTO_CANCEL';
/** AUTO_CANCEL 모드 허용 이탈폭 (%) */
paperOrderAutoCancelPct?: number;
/** 가상매매 투자대상 목록 최대 종목 수 */
virtualTargetMaxCount?: number;
/** PAPER | LIVE | BOTH */
+3 -2
View File
@@ -9,14 +9,14 @@ export type TopMenuId = MenuPage;
export type SettingsCategoryId =
| '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[] = [
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'analysis-report', 'notifications', 'settings', 'verification-board',
];
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',
];
@@ -45,6 +45,7 @@ export const MENU_LABELS: Record<string, string> = {
settings_indicators: '설정 · 보조지표',
settings_backtest: '설정 · 백테스팅',
settings_strategy: '설정 · 전략',
settings_trading: '설정 · 매매설정',
settings_paper: '설정 · 모의투자',
settings_virtual: '설정 · 가상매매',
settings_live: '설정 · 실거래',
+1
View File
@@ -24,6 +24,7 @@ export function isTradingMenuPage(page: string): boolean {
export const TRADING_SETTINGS_CATEGORIES = new Set([
'strategy',
'trading',
'paper',
'virtual',
'live',