분석레포트 로직 수정

This commit is contained in:
Macbook
2026-06-08 10:08:39 +09:00
parent e11e1a46fa
commit c20c806c19
22 changed files with 254 additions and 108 deletions
@@ -12,6 +12,9 @@ public class PaperTradeDto {
private String orderKind; private String orderKind;
private String source; private String source;
private Long strategyId; private Long strategyId;
private String candleType;
private String strategyName;
private String executionType;
private Double price; private Double price;
private Double quantity; private Double quantity;
private Double grossAmount; private Double grossAmount;
@@ -28,6 +28,9 @@ public class GcBacktestResult {
@Column(name = "settings_json", columnDefinition = "JSON") @Column(name = "settings_json", columnDefinition = "JSON")
@JdbcTypeCode(SqlTypes.JSON) private String settingsJson; @JdbcTypeCode(SqlTypes.JSON) private String settingsJson;
@Column(name = "execution_snapshot_json", columnDefinition = "JSON")
@JdbcTypeCode(SqlTypes.JSON) private String executionSnapshotJson;
@Column(name = "signals_json", columnDefinition = "JSON") @Column(name = "signals_json", columnDefinition = "JSON")
@JdbcTypeCode(SqlTypes.JSON) private String signalsJson; @JdbcTypeCode(SqlTypes.JSON) private String signalsJson;
@@ -61,6 +61,15 @@ public class GcPaperOrder {
@Column(name = "strategy_id") @Column(name = "strategy_id")
private Long strategyId; private Long strategyId;
@Column(name = "candle_type", length = 10)
private String candleType;
@Column(name = "strategy_name", length = 200)
private String strategyName;
@Column(name = "execution_type", length = 30)
private String executionType;
/** /**
* 분할매매(staged) 주문 여부. * 분할매매(staged) 주문 여부.
* TRUE 이면 체결(FILLED) 시 allocationService 단계를 자동 진행한다. * TRUE 이면 체결(FILLED) 시 allocationService 단계를 자동 진행한다.
@@ -35,6 +35,18 @@ public class GcPaperTrade {
@Column(name = "strategy_id") @Column(name = "strategy_id")
private Long strategyId; private Long strategyId;
/** 체결 시점 전략 평가 시간봉 (1m, 10m, …) */
@Column(name = "candle_type", length = 10)
private String candleType;
/** 체결 시점 전략명 스냅샷 */
@Column(name = "strategy_name", length = 200)
private String strategyName;
/** CANDLE_CLOSE | REALTIME_TICK */
@Column(name = "execution_type", length = 30)
private String executionType;
@Column(name = "price", precision = 20, scale = 2, nullable = false) @Column(name = "price", precision = 20, scale = 2, nullable = false)
private BigDecimal price; private BigDecimal price;
@@ -96,7 +96,7 @@ public class BacktestingService {
: new BooleanRule(false); : new BooleanRule(false);
Rule exitRule = buildExitRule(baseExitRule, series, cfg); Rule exitRule = buildExitRule(baseExitRule, series, cfg);
return runBacktest(series, entryRule, exitRule, req, cfg, strategyName); return runBacktest(series, entryRule, exitRule, req, cfg, strategyName, buyDsl, sellDsl, params);
} }
// ── 청산 규칙 합성 ──────────────────────────────────────────────────────── // ── 청산 규칙 합성 ────────────────────────────────────────────────────────
@@ -124,7 +124,9 @@ public class BacktestingService {
private BacktestResponse runBacktest(BarSeries series, Rule entryRule, Rule exitRule, private BacktestResponse runBacktest(BarSeries series, Rule entryRule, Rule exitRule,
BacktestRequest req, BacktestSettingsDto cfg, BacktestRequest req, BacktestSettingsDto cfg,
String strategyName) { String strategyName,
JsonNode execBuyDsl, JsonNode execSellDsl,
Map<String, Map<String, Object>> execParams) {
BaseTradingRecord record = new BaseTradingRecord(); BaseTradingRecord record = new BaseTradingRecord();
@@ -295,7 +297,8 @@ public class BacktestingService {
Stats stats = toStats(analysis, signals); Stats stats = toStats(analysis, signals);
// ── DB 저장 ─────────────────────────────────────────────────────────── // ── DB 저장 ───────────────────────────────────────────────────────────
Long resultId = saveResult(req, cfg, signals, analysis, series, strategyName); Long resultId = saveResult(req, cfg, signals, analysis, series, strategyName,
execBuyDsl, execSellDsl, execParams);
return BacktestResponse.builder() return BacktestResponse.builder()
.signals(signals) .signals(signals)
@@ -601,12 +604,24 @@ public class BacktestingService {
private Long saveResult(BacktestRequest req, BacktestSettingsDto cfg, private Long saveResult(BacktestRequest req, BacktestSettingsDto cfg,
List<Signal> signals, BacktestAnalysisDto analysis, List<Signal> signals, BacktestAnalysisDto analysis,
BarSeries series, String strategyName) { BarSeries series, String strategyName,
JsonNode execBuyDsl, JsonNode execSellDsl,
Map<String, Map<String, Object>> execParams) {
try { try {
List<OhlcvBar> bars = req.getBars(); List<OhlcvBar> bars = req.getBars();
long fromTime = bars.isEmpty() ? 0 : bars.get(0).getTime(); long fromTime = bars.isEmpty() ? 0 : bars.get(0).getTime();
long toTime = bars.isEmpty() ? 0 : bars.get(bars.size() - 1).getTime(); long toTime = bars.isEmpty() ? 0 : bars.get(bars.size() - 1).getTime();
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("strategyId", req.getStrategyId());
snapshot.put("strategyName", strategyName);
snapshot.put("symbol", req.getSymbol() != null ? req.getSymbol() : "UNKNOWN");
snapshot.put("timeframe", req.getTimeframe());
snapshot.put("barCount", bars.size());
if (execParams != null && !execParams.isEmpty()) snapshot.put("indicatorParams", execParams);
if (execBuyDsl != null && !execBuyDsl.isNull()) snapshot.put("buyCondition", execBuyDsl);
if (execSellDsl != null && !execSellDsl.isNull()) snapshot.put("sellCondition", execSellDsl);
GcBacktestResult entity = GcBacktestResult.builder() GcBacktestResult entity = GcBacktestResult.builder()
.deviceId(req.getDeviceId()) .deviceId(req.getDeviceId())
.strategyId(req.getStrategyId()) .strategyId(req.getStrategyId())
@@ -617,6 +632,7 @@ public class BacktestingService {
.fromTime(fromTime) .fromTime(fromTime)
.toTime(toTime) .toTime(toTime)
.settingsJson(objectMapper.writeValueAsString(cfg)) .settingsJson(objectMapper.writeValueAsString(cfg))
.executionSnapshotJson(objectMapper.writeValueAsString(snapshot))
.signalsJson(objectMapper.writeValueAsString(signals)) .signalsJson(objectMapper.writeValueAsString(signals))
.analysisJson(objectMapper.writeValueAsString(analysis)) .analysisJson(objectMapper.writeValueAsString(analysis))
.totalReturn(BigDecimal.valueOf(analysis.getTotalReturnPct())) .totalReturn(BigDecimal.valueOf(analysis.getTotalReturnPct()))
@@ -87,10 +87,13 @@ public class BarCloseStrategyEvaluationService {
closeSignal, signalBar.getClosePrice().doubleValue(), closeSignal, signalBar.getClosePrice().doubleValue(),
candleTimeEpoch, ct, signalExecType); candleTimeEpoch, ct, signalExecType);
if (s.getUserId() != null) { if (s.getUserId() != null) {
String strategyName = tradeSignalService.resolveStrategyName(
s.getStrategyId(), null);
orderExecutionQueue.submitSignal( orderExecutionQueue.submitSignal(
s.getUserId(), market, s.getUserId(), market,
s.getStrategyId(), closeSignal, s.getStrategyId(), closeSignal,
signalBar.getClosePrice().doubleValue()); signalBar.getClosePrice().doubleValue(),
ct, strategyName, signalExecType);
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("[BarCloseEval] 시그널 처리 실패 ({}/{} strategyId={}): {}", log.warn("[BarCloseEval] 시그널 처리 실패 ({}/{} strategyId={}): {}",
@@ -237,7 +237,7 @@ public class PaperTradingService {
// 시장가(MARKET)를 포함한 모든 주문을 현재가 기준 지정가 미체결로 생성한다. // 시장가(MARKET)를 포함한 모든 주문을 현재가 기준 지정가 미체결로 생성한다.
// 체결은 PaperOrderMatcherService 가 다음 시세 틱에서 처리한다. // 체결은 PaperOrderMatcherService 가 다음 시세 틱에서 처리한다.
GcPaperOrder order = createPendingLimitOrder(account, app, req.getMarket(), side, orderKind, GcPaperOrder order = createPendingLimitOrder(account, app, req.getMarket(), side, orderKind,
source, req.getStrategyId(), price, qty, false); source, req.getStrategyId(), price, qty, false, null, null, null);
return PaperPlaceOrderResult.builder().order(toOrderDto(order)).build(); return PaperPlaceOrderResult.builder().order(toOrderDto(order)).build();
} }
@@ -261,6 +261,12 @@ public class PaperTradingService {
public void tryExecuteOnSignal(Long userId, String market, public void tryExecuteOnSignal(Long userId, String market,
Long strategyId, String signalType, double price) { Long strategyId, String signalType, double price) {
tryExecuteOnSignal(userId, market, strategyId, signalType, price, null, null, null);
}
public void tryExecuteOnSignal(Long userId, String market,
Long strategyId, String signalType, double price,
String candleType, String strategyName, String executionType) {
long uid; long uid;
try { try {
uid = TradingAccess.requireUserId(userId); uid = TradingAccess.requireUserId(userId);
@@ -332,7 +338,8 @@ public class PaperTradingService {
if (qty * execPrice < minOrder) return; if (qty * execPrice < minOrder) return;
// 알림 발생 시점 현재가 기준 미체결 주문 생성 (staged=true → 체결 시 단계 진행) // 알림 발생 시점 현재가 기준 미체결 주문 생성 (staged=true → 체결 시 단계 진행)
createPendingLimitOrder(account, app, market, "BUY", "market", "STRATEGY", createPendingLimitOrder(account, app, market, "BUY", "market", "STRATEGY",
strategyId, price, qty, true); strategyId, price, qty, true,
candleType, strategyName, executionType);
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);
@@ -347,7 +354,8 @@ public class PaperTradingService {
if (qty * execPrice < minOrder) return; if (qty * execPrice < minOrder) return;
// 알림 발생 시점 현재가 기준 미체결 주문 생성 // 알림 발생 시점 현재가 기준 미체결 주문 생성
createPendingLimitOrder(account, app, market, "BUY", "market", "STRATEGY", createPendingLimitOrder(account, app, market, "BUY", "market", "STRATEGY",
strategyId, price, qty, false); strategyId, price, qty, false,
candleType, strategyName, executionType);
log.info("[Paper] STRATEGY BUY 미체결 생성 {} qty≈{} @ {}", market, qty, price); log.info("[Paper] STRATEGY BUY 미체결 생성 {} qty≈{} @ {}", market, qty, price);
} }
} else { } else {
@@ -364,12 +372,14 @@ public class PaperTradingService {
if (qty * execPrice < minOrder) return; if (qty * execPrice < minOrder) return;
// 알림 발생 시점 현재가 기준 미체결 주문 생성 (staged=true → 체결 시 단계 진행) // 알림 발생 시점 현재가 기준 미체결 주문 생성 (staged=true → 체결 시 단계 진행)
createPendingLimitOrder(account, app, market, "SELL", "market", "STRATEGY", createPendingLimitOrder(account, app, market, "SELL", "market", "STRATEGY",
strategyId, price, qty, true); strategyId, price, qty, true,
candleType, strategyName, executionType);
log.info("[Paper] STRATEGY SELL (staged) 미체결 생성 {} qty={} @ {}", market, qty, price); log.info("[Paper] STRATEGY SELL (staged) 미체결 생성 {} qty={} @ {}", market, qty, price);
} else { } else {
// 알림 발생 시점 현재가 기준 미체결 주문 생성 // 알림 발생 시점 현재가 기준 미체결 주문 생성
createPendingLimitOrder(account, app, market, "SELL", "market", "STRATEGY", createPendingLimitOrder(account, app, market, "SELL", "market", "STRATEGY",
strategyId, price, avail, false); strategyId, price, avail, false,
candleType, strategyName, executionType);
log.info("[Paper] STRATEGY SELL 미체결 생성 {} qty={} @ {}", market, avail, price); log.info("[Paper] STRATEGY SELL 미체결 생성 {} qty={} @ {}", market, avail, price);
} }
} }
@@ -440,7 +450,8 @@ public class PaperTradingService {
double qty = order.getQuantity().doubleValue(); double qty = order.getQuantity().doubleValue();
executeTrade(uid, app, order.getSymbol(), executeTrade(uid, app, order.getSymbol(),
order.getSide(), order.getOrderKind(), order.getSource(), order.getStrategyId(), order.getSide(), order.getOrderKind(), order.getSource(), order.getStrategyId(),
limit, qty, null, "STRATEGY".equals(order.getSource())); limit, qty, null, "STRATEGY".equals(order.getSource()),
order.getCandleType(), order.getStrategyName(), order.getExecutionType());
order.setStatus("FILLED"); order.setStatus("FILLED");
order.setFilledQuantity(order.getQuantity()); order.setFilledQuantity(order.getQuantity());
@@ -532,7 +543,9 @@ 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, boolean staged) { double limitPrice, double qty, boolean staged,
String candleType, String strategyName,
String executionType) {
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)
@@ -559,6 +572,9 @@ public class PaperTradingService {
.orderKind(orderKind) .orderKind(orderKind)
.source(source) .source(source)
.strategyId(strategyId) .strategyId(strategyId)
.candleType(candleType)
.strategyName(strategyName)
.executionType(executionType)
.staged(staged) .staged(staged)
.build()); .build());
} }
@@ -581,6 +597,9 @@ public class PaperTradingService {
.orderKind(orderKind) .orderKind(orderKind)
.source(source) .source(source)
.strategyId(strategyId) .strategyId(strategyId)
.candleType(candleType)
.strategyName(strategyName)
.executionType(executionType)
.staged(staged) .staged(staged)
.build()); .build());
} }
@@ -611,6 +630,15 @@ public class PaperTradingService {
String market, String side, String orderKind, String source, String market, String side, String orderKind, String source,
Long strategyId, double inputPrice, double inputQty, Long strategyId, double inputPrice, double inputQty,
String koreanName, boolean autoTrade) { String koreanName, boolean autoTrade) {
return executeTrade(userId, app, market, side, orderKind, source, strategyId,
inputPrice, inputQty, koreanName, autoTrade, null, null, null);
}
private PaperTradeDto executeTrade(long userId, GcAppSettings app,
String market, String side, String orderKind, String source,
Long strategyId, double inputPrice, double inputQty,
String koreanName, boolean autoTrade,
String candleType, String strategyName, String executionType) {
GcPaperAccount account = getOrCreateAccount(userId, app); GcPaperAccount account = getOrCreateAccount(userId, app);
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);
@@ -646,6 +674,9 @@ public class PaperTradingService {
.orderKind(orderKind) .orderKind(orderKind)
.source(source) .source(source)
.strategyId(strategyId) .strategyId(strategyId)
.candleType(candleType)
.strategyName(strategyName)
.executionType(executionType)
.price(price) .price(price)
.quantity(qty) .quantity(qty)
.grossAmount(gross) .grossAmount(gross)
@@ -690,6 +721,9 @@ public class PaperTradingService {
.orderKind(orderKind) .orderKind(orderKind)
.source(source) .source(source)
.strategyId(strategyId) .strategyId(strategyId)
.candleType(candleType)
.strategyName(strategyName)
.executionType(executionType)
.price(price) .price(price)
.quantity(qty) .quantity(qty)
.grossAmount(gross) .grossAmount(gross)
@@ -791,6 +825,9 @@ public class PaperTradingService {
.orderKind(t.getOrderKind()) .orderKind(t.getOrderKind())
.source(t.getSource()) .source(t.getSource())
.strategyId(t.getStrategyId()) .strategyId(t.getStrategyId())
.candleType(t.getCandleType())
.strategyName(t.getStrategyName())
.executionType(t.getExecutionType())
.price(t.getPrice().doubleValue()) .price(t.getPrice().doubleValue())
.quantity(t.getQuantity().doubleValue()) .quantity(t.getQuantity().doubleValue())
.grossAmount(t.getGrossAmount().doubleValue()) .grossAmount(t.getGrossAmount().doubleValue())
@@ -122,7 +122,7 @@ public class TradeSignalService {
return deviceId.equals(entity.getDeviceId()); return deviceId.equals(entity.getDeviceId());
} }
private String resolveStrategyName(Long strategyId, String strategyName) { public String resolveStrategyName(Long strategyId, String strategyName) {
if (strategyName != null && !strategyName.isBlank()) return strategyName; if (strategyName != null && !strategyName.isBlank()) return strategyName;
if (strategyId == null) return null; if (strategyId == null) return null;
return strategyRepo.findById(strategyId) return strategyRepo.findById(strategyId)
@@ -24,12 +24,20 @@ public class TradingExecutionService {
public void executeSignal(Long userId, String market, public void executeSignal(Long userId, String market,
Long strategyId, String side, double price) { Long strategyId, String side, double price) {
executeSignal(userId, market, strategyId, side, price, null, null, null);
}
public void executeSignal(Long userId, String market,
Long strategyId, String side, double price,
String candleType, String strategyName, String executionType) {
if (!TradingAccess.isRegisteredUser(userId)) return; if (!TradingAccess.isRegisteredUser(userId)) return;
GcAppSettings app = appSettingsService.getEntity(userId, null); GcAppSettings app = appSettingsService.getEntity(userId, null);
TradingMode mode = TradingMode.fromString(app.getTradingMode()); TradingMode mode = TradingMode.fromString(app.getTradingMode());
if (mode.usePaper() && Boolean.TRUE.equals(app.getPaperTradingEnabled())) { if (mode.usePaper() && Boolean.TRUE.equals(app.getPaperTradingEnabled())) {
paperTradingService.tryExecuteOnSignal(userId, market, strategyId, side, price); paperTradingService.tryExecuteOnSignal(
userId, market, strategyId, side, price,
candleType, strategyName, executionType);
} }
if (mode.useLive()) { if (mode.useLive()) {
liveTradingService.tryExecuteOnSignal( liveTradingService.tryExecuteOnSignal(
@@ -67,9 +67,16 @@ public class OrderExecutionQueue {
public void submitSignal(Long userId, String market, public void submitSignal(Long userId, String market,
Long strategyId, String signal, double price) { Long strategyId, String signal, double price) {
submitSignal(userId, market, strategyId, signal, price, null, null, null);
}
public void submitSignal(Long userId, String market,
Long strategyId, String signal, double price,
String candleType, String strategyName, String executionType) {
if (!TradingAccess.isRegisteredUser(userId)) return; if (!TradingAccess.isRegisteredUser(userId)) return;
submit(OrderRequest.strategySignal( submit(OrderRequest.strategySignal(
TradingAccess.accountDeviceKey(userId), userId, market, strategyId, signal, price)); TradingAccess.accountDeviceKey(userId), userId, market, strategyId, signal, price,
candleType, strategyName, executionType));
} }
private void runLoop() { private void runLoop() {
@@ -97,7 +104,8 @@ public class OrderExecutionQueue {
if (req.kind() == OrderRequest.OrderKind.STRATEGY_SIGNAL) { if (req.kind() == OrderRequest.OrderKind.STRATEGY_SIGNAL) {
tradingExecutionService.executeSignal( tradingExecutionService.executeSignal(
req.userId(), req.market(), req.userId(), req.market(),
req.strategyId(), req.side(), req.price()); req.strategyId(), req.side(), req.price(),
req.candleType(), req.strategyName(), req.executionType());
} else { } else {
tradingExecutionService.executeRiskExit(req); tradingExecutionService.executeRiskExit(req);
} }
@@ -11,7 +11,10 @@ public record OrderRequest(
Long strategyId, Long strategyId,
String side, String side,
double price, double price,
String reason String reason,
String candleType,
String strategyName,
String executionType
) { ) {
public enum OrderKind { public enum OrderKind {
STRATEGY_SIGNAL, STRATEGY_SIGNAL,
@@ -20,20 +23,25 @@ public record OrderRequest(
} }
public static OrderRequest strategySignal(String deviceId, Long userId, String market, public static OrderRequest strategySignal(String deviceId, Long userId, String market,
Long strategyId, String side, double price) { Long strategyId, String side, double price,
String candleType, String strategyName,
String executionType) {
return new OrderRequest(OrderKind.STRATEGY_SIGNAL, deviceId, userId, market, return new OrderRequest(OrderKind.STRATEGY_SIGNAL, deviceId, userId, market,
strategyId, side, price, "STRATEGY"); strategyId, side, price, "STRATEGY",
candleType, strategyName, executionType);
} }
public static OrderRequest stopLoss(String deviceId, Long userId, String market, public static OrderRequest stopLoss(String deviceId, Long userId, String market,
double price, double lossPct) { double price, double lossPct) {
return new OrderRequest(OrderKind.STOP_LOSS, deviceId, userId, market, return new OrderRequest(OrderKind.STOP_LOSS, deviceId, userId, market,
null, "SELL", price, "STOP_LOSS_" + lossPct + "%"); null, "SELL", price, "STOP_LOSS_" + lossPct + "%",
null, null, null);
} }
public static OrderRequest takeProfit(String deviceId, Long userId, String market, public static OrderRequest takeProfit(String deviceId, Long userId, String market,
double price, double profitPct) { double price, double profitPct) {
return new OrderRequest(OrderKind.TAKE_PROFIT, deviceId, userId, market, return new OrderRequest(OrderKind.TAKE_PROFIT, deviceId, userId, market,
null, "SELL", price, "TAKE_PROFIT_" + profitPct + "%"); null, "SELL", price, "TAKE_PROFIT_" + profitPct + "%",
null, null, null);
} }
} }
@@ -122,10 +122,13 @@ public class LiveStrategyScheduler {
barStartEpoch, candleType, "REALTIME_TICK" barStartEpoch, candleType, "REALTIME_TICK"
); );
if (s.getUserId() != null) { if (s.getUserId() != null) {
String strategyName = tradeSignalService.resolveStrategyName(
s.getStrategyId(), null);
orderExecutionQueue.submitSignal( orderExecutionQueue.submitSignal(
s.getUserId(), market, s.getUserId(), market,
s.getStrategyId(), signal, s.getStrategyId(), signal,
lastBar.getClosePrice().doubleValue()); lastBar.getClosePrice().doubleValue(),
candleType, strategyName, "REALTIME_TICK");
} }
} catch (Exception ex) { } catch (Exception ex) {
log.warn("[LiveStrategyScheduler] 시그널 DB 저장 실패: {}", ex.getMessage()); log.warn("[LiveStrategyScheduler] 시그널 DB 저장 실패: {}", ex.getMessage());
@@ -0,0 +1,10 @@
-- 매매 실행 당시 전략·시간봉 스냅샷 (분석레포트 실시간 매매 탭)
ALTER TABLE gc_paper_trade
ADD COLUMN candle_type VARCHAR(10) NULL COMMENT '체결 시점 평가 시간봉' AFTER strategy_id,
ADD COLUMN strategy_name VARCHAR(200) NULL COMMENT '체결 시점 전략명' AFTER candle_type,
ADD COLUMN execution_type VARCHAR(30) NULL COMMENT 'CANDLE_CLOSE | REALTIME_TICK' AFTER strategy_name;
ALTER TABLE gc_paper_order
ADD COLUMN candle_type VARCHAR(10) NULL AFTER strategy_id,
ADD COLUMN strategy_name VARCHAR(200) NULL AFTER candle_type,
ADD COLUMN execution_type VARCHAR(30) NULL AFTER strategy_name;
@@ -0,0 +1,3 @@
-- 백테스트 실행 당시 전략 DSL·지표 파라미터 스냅샷
ALTER TABLE gc_backtest_result
ADD COLUMN execution_snapshot_json JSON NULL COMMENT '실행 당시 symbol/timeframe/DSL/indicatorParams 스냅샷' AFTER settings_json;
@@ -251,14 +251,10 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
); );
} }
if (tab === 'live' && selectedLive) { if (tab === 'live' && selectedLive) {
return buildLiveReportModel( return buildLiveReportModel(selectedLive, activeSignals);
selectedLive,
activeSignals,
chartProps?.timeframe ?? '1h',
);
} }
return null; return null;
}, [tab, selectedBacktest, backtestDetail, selectedLive, activeSignals, chartProps?.timeframe, strategyNamesById]); }, [tab, selectedBacktest, backtestDetail, selectedLive, activeSignals, strategyNamesById]);
const showRightDetail = tab === 'backtest' const showRightDetail = tab === 'backtest'
? backtestDetail?.analysis != null ? backtestDetail?.analysis != null
@@ -119,7 +119,7 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
); );
} }
if (sourceTab === 'live' && selectedLive) { if (sourceTab === 'live' && selectedLive) {
return buildLiveReportModel(selectedLive, ctx?.signals ?? [], '1h'); return buildLiveReportModel(selectedLive, ctx?.signals ?? []);
} }
return null; return null;
}, [sourceTab, selectedBacktest, selectedLive, ctx, strategyNamesById]); }, [sourceTab, selectedBacktest, selectedLive, ctx, strategyNamesById]);
@@ -110,10 +110,12 @@ export default function BacktestExecutionList({
const code = item.symbol.replace(/^KRW-/i, '').toLowerCase(); const code = item.symbol.replace(/^KRW-/i, '').toLowerCase();
const strategy = item.strategyLabel.toLowerCase(); const strategy = item.strategyLabel.toLowerCase();
const source = item.sourceLabel.toLowerCase(); const source = item.sourceLabel.toLowerCase();
const tf = item.timeframe?.toLowerCase() ?? '';
return ko.includes(normalizedQuery) return ko.includes(normalizedQuery)
|| code.includes(normalizedQuery) || code.includes(normalizedQuery)
|| strategy.includes(normalizedQuery) || strategy.includes(normalizedQuery)
|| source.includes(normalizedQuery); || source.includes(normalizedQuery)
|| tf.includes(normalizedQuery);
}); });
}, [liveItems, normalizedQuery]); }, [liveItems, normalizedQuery]);
@@ -133,7 +135,7 @@ export default function BacktestExecutionList({
<input <input
type="text" type="text"
className="btd-exec-search-input" className="btd-exec-search-input"
placeholder={tab === 'backtest' ? '종목·전략·타임프레임 검색…' : '종목·전략 검색…'} placeholder={tab === 'backtest' ? '종목·전략·타임프레임 검색…' : '종목·전략·타임프레임 검색…'}
value={query} value={query}
onChange={e => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
aria-label="목록 검색" aria-label="목록 검색"
@@ -242,12 +244,16 @@ export default function BacktestExecutionList({
<div className="btd-history-card-row"> <div className="btd-history-card-row">
<div className="btd-history-card-main"> <div className="btd-history-card-main">
<span className="btd-history-ko">{ko}</span> <span className="btd-history-ko">{ko}</span>
<span className="btd-history-strategy">{item.strategyLabel} · {item.sourceLabel}</span> <span className="btd-history-strategy">
{item.strategyLabel}
{item.timeframe !== 'unknown' ? ` · ${formatTimeframeKo(item.timeframe)}` : ''}
{item.sourceLabel !== '자동' ? ` · ${item.sourceLabel}` : ''}
</span>
<span className="btd-history-date">{fmtShortTimestamp(item.createdAt)}</span> <span className="btd-history-date">{fmtShortTimestamp(item.createdAt)}</span>
</div> </div>
<div className="btd-history-card-side"> <div className="btd-history-card-side">
<BacktestSparkline curve={parseLiveSpark(item)} positive={positive} width={56} height={22} /> <BacktestSparkline curve={parseLiveSpark(item)} positive={positive} width={56} height={22} />
<span className="btd-history-win"> {item.tradeCount}</span> <span className="btd-history-win"> {item.roundTripCount} · {item.tradeCount}</span>
<span className={`btd-history-roi${positive ? ' up' : ' down'}`}>{pct(item.totalReturnPct)}</span> <span className={`btd-history-roi${positive ? ' up' : ' down'}`}>{pct(item.totalReturnPct)}</span>
</div> </div>
</div> </div>
+5 -27
View File
@@ -8,6 +8,7 @@ import type { LiveExecutionItem } from './liveExecutionGroups';
import type { EquityPoint, TradeHistoryRow } from './backtestEquity'; import type { EquityPoint, TradeHistoryRow } from './backtestEquity';
import { buildEquityFromSignals } from './backtestEquity'; import { buildEquityFromSignals } from './backtestEquity';
import { paperTradesToSignals } from './liveExecutionGroups'; import { paperTradesToSignals } from './liveExecutionGroups';
import { buildAnalysisFromPaperTrades } from './paperMetrics';
import { repairUtf8Mojibake } from './textEncoding'; import { repairUtf8Mojibake } from './textEncoding';
export type AnalysisSourceTab = 'backtest' | 'live'; export type AnalysisSourceTab = 'backtest' | 'live';
@@ -72,37 +73,14 @@ export function buildContextFromLive(item: LiveExecutionItem): AnalysisReportCon
const signals = paperTradesToSignals(item.trades); const signals = paperTradesToSignals(item.trades);
const cap = 10_000_000; const cap = 10_000_000;
const eq = buildEquityFromSignals(signals, cap, item.symbol); const eq = buildEquityFromSignals(signals, cap, item.symbol);
const finalEquity = cap * (1 + item.totalReturnPct); const analysis = buildAnalysisFromPaperTrades(item.trades, cap);
const analysis: BacktestAnalysis = { const timeframe = item.timeframe !== 'unknown' ? item.timeframe : '—';
initialCapital: cap,
finalEquity,
totalReturnPct: item.totalReturnPct,
totalProfitLoss: finalEquity - cap,
grossProfit: 0,
grossLoss: 0,
avgReturnPct: item.tradeCount > 0 ? item.totalReturnPct / item.tradeCount : 0,
profitLossRatio: item.profitFactor,
numberOfPositions: item.tradeCount,
numberOfWinning: Math.round(item.tradeCount * item.winRatePct),
numberOfLosing: Math.max(0, item.tradeCount - Math.round(item.tradeCount * item.winRatePct)),
numberOfBreakEven: 0,
winRate: item.winRatePct,
maxDrawdownPct: item.mddPct,
maxRunupPct: 0,
sharpeRatio: item.sharpeRatio,
sortinoRatio: 0,
calmarRatio: 0,
valueAtRisk95: 0,
expectedShortfall: 0,
buyAndHoldReturnPct: 0,
vsBuyAndHold: 0,
};
return { return {
sourceTab: 'live', sourceTab: 'live',
strategyName: repairUtf8Mojibake(item.strategyLabel), strategyName: repairUtf8Mojibake(item.strategyLabel),
symbol: item.symbol, symbol: item.symbol,
timeframe: '1h', timeframe,
barCount: 300, barCount: item.trades.length,
createdAt: item.createdAt, createdAt: item.createdAt,
analysis, analysis,
signals, signals,
+4
View File
@@ -702,6 +702,9 @@ export interface PaperTradeDto {
orderKind: string; orderKind: string;
source: string; source: string;
strategyId?: number | null; strategyId?: number | null;
candleType?: string | null;
strategyName?: string | null;
executionType?: string | null;
price: number; price: number;
quantity: number; quantity: number;
grossAmount: number; grossAmount: number;
@@ -1208,6 +1211,7 @@ export interface BacktestResultRecord {
fromTime: number; fromTime: number;
toTime: number; toTime: number;
settingsJson: string; settingsJson: string;
executionSnapshotJson?: string;
signalsJson: string; signalsJson: string;
analysisJson: string; analysisJson: string;
totalReturn: number; totalReturn: number;
+7 -34
View File
@@ -6,41 +6,17 @@ import type {
} from './backendApi'; } from './backendApi';
import type { BacktestAnalysisReportModel } from '../components/backtest/BacktestAnalysisReportModal'; import type { BacktestAnalysisReportModel } from '../components/backtest/BacktestAnalysisReportModal';
import type { LiveExecutionItem } from './liveExecutionGroups'; import type { LiveExecutionItem } from './liveExecutionGroups';
import { buildAnalysisFromPaperTrades } from './paperMetrics';
import { fmtListTimestamp, toUpbitMarket } from './backtestUiUtils'; import { fmtListTimestamp, toUpbitMarket } from './backtestUiUtils';
import { getKoreanName } from './marketNameCache'; import { getKoreanName } from './marketNameCache';
import { repairUtf8Mojibake } from './textEncoding'; import { repairUtf8Mojibake } from './textEncoding';
/** 실시간 매매 탭 — KPI만 있는 경우 최소 분석 객체 */ /** 실시간 매매 탭 — 체결 이력 기반 분석 */
export function buildAnalysisFromLive( export function buildAnalysisFromLive(
item: LiveExecutionItem, item: LiveExecutionItem,
initialCapital = 10_000_000, initialCapital = 10_000_000,
): BacktestAnalysis { ): BacktestAnalysis {
const finalEquity = initialCapital * (1 + item.totalReturnPct); return buildAnalysisFromPaperTrades(item.trades, initialCapital);
const winCount = Math.round(item.tradeCount * item.winRatePct);
return {
initialCapital,
finalEquity,
totalReturnPct: item.totalReturnPct,
totalProfitLoss: finalEquity - initialCapital,
grossProfit: 0,
grossLoss: 0,
avgReturnPct: item.tradeCount > 0 ? item.totalReturnPct / item.tradeCount : 0,
profitLossRatio: item.profitFactor,
numberOfPositions: item.tradeCount,
numberOfWinning: winCount,
numberOfLosing: Math.max(0, item.tradeCount - winCount),
numberOfBreakEven: 0,
winRate: item.winRatePct,
maxDrawdownPct: item.mddPct,
maxRunupPct: 0,
sharpeRatio: item.sharpeRatio,
sortinoRatio: 0,
calmarRatio: 0,
valueAtRisk95: 0,
expectedShortfall: 0,
buyAndHoldReturnPct: 0,
vsBuyAndHold: 0,
};
} }
function buildAnalysisFromStats(s: BacktestStats): BacktestAnalysis { function buildAnalysisFromStats(s: BacktestStats): BacktestAnalysis {
@@ -101,14 +77,11 @@ export function buildBacktestReportModel(
record: BacktestResultRecord, record: BacktestResultRecord,
analysis: BacktestAnalysis, analysis: BacktestAnalysis,
signals: BacktestSignal[], signals: BacktestSignal[],
strategyNamesById: Record<number, string> = {}, _strategyNamesById: Record<number, string> = {},
): BacktestAnalysisReportModel { ): BacktestAnalysisReportModel {
const market = toUpbitMarket(record.symbol); const market = toUpbitMarket(record.symbol);
const freshName = record.strategyId != null
? strategyNamesById[record.strategyId]
: undefined;
const strategyName = repairUtf8Mojibake( const strategyName = repairUtf8Mojibake(
freshName || record.strategyName || '전략 없음', record.strategyName || '전략 없음',
); );
return { return {
analysis, analysis,
@@ -126,10 +99,10 @@ export function buildBacktestReportModel(
export function buildLiveReportModel( export function buildLiveReportModel(
item: LiveExecutionItem, item: LiveExecutionItem,
signals: BacktestSignal[], signals: BacktestSignal[],
timeframe: string,
initialCapital = 10_000_000, initialCapital = 10_000_000,
): BacktestAnalysisReportModel { ): BacktestAnalysisReportModel {
const market = toUpbitMarket(item.symbol); const market = toUpbitMarket(item.symbol);
const timeframe = item.timeframe !== 'unknown' ? item.timeframe : '—';
return { return {
analysis: buildAnalysisFromLive(item, initialCapital), analysis: buildAnalysisFromLive(item, initialCapital),
signals, signals,
@@ -137,7 +110,7 @@ export function buildLiveReportModel(
symbol: item.symbol, symbol: item.symbol,
symbolKo: getKoreanName(market), symbolKo: getKoreanName(market),
timeframe, timeframe,
barCount: 300, barCount: item.trades.length,
createdAt: item.createdAt ? fmtListTimestamp(item.createdAt) : undefined, createdAt: item.createdAt ? fmtListTimestamp(item.createdAt) : undefined,
reportKind: 'live', reportKind: 'live',
}; };
+39 -13
View File
@@ -1,16 +1,21 @@
import type { PaperSummaryDto, PaperTradeDto } from './backendApi'; import type { PaperSummaryDto, PaperTradeDto } from './backendApi';
import { computePaperMetrics } from './paperMetrics'; import { buildAnalysisFromPaperTrades, computePaperMetrics } from './paperMetrics';
export interface LiveExecutionItem { export interface LiveExecutionItem {
id: string; id: string;
symbol: string; symbol: string;
strategyLabel: string; strategyLabel: string;
sourceLabel: string; sourceLabel: string;
/** 체결 시점 평가 시간봉 */
timeframe: string;
executionType?: string;
strategyId?: number | null;
trades: PaperTradeDto[]; trades: PaperTradeDto[];
createdAt: string; createdAt: string;
totalReturnPct: number; totalReturnPct: number;
winRatePct: number; winRatePct: number;
tradeCount: number; tradeCount: number;
roundTripCount: number;
mddPct: number; mddPct: number;
sharpeRatio: number; sharpeRatio: number;
profitFactor: number; profitFactor: number;
@@ -26,6 +31,27 @@ function sourceLabel(source: string): string {
return '수동'; return '수동';
} }
function resolveStrategyLabel(
t: PaperTradeDto,
strategyNames: Record<number, string>,
): string {
if (t.strategyName) return t.strategyName;
if (t.strategyId != null) {
return strategyNames[t.strategyId] ?? `전략 #${t.strategyId}`;
}
return '수동 매매';
}
/** 자동매매: 종목·전략·시간봉·실행방식 기준 / 수동: 종목·일자 기준 */
function groupKey(t: PaperTradeDto): string {
if (t.source === 'STRATEGY') {
const tf = t.candleType ?? 'unknown';
const exec = t.executionType ?? '';
return `${t.symbol}|${t.strategyId ?? 0}|${tf}|${exec}|STRATEGY`;
}
return `${t.symbol}|manual|${dayKey(t.createdAt)}|MANUAL`;
}
/** 가상투자·모의투자 체결 이력을 실행 단위 목록으로 그룹 */ /** 가상투자·모의투자 체결 이력을 실행 단위 목록으로 그룹 */
export function buildLiveExecutionItems( export function buildLiveExecutionItems(
trades: PaperTradeDto[], trades: PaperTradeDto[],
@@ -36,7 +62,7 @@ export function buildLiveExecutionItems(
const groups = new Map<string, PaperTradeDto[]>(); const groups = new Map<string, PaperTradeDto[]>();
for (const t of trades) { for (const t of trades) {
const key = `${t.symbol}|${t.strategyId ?? 0}|${dayKey(t.createdAt)}|${t.source}`; const key = groupKey(t);
const list = groups.get(key) ?? []; const list = groups.get(key) ?? [];
list.push(t); list.push(t);
groups.set(key, list); groups.set(key, list);
@@ -51,26 +77,25 @@ export function buildLiveExecutionItems(
); );
const first = sorted[0]; const first = sorted[0];
const last = sorted[sorted.length - 1]; const last = sorted[sorted.length - 1];
const analysis = buildAnalysisFromPaperTrades(sorted, initial);
const metrics = computePaperMetrics(sorted, summary); const metrics = computePaperMetrics(sorted, summary);
const buyVol = sorted.filter(t => t.side === 'BUY').reduce((s, t) => s + t.netAmount, 0);
const sellVol = sorted.filter(t => t.side === 'SELL').reduce((s, t) => s + t.netAmount, 0);
const pnl = sellVol - buyVol;
const totalReturnPct = initial > 0 ? pnl / initial : 0;
return { return {
id: key, id: key,
symbol: first.symbol, symbol: first.symbol,
strategyLabel: first.strategyId != null strategyLabel: resolveStrategyLabel(first, strategyNames),
? (strategyNames[first.strategyId] ?? `전략 #${first.strategyId}`)
: '수동 매매',
sourceLabel: sourceLabel(first.source), sourceLabel: sourceLabel(first.source),
timeframe: first.candleType ?? 'unknown',
executionType: first.executionType ?? undefined,
strategyId: first.strategyId,
trades: sorted, trades: sorted,
createdAt: last.createdAt ?? first.createdAt ?? new Date().toISOString(), createdAt: last.createdAt ?? first.createdAt ?? new Date().toISOString(),
totalReturnPct, totalReturnPct: analysis.totalReturnPct,
winRatePct: metrics.winRatePct / 100, winRatePct: analysis.winRate,
tradeCount: sorted.length, tradeCount: sorted.length,
mddPct: metrics.mddPct / 100, roundTripCount: analysis.numberOfPositions,
sharpeRatio: metrics.sharpeRatio, mddPct: analysis.maxDrawdownPct,
sharpeRatio: analysis.sharpeRatio,
profitFactor: metrics.profitFactor, profitFactor: metrics.profitFactor,
}; };
}) })
@@ -84,6 +109,7 @@ export function paperTradesToSignals(trades: PaperTradeDto[]) {
time: Math.floor(new Date(t.createdAt!).getTime() / 1000), time: Math.floor(new Date(t.createdAt!).getTime() / 1000),
type: t.side as 'BUY' | 'SELL', type: t.side as 'BUY' | 'SELL',
price: t.price, price: t.price,
quantity: t.quantity,
barIndex: 0, barIndex: 0,
})); }));
} }
+41 -1
View File
@@ -1,4 +1,4 @@
import type { PaperSummaryDto, PaperTradeDto } from './backendApi'; import type { PaperSummaryDto, PaperTradeDto, BacktestAnalysis } from './backendApi';
export interface PaperPerformanceMetrics { export interface PaperPerformanceMetrics {
mddPct: number; mddPct: number;
@@ -126,3 +126,43 @@ export function computePaperMetrics(
profitFactor: Math.min(profitFactor, 99), profitFactor: Math.min(profitFactor, 99),
}; };
} }
/** 실시간 매매 체결 이력 → 백테스트 분석 DTO (ledger·라운드트립 기준) */
export function buildAnalysisFromPaperTrades(
trades: PaperTradeDto[],
initialCapital = 10_000_000,
): BacktestAnalysis {
const sorted = [...trades].sort((a, b) =>
(a.createdAt ?? '').localeCompare(b.createdAt ?? ''),
);
const metrics = computePaperMetrics(sorted, { initialCapital } as PaperSummaryDto);
const curve = buildEquityCurve(sorted, initialCapital);
const finalEquity = curve.length > 0 ? curve[curve.length - 1] : initialCapital;
const { wins, total, grossProfit, grossLoss } = roundTripStats(sorted);
const profitLossRatio = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? 99 : 0;
return {
initialCapital,
finalEquity,
totalReturnPct: initialCapital > 0 ? (finalEquity - initialCapital) / initialCapital : 0,
totalProfitLoss: finalEquity - initialCapital,
grossProfit,
grossLoss,
avgReturnPct: total > 0 ? (finalEquity - initialCapital) / initialCapital / total : 0,
profitLossRatio: Math.min(profitLossRatio, 99),
numberOfPositions: total,
numberOfWinning: wins,
numberOfLosing: Math.max(0, total - wins),
numberOfBreakEven: 0,
winRate: total > 0 ? wins / total : 0,
maxDrawdownPct: metrics.mddPct / 100,
maxRunupPct: 0,
sharpeRatio: metrics.sharpeRatio,
sortinoRatio: 0,
calmarRatio: 0,
valueAtRisk95: 0,
expectedShortfall: 0,
buyAndHoldReturnPct: 0,
vsBuyAndHold: 0,
};
}