매수, 매도 색상 수정

This commit is contained in:
Macbook
2026-05-31 01:23:47 +09:00
parent ba4ef5e230
commit 9d7dddfa57
24 changed files with 573 additions and 441 deletions
@@ -0,0 +1,161 @@
package com.goldenchart.service;
import com.goldenchart.dto.CandleBarDto;
import com.goldenchart.entity.GcLiveStrategySettings;
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
import com.goldenchart.websocket.TradingWebSocketBroker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import com.goldenchart.storage.Ta4jStorage;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/**
* 봉 마감 시점 전략 평가 — BarBuilder·갭 백필 등 단일 진입점.
*
* <p>활성 live 설정 중 전략 DSL에 포함된 분봉({@link StrategyConditionTimeframeService})
* 에서만 평가하며, 동일 봉 마감(endTime)에 대해 중복 평가하지 않는다.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class BarCloseStrategyEvaluationService {
private final LiveStrategyEvaluator liveStrategyEvaluator;
private final TradeSignalService tradeSignalService;
private final OrderExecutionQueue orderExecutionQueue;
private final GcLiveStrategySettingsRepository liveSettingsRepo;
private final StrategyConditionTimeframeService conditionTimeframes;
private final TradingWebSocketBroker broker;
private final Ta4jStorage ta4jStorage;
/** market:candleType:barEndEpoch — 동일 마감봉 중복 평가 방지 */
private final ConcurrentHashMap<String, Long> evaluatedBarCloseKeys = new ConcurrentHashMap<>();
/**
* 확정된 봉(마감)에 대해 전략 평가 + BUY/SELL 시 STOMP·DB·주문 큐.
*/
public void onMaturedBarClose(String market, String candleType, int maturedIndex, Bar signalBar) {
String ct = LiveStrategyTimeframeService.normalize(candleType);
long barEndEpoch = signalBar.getEndTime().getEpochSecond();
String dedupKey = market + ":" + ct + ":" + barEndEpoch;
if (evaluatedBarCloseKeys.putIfAbsent(dedupKey, barEndEpoch) != null) {
return;
}
trimEvaluatedKeys();
List<GcLiveStrategySettings> activeSettings = liveSettingsRepo.findActiveByMarket(market).stream()
.filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null)
.toList();
if (activeSettings.isEmpty()) return;
long candleTimeEpoch = barEndEpoch - signalBar.getTimePeriod().getSeconds();
for (GcLiveStrategySettings s : activeSettings) {
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), ct)) continue;
String closeSignal = "NONE";
String signalExecType = null;
if ("CANDLE_CLOSE".equals(s.getExecutionType())) {
closeSignal = liveStrategyEvaluator.evaluateSettingOnCandleClose(
s, market, ct, maturedIndex);
signalExecType = "CANDLE_CLOSE";
} else if ("REALTIME_TICK".equals(s.getExecutionType())) {
closeSignal = liveStrategyEvaluator.evaluateSettingRealtimeAtClose(
s, market, ct, maturedIndex);
signalExecType = "REALTIME_TICK";
}
if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) continue;
publishStrategySignal(market, ct, signalBar, closeSignal, s, signalExecType);
try {
tradeSignalService.save(
s.getDeviceId(), s.getUserId(),
market, s.getStrategyId(), null,
closeSignal, signalBar.getClosePrice().doubleValue(),
candleTimeEpoch, ct, signalExecType);
if (s.getDeviceId() != null) {
orderExecutionQueue.submitSignal(
s.getDeviceId(), s.getUserId(), market,
s.getStrategyId(), closeSignal,
signalBar.getClosePrice().doubleValue());
}
} catch (Exception e) {
log.warn("[BarCloseEval] 시그널 처리 실패 ({}/{} strategyId={}): {}",
market, ct, s.getStrategyId(), e.getMessage());
}
}
}
/**
* REST 갭 백필 등으로 시리즈가 갱신된 뒤, 병합된 확정봉 구간을 순회하며 평가한다.
*
* @param maxMaturedBars 병합된 봉 수(상한) — 중간 마감봉 누락 방지
*/
public void evaluateRecentMaturedBarsAfterBackfill(String market, String candleType, int maxMaturedBars) {
if (maxMaturedBars <= 0 || !ta4jStorage.exists(market, candleType)) return;
String ct = LiveStrategyTimeframeService.normalize(candleType);
if (!shouldEvaluateOnClose(market, ct)) return;
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
if (series.isEmpty()) return;
int end = series.getEndIndex();
int begin = series.getBeginIndex();
int maturedEnd = Math.max(begin, end - 1);
int maturedStart = Math.max(begin, maturedEnd - maxMaturedBars + 1);
for (int i = maturedStart; i <= maturedEnd; i++) {
onMaturedBarClose(market, ct, i, series.getBar(i));
}
}
public void invalidateMarket(String market) {
evaluatedBarCloseKeys.keySet().removeIf(k -> k.startsWith(market + ":"));
}
/** 활성 실시간 전략 중 DSL에 해당 분봉이 포함된 항목이 있는지 */
public boolean shouldEvaluateOnClose(String market, String candleType) {
String ct = LiveStrategyTimeframeService.normalize(candleType);
return liveSettingsRepo.findActiveByMarket(market).stream()
.filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null)
.anyMatch(s -> conditionTimeframes.usesTimeframe(s.getStrategyId(), ct));
}
private void publishStrategySignal(String market, String candleType, Bar bar, String signal,
GcLiveStrategySettings setting, String executionType) {
long barStartEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
CandleBarDto dto = CandleBarDto.builder()
.time(barStartEpoch)
.open(bar.getOpenPrice().doubleValue())
.high(bar.getHighPrice().doubleValue())
.low(bar.getLowPrice().doubleValue())
.close(bar.getClosePrice().doubleValue())
.volume(bar.getVolume().doubleValue())
.signal(signal)
.candleType(candleType)
.strategyId(setting.getStrategyId())
.userId(setting.getUserId())
.deviceId(setting.getDeviceId())
.executionType(executionType)
.build();
broker.publish(market, candleType, dto);
log.info("[BarCloseEval] bar-close signal={} market={} candleType={} strategyId={} userId={}",
signal, market, candleType, setting.getStrategyId(), setting.getUserId());
}
private void trimEvaluatedKeys() {
if (evaluatedBarCloseKeys.size() > 50_000) {
evaluatedBarCloseKeys.clear();
log.info("[BarCloseEval] evaluatedBarCloseKeys cleared (size cap)");
}
}
}
@@ -67,17 +67,12 @@ public class StrategyConditionTimeframeService {
GcStrategy strategy = opt.get();
ensureDslRepaired(strategy);
Set<String> fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson());
if (!fromFlowLayout.isEmpty()) {
return sanitizeEvaluationTimeframes(fromFlowLayout, strategy);
}
Set<String> buyOut = new LinkedHashSet<>();
Set<String> sellOut = new LinkedHashSet<>();
boolean buyScoped = collectStartScopeFromJson(strategy.getBuyConditionJson(), buyOut);
boolean sellScoped = collectStartScopeFromJson(strategy.getSellConditionJson(), sellOut);
// 매수 START 분봉 우선 — 매도 쪽 레거시 1m TIMEFRAME 이 합집합에 섞이지 않도록
// 평가 분봉은 buy/sell DSL(TIMEFRAME·START)이 단일 원천 — flow_layout 은 편집기 UI 보조
if (buyScoped && !buyOut.isEmpty()) {
return sanitizeEvaluationTimeframes(buyOut, strategy);
}
@@ -96,6 +91,10 @@ public class StrategyConditionTimeframeService {
}
if (out.isEmpty()) {
Set<String> fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson());
if (!fromFlowLayout.isEmpty()) {
return sanitizeEvaluationTimeframes(fromFlowLayout, strategy);
}
String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategy.getName());
return Set.of(fromName != null ? fromName : "1m");
}
@@ -2,6 +2,7 @@ package com.goldenchart.trading.pipeline;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.service.BarCloseStrategyEvaluationService;
import com.goldenchart.storage.Ta4jStorage;
import com.goldenchart.websocket.UpbitWebSocketClient;
import lombok.RequiredArgsConstructor;
@@ -37,6 +38,7 @@ import java.util.concurrent.atomic.AtomicLong;
public class CandleGapBackfillService {
private final Ta4jStorage ta4jStorage;
private final BarCloseStrategyEvaluationService barCloseEvaluation;
private final UpbitWebSocketClient upbitWebSocketClient;
private final TradingPipelineProperties props;
private final WebClient.Builder webClientBuilder;
@@ -143,6 +145,7 @@ public class CandleGapBackfillService {
BarSeries series = ta4jStorage.getOrCreate(market, candleType);
log.debug("[GapBackfill] {} {} — REST {}봉 병합 (series={})",
market, candleType, merged, series.getBarCount());
barCloseEvaluation.evaluateRecentMaturedBarsAfterBackfill(market, candleType, merged);
}
return merged;
}
@@ -1,13 +1,7 @@
package com.goldenchart.websocket;
import com.goldenchart.dto.CandleBarDto;
import com.goldenchart.entity.GcLiveStrategySettings;
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
import com.goldenchart.service.LiveStrategyEvaluator;
import com.goldenchart.service.LiveStrategyTimeframeService;
import com.goldenchart.service.StrategyConditionTimeframeService;
import com.goldenchart.service.TradeSignalService;
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
import com.goldenchart.service.BarCloseStrategyEvaluationService;
import com.goldenchart.storage.Ta4jStorage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -23,7 +17,6 @@ import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/**
@@ -43,11 +36,7 @@ public class BarBuilder {
private final Ta4jStorage ta4jStorage;
private final TradingWebSocketBroker broker;
private final LiveStrategyEvaluator liveStrategyEvaluator;
private final TradeSignalService tradeSignalService;
private final OrderExecutionQueue orderExecutionQueue;
private final GcLiveStrategySettingsRepository liveSettingsRepo;
private final StrategyConditionTimeframeService conditionTimeframes;
private final BarCloseStrategyEvaluationService barCloseEvaluation;
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 10, 15, 30, 60, 240, 1440) */
private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 10080, 1440};
@@ -116,9 +105,9 @@ public class BarBuilder {
ta4jStorage.addBar(market, "1m", bar);
log.debug("[BarBuilder] 1m Bar 확정: {} @ {}", market, endTime);
if (shouldEvaluateOnClose(market, "1m")) {
if (barCloseEvaluation.shouldEvaluateOnClose(market, "1m")) {
BarSeries series1m = ta4jStorage.getOrCreate(market, "1m");
evaluateStrategyOnBarClose(market, "1m", series1m.getEndIndex(), bar);
barCloseEvaluation.onMaturedBarClose(market, "1m", series1m.getEndIndex(), bar);
}
// 상위 타임프레임 전파
@@ -147,98 +136,20 @@ public class BarBuilder {
Bar upperBar = buildTa4jBar(partial, Duration.ofMinutes(minutes), upperEnd);
ta4jStorage.addBar(market, type, upperBar);
BarSeries upperAfter = ta4jStorage.getOrCreate(market, type);
if (shouldEvaluateOnClose(market, type)) {
if (barCloseEvaluation.shouldEvaluateOnClose(market, type)) {
int maturedUpper = Math.max(upperAfter.getBeginIndex(), upperAfter.getEndIndex() - 1);
Bar maturedUpperBar = upperAfter.getBar(maturedUpper);
evaluateStrategyOnBarClose(market, type, maturedUpper, maturedUpperBar);
barCloseEvaluation.onMaturedBarClose(market, type, maturedUpper, maturedUpperBar);
}
} else {
log.warn("[BarBuilder] 상위봉 endTime 역행 {} {} last={} expected={} — 갱신만 수행",
market, type, lastUpperEndInstant, upperEndInstant);
ta4jStorage.updateLastBar(market, type, partial.close, partial.volume);
}
}
}
}
/** 활성 실시간 전략 설정 중 전략 DSL에 해당 분봉이 포함된 항목이 있는지 */
private boolean shouldEvaluateOnClose(String market, String candleType) {
String ct = LiveStrategyTimeframeService.normalize(candleType);
return liveSettingsRepo.findActiveByMarket(market).stream()
.filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null)
.anyMatch(s -> conditionTimeframes.usesTimeframe(s.getStrategyId(), ct));
}
/**
* 봉 마감 시점 전략 평가 + 시그널 STOMP 발행(평가 분봉 채널) + DB/주문 큐 적재.
*/
private void evaluateStrategyOnBarClose(String market, String candleType, int maturedIndex,
Bar signalBar) {
List<GcLiveStrategySettings> activeSettings = liveSettingsRepo.findActiveByMarket(market).stream()
.filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && s.getStrategyId() != null)
.toList();
if (activeSettings.isEmpty()) return;
long candleTimeEpoch = signalBar.getEndTime().getEpochSecond()
- signalBar.getTimePeriod().getSeconds();
for (GcLiveStrategySettings s : activeSettings) {
if (!conditionTimeframes.usesTimeframe(s.getStrategyId(), candleType)) continue;
String closeSignal = "NONE";
String signalExecType = null;
if ("CANDLE_CLOSE".equals(s.getExecutionType())) {
closeSignal = liveStrategyEvaluator.evaluateSettingOnCandleClose(
s, market, candleType, maturedIndex);
signalExecType = "CANDLE_CLOSE";
} else if ("REALTIME_TICK".equals(s.getExecutionType())) {
closeSignal = liveStrategyEvaluator.evaluateSettingRealtimeAtClose(
s, market, candleType, maturedIndex);
signalExecType = "REALTIME_TICK";
}
if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) continue;
publishStrategySignal(market, candleType, signalBar, closeSignal, s, signalExecType);
try {
tradeSignalService.save(
s.getDeviceId(), s.getUserId(),
market, s.getStrategyId(), null,
closeSignal, signalBar.getClosePrice().doubleValue(),
candleTimeEpoch, candleType, signalExecType);
if (s.getDeviceId() != null) {
orderExecutionQueue.submitSignal(
s.getDeviceId(), s.getUserId(), market,
s.getStrategyId(), closeSignal,
signalBar.getClosePrice().doubleValue());
}
} catch (Exception e) {
log.warn("[BarBuilder] 시그널 처리 실패 ({}/{} strategyId={}): {}",
market, candleType, s.getStrategyId(), e.getMessage());
}
}
}
/** 평가 분봉 STOMP 채널로 봉 마감 시그널 발행 (소유자·전략 메타 포함) */
private void publishStrategySignal(String market, String candleType, Bar bar, String signal,
GcLiveStrategySettings setting, String executionType) {
long barStartEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
CandleBarDto dto = CandleBarDto.builder()
.time(barStartEpoch)
.open(bar.getOpenPrice().doubleValue())
.high(bar.getHighPrice().doubleValue())
.low(bar.getLowPrice().doubleValue())
.close(bar.getClosePrice().doubleValue())
.volume(bar.getVolume().doubleValue())
.signal(signal)
.candleType(candleType)
.strategyId(setting.getStrategyId())
.userId(setting.getUserId())
.deviceId(setting.getDeviceId())
.executionType(executionType)
.build();
broker.publish(market, candleType, dto);
log.info("[BarBuilder] bar-close signal={} market={} candleType={} strategyId={} userId={}",
signal, market, candleType, setting.getStrategyId(), setting.getUserId());
}
/** 현재 진행 중인 1분봉을 STOMP 로 발행 (실시간 틱 렌더링용, 시그널 없음). */
private void publishCurrentBar(String market) {
PartialBar p = partialBars.get(market);
@@ -0,0 +1,97 @@
package com.goldenchart.service;
import com.goldenchart.entity.GcLiveStrategySettings;
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
import com.goldenchart.storage.Ta4jStorage;
import com.goldenchart.trading.pipeline.OrderExecutionQueue;
import com.goldenchart.websocket.TradingWebSocketBroker;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.ta4j.core.Bar;
import org.ta4j.core.BarSeries;
import org.ta4j.core.BaseBarSeriesBuilder;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.num.DoubleNumFactory;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class BarCloseStrategyEvaluationServiceTest {
@Mock private LiveStrategyEvaluator liveStrategyEvaluator;
@Mock private TradeSignalService tradeSignalService;
@Mock private OrderExecutionQueue orderExecutionQueue;
@Mock private GcLiveStrategySettingsRepository liveSettingsRepo;
@Mock private StrategyConditionTimeframeService conditionTimeframes;
@Mock private TradingWebSocketBroker broker;
@Mock private Ta4jStorage ta4jStorage;
private BarCloseStrategyEvaluationService service;
@BeforeEach
void setUp() {
service = new BarCloseStrategyEvaluationService(
liveStrategyEvaluator, tradeSignalService, orderExecutionQueue,
liveSettingsRepo, conditionTimeframes, broker, ta4jStorage);
}
@Test
void onMaturedBarClose_evaluatesOnlyDslTimeframes_andDedupesSameBarEnd() {
GcLiveStrategySettings setting = new GcLiveStrategySettings();
setting.setStrategyId(10L);
setting.setIsLiveCheck(true);
setting.setExecutionType("CANDLE_CLOSE");
setting.setMarket("KRW-BTC");
when(liveSettingsRepo.findActiveByMarket("KRW-BTC")).thenReturn(List.of(setting));
when(conditionTimeframes.usesTimeframe(10L, "5m")).thenReturn(true);
when(conditionTimeframes.usesTimeframe(10L, "15m")).thenReturn(false);
when(liveStrategyEvaluator.evaluateSettingOnCandleClose(eq(setting), eq("KRW-BTC"), eq("5m"), anyInt()))
.thenReturn("NONE");
Bar bar = sampleBar(Duration.ofMinutes(5), Instant.parse("2026-05-29T10:05:00Z"));
service.onMaturedBarClose("KRW-BTC", "5m", 0, bar);
service.onMaturedBarClose("KRW-BTC", "5m", 0, bar);
verify(liveStrategyEvaluator, times(1))
.evaluateSettingOnCandleClose(setting, "KRW-BTC", "5m", 0);
verify(broker, never()).publish(anyString(), anyString(), any());
}
@Test
void shouldEvaluateOnClose_falseWhenNoMatchingTimeframe() {
when(liveSettingsRepo.findActiveByMarket("KRW-ETH")).thenReturn(List.of());
assertFalse(service.shouldEvaluateOnClose("KRW-ETH", "3m"));
}
@Test
void shouldEvaluateOnClose_trueWhenStrategyUsesTimeframe() {
GcLiveStrategySettings s = new GcLiveStrategySettings();
s.setStrategyId(1L);
s.setIsLiveCheck(true);
when(liveSettingsRepo.findActiveByMarket("KRW-BTC")).thenReturn(List.of(s));
when(conditionTimeframes.usesTimeframe(1L, "10m")).thenReturn(true);
assertTrue(service.shouldEvaluateOnClose("KRW-BTC", "10m"));
}
private static Bar sampleBar(Duration period, Instant end) {
return new BaseBarSeriesBuilder()
.withBarBuilderFactory(new TimeBarBuilderFactory())
.withNumFactory(DoubleNumFactory.getInstance())
.build()
.barBuilder()
.timePeriod(period)
.endTime(end)
.openPrice(1).highPrice(2).lowPrice(0.5).closePrice(1.5).volume(10)
.build();
}
}
+44 -36
View File
@@ -24,6 +24,11 @@
--accent-h: #5a7dc7;
--up: #ff6b6b;
--down: #4dabf7;
/* 매매 시그널 — 매수(빨강) · 매도(파랑). 시세 등락(--up/--down)과 동일 톤 */
--gc-trade-buy: #ef5350;
--gc-trade-sell: #4dabf7;
--gc-trade-buy-hover: #e53935;
--gc-trade-sell-hover: #339af0;
--panel-bg: #24283bee;
--sep: rgba(122, 162, 247, 0.15);
--panel: #24283b;
@@ -6648,8 +6653,8 @@ html.theme-blue {
border-radius: 50%;
flex-shrink: 0;
}
.strat-cond-dot--buy { background: var(--up); }
.strat-cond-dot--sell { background: var(--down); }
.strat-cond-dot--buy { background: var(--gc-trade-buy); }
.strat-cond-dot--sell { background: var(--gc-trade-sell); }
.strat-cond-empty {
font-size: 12px;
color: var(--text3);
@@ -7024,8 +7029,8 @@ html.theme-blue {
display: inline-block; font-size: 12px; padding: 3px 10px;
border-radius: 99px; margin-bottom: 8px;
}
.sp-prev-badge--buy { background: rgba(72,199,116,.2); color: #48c774; }
.sp-prev-badge--sell { background: rgba(255,107,107,.2); color: #ff6b6b; }
.sp-prev-badge--buy { background: color-mix(in srgb, var(--gc-trade-buy) 22%, transparent); color: var(--gc-trade-buy); }
.sp-prev-badge--sell { background: color-mix(in srgb, var(--gc-trade-sell) 22%, transparent); color: var(--gc-trade-sell); }
.sp-prev-text {
background: var(--bg3); border: 1px solid var(--border);
padding: 12px; font-size: 13px; line-height: 1.6;
@@ -7036,8 +7041,8 @@ html.theme-blue {
border-bottom: 2px solid var(--border); padding-bottom: 4px; margin-bottom: 10px;
}
.sp-prev-formula { font-size: 13px; font-family: monospace; line-height: 1.6; margin-bottom: 6px; }
.sp-prev-buy { color: #48c774; font-weight: 700; }
.sp-prev-sell { color: #ff6b6b; font-weight: 700; }
.sp-prev-buy { color: var(--gc-trade-buy); font-weight: 700; }
.sp-prev-sell { color: var(--gc-trade-sell); font-weight: 700; }
.sp-validation { margin-bottom: 8px; }
.sp-val-head { font-size: 12px; font-weight: 600; margin-bottom: 4px; }
.sp-val-ok { color: #48c774; font-size: 13px; padding: 5px 10px; background: rgba(72,199,116,.1); border-radius: var(--radius); }
@@ -7096,11 +7101,11 @@ html.theme-blue {
color: var(--text2); cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 4px;
}
.sp-signal-tab--buy { color: #ff6b6b; border-bottom-color: #ff6b6b; font-weight: 600; background: rgba(255,107,107,.06); }
.sp-signal-tab--sell { color: #4dabf7; border-bottom-color: #4dabf7; font-weight: 600; background: rgba(77,171,247,.06); }
.sp-signal-tab--buy { color: var(--gc-trade-buy); border-bottom-color: var(--gc-trade-buy); font-weight: 600; background: color-mix(in srgb, var(--gc-trade-buy) 8%, transparent); }
.sp-signal-tab--sell { color: var(--gc-trade-sell); border-bottom-color: var(--gc-trade-sell); font-weight: 600; background: color-mix(in srgb, var(--gc-trade-sell) 8%, transparent); }
.sp-signal-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.sp-signal-dot--buy { background: #48c774; }
.sp-signal-dot--sell { background: #ff6b6b; }
.sp-signal-dot--buy { background: var(--gc-trade-buy); }
.sp-signal-dot--sell { background: var(--gc-trade-sell); }
/* 드롭존 */
.sp-dropzone {
@@ -7383,8 +7388,8 @@ html.theme-blue {
.sp-tmpl-badge {
font-size: 11px; padding: 3px 8px; border-radius: 99px; white-space: nowrap; font-weight: 600;
}
.sp-tmpl-badge--buy { background: rgba(72,199,116,.2); color: #48c774; }
.sp-tmpl-badge--sell { background: rgba(255,107,107,.2); color: #ff6b6b; }
.sp-tmpl-badge--buy { background: color-mix(in srgb, var(--gc-trade-buy) 22%, transparent); color: var(--gc-trade-buy); }
.sp-tmpl-badge--sell { background: color-mix(in srgb, var(--gc-trade-sell) 22%, transparent); color: var(--gc-trade-sell); }
.sp-tmpl-name { flex: 1 1 auto; color: var(--text); }
.sp-tmpl-desc {
flex: 1 1 100%;
@@ -7444,8 +7449,8 @@ html.theme-blue {
font-size: 11px; padding: 2px 8px; border-radius: 99px;
border: 1px solid var(--border); color: var(--text2);
}
.sp-modal-chip--buy { border-color: #48c774; color: #48c774; }
.sp-modal-chip--sell { border-color: #ff6b6b; color: #ff6b6b; }
.sp-modal-chip--buy { border-color: var(--gc-trade-buy); color: var(--gc-trade-buy); }
.sp-modal-chip--sell { border-color: var(--gc-trade-sell); color: var(--gc-trade-sell); }
.sp-modal-lbl { display: block; font-size: 12px; color: var(--text2); margin: 10px 0 4px; }
.sp-modal-inp {
width: 100%; padding: 8px 10px; font-size: 13px;
@@ -8717,6 +8722,8 @@ html.theme-blue {
.brd-sig-badge.brd-pos { background: rgba(38,201,126,.12); }
.brd-sig-badge.brd-neg { background: rgba(240,78,94,.12); }
.brd-sig-badge.brd-warn{ background: rgba(255,159,67,.12); }
.brd-sig-badge.brd-sig-buy { background: color-mix(in srgb, var(--gc-trade-buy) 22%, transparent); color: var(--gc-trade-buy); }
.brd-sig-badge.brd-sig-sell { background: color-mix(in srgb, var(--gc-trade-sell) 22%, transparent); color: var(--gc-trade-sell); }
.brd-expand-btn {
display: block; width: 100%;
@@ -10024,12 +10031,12 @@ html.theme-blue {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
}
.rsp-trade-card--buy {
border-color: rgba(255, 107, 107, 0.35);
background: linear-gradient(180deg, rgba(255, 107, 107, 0.08) 0%, var(--bg3) 48px);
border-color: color-mix(in srgb, var(--gc-trade-buy) 40%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--gc-trade-buy) 10%, transparent) 0%, var(--bg3) 48px);
}
.rsp-trade-card--sell {
border-color: rgba(77, 171, 247, 0.35);
background: linear-gradient(180deg, rgba(77, 171, 247, 0.08) 0%, var(--bg3) 48px);
border-color: color-mix(in srgb, var(--gc-trade-sell) 40%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--gc-trade-sell) 10%, transparent) 0%, var(--bg3) 48px);
}
.rsp-trade-card-title {
padding: 7px 12px 5px;
@@ -10038,10 +10045,10 @@ html.theme-blue {
border-bottom: 1px solid var(--border);
}
.rsp-trade-card--buy .rsp-trade-card-title {
color: var(--up);
color: var(--gc-trade-buy);
}
.rsp-trade-card--sell .rsp-trade-card-title {
color: var(--down);
color: var(--gc-trade-sell);
}
.rsp-trade-card-body .top-panel {
flex: 1 1 auto;
@@ -10547,16 +10554,17 @@ html.theme-blue {
cursor: pointer;
}
.top-submit--buy {
background: #ef5350;
background: var(--gc-trade-buy);
color: #fff;
}
.top-submit--buy:hover {
background: #e53935;
background: var(--gc-trade-buy-hover);
}
.top-submit--sell {
background: #2962ff;
background: var(--gc-trade-sell);
}
.top-submit--sell:hover {
background: #1e53e5;
background: var(--gc-trade-sell-hover);
}
.app.light .top-kind-btn--active {
border-color: #1976d2;
@@ -10658,10 +10666,10 @@ html.theme-blue {
background: var(--bg3);
}
.chart-ctx-menu-item--buy {
color: #ef5350;
color: var(--gc-trade-buy);
}
.chart-ctx-menu-item--sell {
color: #2962ff;
color: var(--gc-trade-sell);
}
.app.light .chart-ctx-menu {
background: #fff;
@@ -11489,8 +11497,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
.tsn-card--latest {
box-shadow: 0 14px 44px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(77, 171, 247, 0.12);
}
.tsn-card--buy { --tsn-accent: #3f7ef5; }
.tsn-card--sell { --tsn-accent: #ef5350; }
.tsn-card--buy { --tsn-accent: var(--gc-trade-buy); }
.tsn-card--sell { --tsn-accent: var(--gc-trade-sell); }
.tsn-card-header {
cursor: grab;
@@ -11512,8 +11520,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
border-radius: 3px;
letter-spacing: 0.04em;
}
.tsn-badge--buy { background: rgba(63,126,245,0.2); color: #7aa2f7; }
.tsn-badge--sell { background: rgba(239,83,80,0.2); color: #f7768e; }
.tsn-badge--buy { background: color-mix(in srgb, var(--gc-trade-buy) 25%, transparent); color: var(--gc-trade-buy); }
.tsn-badge--sell { background: color-mix(in srgb, var(--gc-trade-sell) 25%, transparent); color: var(--gc-trade-sell); }
.tsn-card-title {
flex: 1;
font-size: 13px;
@@ -11551,8 +11559,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
font-size: 15px;
font-weight: 700;
}
.tsd-direction--buy { color: var(--up, #ff6b6b); }
.tsd-direction--sell { color: var(--down, #4dabf7); }
.tsd-direction--buy { color: var(--gc-trade-buy); }
.tsd-direction--sell { color: var(--gc-trade-sell); }
.tsd-headline-price {
font-size: 18px;
font-weight: 700;
@@ -11935,8 +11943,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
border-radius: 4px;
flex-shrink: 0;
}
.tnl-signal--buy { background: rgba(63,126,245,0.2); color: #7aa2f7; }
.tnl-signal--sell { background: rgba(239,83,80,0.2); color: #f7768e; }
.tnl-signal--buy { background: color-mix(in srgb, var(--gc-trade-buy) 25%, transparent); color: var(--gc-trade-buy); }
.tnl-signal--sell { background: color-mix(in srgb, var(--gc-trade-sell) 25%, transparent); color: var(--gc-trade-sell); }
.tnl-row-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.tnl-row-title { font-size: 14px; font-weight: 600; }
.tnl-row-price {
@@ -12406,8 +12414,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
.dashboard-kv--cols li { border: none; flex-direction: column; gap: 4px; }
.dashboard-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.dashboard-table th, .dashboard-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border); }
.dashboard-table .buy { color: #26a69a; }
.dashboard-table .sell { color: #ef5350; }
.dashboard-table .buy { color: var(--gc-trade-buy); }
.dashboard-table .sell { color: var(--gc-trade-sell); }
.dashboard-link { background: none; border: none; color: var(--accent); cursor: pointer; padding: 0; }
.dashboard-muted { color: var(--text2); font-size: 13px; }
.dashboard-error { color: #ef5350; font-size: 13px; }
@@ -168,7 +168,7 @@ const SIG_LABEL: Record<string, string> = {
BUY:'매수', SELL:'매도', SHORT_ENTRY:'공매도', SHORT_EXIT:'공매도청산', PARTIAL_SELL:'분할매도',
};
const SIG_COLOR: Record<string, string> = {
BUY:'brd-pos', SELL:'brd-neg', SHORT_ENTRY:'brd-neg', SHORT_EXIT:'brd-pos', PARTIAL_SELL:'brd-warn',
BUY:'brd-sig-buy', SELL:'brd-sig-sell', SHORT_ENTRY:'brd-neg', SHORT_EXIT:'brd-pos', PARTIAL_SELL:'brd-warn',
};
// ══════════════════════════════════════════════════════════════════════════
+3 -2
View File
@@ -3,6 +3,7 @@ import React, { useState } from 'react';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
import { placePaperOrder, placeLiveOrder, loadLiveSummary } from '../utils/backendApi';
import { formatSignalTime } from '../utils/tradeSignalDisplay';
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from '../utils/tradeSignalColors';
import { useTradeAlertTimeFormat } from '../utils/tradeAlertTimeFormat';
export interface TradeSignalInfo {
@@ -61,8 +62,8 @@ export const TradeAlertModal: React.FC<Props> = ({
}) => {
useTradeAlertTimeFormat();
const isBuy = signal.signalType === 'BUY';
const accentColor = isBuy ? '#3f7ef5' : '#ef5350';
const accentGlow = isBuy ? 'rgba(63,126,245,0.22)' : 'rgba(239,83,80,0.22)';
const accentColor = isBuy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR;
const accentGlow = isBuy ? 'rgba(239, 83, 80, 0.28)' : 'rgba(77, 171, 247, 0.28)';
const signalLabel = isBuy ? 'BUY' : 'SELL';
const signalKo = isBuy ? '매수 알림' : '매도 알림';
@@ -30,7 +30,7 @@ import {
} from './virtual/VirtualGridUnifiedHeader';
import type { VirtualCardDisplayMode } from './virtual/VirtualTargetCard';
import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots';
import { useVirtualAutoTrade } from '../hooks/useVirtualAutoTrade';
import { useVirtualBackendTradeSignals } from '../hooks/useVirtualBackendTradeSignals';
import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
import {
@@ -197,11 +197,10 @@ const VirtualTradingPage: React.FC<Props> = ({
session.running,
);
useVirtualAutoTrade({
useVirtualBackendTradeSignals({
enabled: paperTradingEnabled && paperAutoTradeEnabled,
session,
targets,
snapshots,
tickers,
paperAutoTradeBudgetPct,
positions: summary?.positions,
-117
View File
@@ -1,117 +0,0 @@
/**
* 가상투자 Match(전략 조건 100% 충족) 시 자동 모의 체결
* 설정 · 가상투자 · 자동매매 ON + 세션 running 일 때만 동작
*/
import { useEffect, useRef } from 'react';
import type { TickerData } from './useMarketTicker';
import type { VirtualIndicatorSnapshot } from './useVirtualIndicatorSnapshots';
import type { VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage';
import { placePaperOrder, type PaperSummaryDto } from '../utils/backendApi';
import {
buildConditionMetrics,
resolveVirtualTradeTiming,
type VirtualTradeTiming,
} from '../utils/virtualSignalMetrics';
import { coerceFiniteNumber } from '../utils/safeFormat';
const EXEC_COOLDOWN_MS = 15_000;
interface Options {
enabled: boolean;
session: VirtualSessionConfig;
targets: VirtualTargetItem[];
snapshots: Record<string, VirtualIndicatorSnapshot | undefined>;
tickers?: Map<string, TickerData>;
paperAutoTradeBudgetPct: number;
positions?: PaperSummaryDto['positions'];
onFilled?: () => void;
}
export function useVirtualAutoTrade({
enabled,
session,
targets,
snapshots,
tickers,
paperAutoTradeBudgetPct,
positions = [],
onFilled,
}: Options): void {
const prevTimingRef = useRef<Record<string, VirtualTradeTiming>>({});
const inflightRef = useRef<Set<string>>(new Set());
const lastExecRef = useRef<Record<string, number>>({});
useEffect(() => {
if (!enabled || !session.running) {
prevTimingRef.current = {};
return;
}
for (const target of targets) {
const snap = snapshots[target.market];
if (!snap?.rows?.length) continue;
const metrics = buildConditionMetrics(snap.rows);
const timing = resolveVirtualTradeTiming(metrics);
const prev = prevTimingRef.current[target.market] ?? 'neutral';
prevTimingRef.current[target.market] = timing;
if (timing !== 'buy' && timing !== 'sell') continue;
if (timing === prev) continue;
const price = coerceFiniteNumber(tickers?.get(target.market)?.tradePrice);
if (price == null || price <= 0) continue;
const heldQty = coerceFiniteNumber(
positions.find(p => p.symbol === target.market)?.quantity,
) ?? 0;
if (session.positionMode === 'LONG_ONLY') {
if (timing === 'buy' && heldQty > 0) continue;
if (timing === 'sell' && heldQty <= 0) continue;
}
const side = timing === 'buy' ? 'BUY' : 'SELL';
const execKey = `${target.market}:${side}`;
if (inflightRef.current.has(execKey)) continue;
if (Date.now() - (lastExecRef.current[execKey] ?? 0) < EXEC_COOLDOWN_MS) continue;
const strategyId = target.strategyId ?? session.globalStrategyId ?? null;
inflightRef.current.add(execKey);
void placePaperOrder({
market: target.market,
side,
orderKind: 'market',
price,
quantity: 0,
budgetPct: timing === 'buy' ? paperAutoTradeBudgetPct : 100,
source: 'STRATEGY',
strategyId,
})
.then(trade => {
if (trade) {
lastExecRef.current[execKey] = Date.now();
onFilled?.();
}
})
.catch(err => {
console.warn('[VirtualAutoTrade]', target.market, side, err);
})
.finally(() => {
inflightRef.current.delete(execKey);
});
}
}, [
enabled,
session.running,
session.globalStrategyId,
session.positionMode,
targets,
snapshots,
tickers,
paperAutoTradeBudgetPct,
positions,
onFilled,
]);
}
@@ -0,0 +1,152 @@
/**
* 가상투자 자동매매 — 백엔드 BarBuilder/LiveStrategyEvaluator 가 발행한 STOMP BUY/SELL 만 체결.
* 프론트 근사 일치율(resolveVirtualTradeTiming) 사용 안 함.
*/
import { useEffect, useRef } from 'react';
import type { TickerData } from './useMarketTicker';
import type { VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage';
import { loadStrategyTimeframes, placePaperOrder, type PaperSummaryDto } from '../utils/backendApi';
import { parseStompJson } from '../utils/stompMessage';
import { subscribeStompTopic } from '../utils/stompChartBroker';
import { signalBelongsToCurrentSession } from '../utils/tradeSignalOwnership';
import { resolveVirtualTargetStrategyId } from '../utils/virtualTargetStrategy';
import { coerceFiniteNumber } from '../utils/safeFormat';
const EXEC_COOLDOWN_MS = 15_000;
interface Options {
enabled: boolean;
session: VirtualSessionConfig;
targets: VirtualTargetItem[];
tickers?: Map<string, TickerData>;
paperAutoTradeBudgetPct: number;
positions?: PaperSummaryDto['positions'];
onFilled?: () => void;
}
export function useVirtualBackendTradeSignals({
enabled,
session,
targets,
tickers,
paperAutoTradeBudgetPct,
positions = [],
onFilled,
}: Options): void {
const inflightRef = useRef<Set<string>>(new Set());
const lastExecRef = useRef<Record<string, number>>({});
const seenSignalRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!enabled || !session.running) {
seenSignalRef.current.clear();
return;
}
const activeTargets = targets.filter(
t => resolveVirtualTargetStrategyId(t, session.globalStrategyId) != null,
);
if (activeTargets.length === 0) return;
const unsubs: Array<() => void> = [];
let cancelled = false;
void (async () => {
for (const target of activeTargets) {
if (cancelled) break;
const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId)!;
let timeframes: string[] = [];
try {
timeframes = await loadStrategyTimeframes(strategyId);
} catch {
timeframes = ['1m'];
}
if (timeframes.length === 0) timeframes = ['1m'];
for (const candleType of timeframes) {
const topic = `/sub/charts/${target.market}/${candleType}`;
const unsub = subscribeStompTopic(topic, msg => {
const data = parseStompJson<{
time: number;
close: number;
signal?: string;
candleType?: string;
strategyId?: number | null;
userId?: number | null;
deviceId?: string | null;
}>(msg);
if (!data?.signal || (data.signal !== 'BUY' && data.signal !== 'SELL')) return;
if (data.strategyId != null && data.strategyId !== strategyId) return;
if (!signalBelongsToCurrentSession({ userId: data.userId, deviceId: data.deviceId })) {
return;
}
const dedup = `${target.market}:${data.candleType ?? candleType}:${data.time}:${data.signal}:${strategyId}`;
if (seenSignalRef.current.has(dedup)) return;
seenSignalRef.current.add(dedup);
if (seenSignalRef.current.size > 5000) {
seenSignalRef.current.clear();
}
const side = data.signal as 'BUY' | 'SELL';
const price = coerceFiniteNumber(tickers?.get(target.market)?.tradePrice)
?? coerceFiniteNumber(data.close);
if (price == null || price <= 0) return;
const heldQty = coerceFiniteNumber(
positions.find(p => p.symbol === target.market)?.quantity,
) ?? 0;
if (session.positionMode === 'LONG_ONLY') {
if (side === 'BUY' && heldQty > 0) return;
if (side === 'SELL' && heldQty <= 0) return;
}
const execKey = `${target.market}:${side}`;
if (inflightRef.current.has(execKey)) return;
if (Date.now() - (lastExecRef.current[execKey] ?? 0) < EXEC_COOLDOWN_MS) return;
inflightRef.current.add(execKey);
void placePaperOrder({
market: target.market,
side,
orderKind: 'market',
price,
quantity: 0,
budgetPct: side === 'BUY' ? paperAutoTradeBudgetPct : 100,
source: 'STRATEGY',
strategyId,
})
.then(trade => {
if (trade) {
lastExecRef.current[execKey] = Date.now();
onFilled?.();
}
})
.catch(err => {
console.warn('[VirtualBackendTrade]', target.market, side, err);
})
.finally(() => {
inflightRef.current.delete(execKey);
});
});
unsubs.push(unsub);
}
}
})();
return () => {
cancelled = true;
unsubs.forEach(u => u());
};
}, [
enabled,
session.running,
session.globalStrategyId,
session.positionMode,
targets,
tickers,
paperAutoTradeBudgetPct,
positions,
onFilled,
]);
}
+2 -3
View File
@@ -14,7 +14,7 @@ import {
type StrategyDto,
} from '../utils/backendApi';
import { useVirtualIndicatorSnapshots } from './useVirtualIndicatorSnapshots';
import { useVirtualAutoTrade } from './useVirtualAutoTrade';
import { useVirtualBackendTradeSignals } from './useVirtualBackendTradeSignals';
import { useVirtualTargetLiveStatus } from './useVirtualTargetLiveStatus';
import {
loadVirtualSession,
@@ -206,11 +206,10 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
session.running,
);
useVirtualAutoTrade({
useVirtualBackendTradeSignals({
enabled: paperTradingEnabled && paperAutoTradeEnabled,
session,
targets,
snapshots,
tickers,
paperAutoTradeBudgetPct,
positions: summary?.positions,
+2 -2
View File
@@ -439,8 +439,8 @@
white-space: nowrap;
}
.btd-table td.buy { color: var(--down, #3b82f6); font-weight: 700; }
.btd-table td.sell { color: var(--up, #ef4444); font-weight: 700; }
.btd-table td.buy { color: var(--gc-trade-buy); font-weight: 700; }
.btd-table td.sell { color: var(--gc-trade-sell); font-weight: 700; }
.btd-table td.up { color: #22c55e; font-weight: 700; }
.btd-table td.down { color: #ef4444; font-weight: 700; }
+2 -2
View File
@@ -504,8 +504,8 @@
font-size: 0.68rem;
}
.gc-signal-item--buy .gc-signal-arrow { color: #34d399; }
.gc-signal-item--sell .gc-signal-arrow { color: #f87171; }
.gc-signal-item--buy .gc-signal-arrow { color: var(--gc-trade-buy); }
.gc-signal-item--sell .gc-signal-arrow { color: var(--gc-trade-sell); }
.gc-signal-main {
display: flex;
+13 -12
View File
@@ -821,34 +821,35 @@
/* 매수·매도 제출 버튼: 실시간 차트 우측 패널(.top-submit--buy/sell)과 동일 */
.ptd-order-card .top-submit--buy {
background: #ef5350;
border-color: #ef5350;
background: var(--gc-trade-buy);
border-color: var(--gc-trade-buy);
color: #fff;
}
.ptd-order-card .top-submit--buy:hover {
background: #e53935;
background: var(--gc-trade-buy-hover);
}
.ptd-order-card .top-submit--sell {
background: #2962ff;
border-color: #2962ff;
background: var(--gc-trade-sell);
border-color: var(--gc-trade-sell);
}
.ptd-order-card .top-submit--sell:hover {
background: #1e53e5;
background: var(--gc-trade-sell-hover);
}
/* 매수·매도 카드 헤더: 실시간 차트 .rsp-trade-card--buy/sell 과 동일 */
.ptd-split-panel--right .ptd-split-card--top {
border-color: rgba(255, 107, 107, 0.35);
background: linear-gradient(180deg, rgba(255, 107, 107, 0.08) 0%, var(--bg3) 48px);
border-color: color-mix(in srgb, var(--gc-trade-buy) 40%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--gc-trade-buy) 10%, transparent) 0%, var(--bg3) 48px);
}
.ptd-split-panel--right .ptd-split-card--bottom {
border-color: rgba(77, 171, 247, 0.35);
background: linear-gradient(180deg, rgba(77, 171, 247, 0.08) 0%, var(--bg3) 48px);
border-color: color-mix(in srgb, var(--gc-trade-sell) 40%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--gc-trade-sell) 10%, transparent) 0%, var(--bg3) 48px);
}
.ptd-split-panel--right .ptd-split-card--top .ptd-split-card-head {
color: var(--up);
color: var(--gc-trade-buy);
}
.ptd-split-panel--right .ptd-split-card--bottom .ptd-split-card-head {
color: var(--down);
color: var(--gc-trade-sell);
}
.ptd-ob {
+6 -6
View File
@@ -15,8 +15,8 @@
--se-gold: #e6c200;
--se-success: #9ece6a;
--se-danger: #f7768e;
--se-buy: #7aa2f7;
--se-sell: #f7768e;
--se-buy: var(--gc-trade-buy, #ef5350);
--se-sell: var(--gc-trade-sell, #4dabf7);
--se-header-bg: linear-gradient(180deg, var(--bg2), var(--bg));
--se-header-border: var(--border);
--se-title-gradient: linear-gradient(90deg, #e6c200, #ffe566);
@@ -156,8 +156,8 @@
--se-gold: #f57f17;
--se-success: #2e7d32;
--se-danger: #c62828;
--se-buy: #1976d2;
--se-sell: #c62828;
--se-buy: var(--gc-trade-buy, #ef5350);
--se-sell: var(--gc-trade-sell, #4dabf7);
--se-title-gradient: linear-gradient(90deg, #1565c0, #1976d2);
--se-panel-card-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
--se-item-selected-bg: linear-gradient(135deg, rgba(25, 118, 210, 0.12), rgba(25, 118, 210, 0.04));
@@ -228,8 +228,8 @@
.se-page--blue {
--se-gold: #ffd54f;
--se-success: #69f0ae;
--se-buy: #5eb5ff;
--se-sell: #ff5555;
--se-buy: var(--gc-trade-buy, #ef5350);
--se-sell: var(--gc-trade-sell, #4dabf7);
--se-title-gradient: linear-gradient(90deg, #ffd54f, #ffe082);
--se-item-selected-bg: linear-gradient(135deg, rgba(255, 213, 79, 0.18), rgba(94, 181, 255, 0.08));
--se-strat-item-bg: color-mix(in srgb, var(--bg2) 92%, transparent);
+10 -10
View File
@@ -21,11 +21,11 @@
}
.tnl-row.tnl-row--receiving .tnl-summary-card--buy {
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border)));
border-color: color-mix(in srgb, var(--gc-trade-buy) 35%, var(--se-border, var(--border)));
}
.tnl-row.tnl-row--receiving .tnl-summary-card--sell {
border-color: color-mix(in srgb, #ef5350 35%, var(--se-border, var(--border)));
border-color: color-mix(in srgb, var(--gc-trade-sell) 35%, var(--se-border, var(--border)));
}
.tnl-row.tnl-row--receiving.tnl-row--selected .tnl-summary-card--selected {
@@ -425,11 +425,11 @@
}
.tnl-summary-card--buy {
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border)));
border-color: color-mix(in srgb, var(--gc-trade-buy) 35%, var(--se-border, var(--border)));
}
.tnl-summary-card--sell {
border-color: color-mix(in srgb, #ef5350 35%, var(--se-border, var(--border)));
border-color: color-mix(in srgb, var(--gc-trade-sell) 35%, var(--se-border, var(--border)));
}
.tnl-summary-panel {
@@ -530,11 +530,11 @@
}
.tnl-summary-arrow--buy {
color: var(--accent, #3f7ef5);
color: var(--gc-trade-buy);
}
.tnl-summary-arrow--sell {
color: #ef5350;
color: var(--gc-trade-sell);
}
.tnl-summary-pair {
@@ -551,11 +551,11 @@
}
.tnl-summary-type--buy {
color: var(--accent, #3f7ef5);
color: var(--gc-trade-buy);
}
.tnl-summary-type--sell {
color: #ef5350;
color: var(--gc-trade-sell);
}
.tnl-summary-quote-right {
@@ -574,11 +574,11 @@
}
.tnl-summary-price--buy {
color: var(--accent, #3f7ef5);
color: var(--gc-trade-buy);
}
.tnl-summary-price--sell {
color: #ef5350;
color: var(--gc-trade-sell);
}
/* 3행+: 상세 정보 */
+14 -10
View File
@@ -576,11 +576,11 @@
}
.vtd-trade-item--buy {
border-color: color-mix(in srgb, var(--up, #ef5350) 22%, var(--se-border));
border-color: color-mix(in srgb, var(--gc-trade-buy) 22%, var(--se-border));
}
.vtd-trade-item--sell {
border-color: color-mix(in srgb, var(--down, #26a69a) 22%, var(--se-border));
border-color: color-mix(in srgb, var(--gc-trade-sell) 22%, var(--se-border));
}
.vtd-trade-side-badge {
@@ -596,15 +596,15 @@
}
.vtd-trade-side-badge--buy {
color: var(--up, #ef5350);
background: color-mix(in srgb, var(--up, #ef5350) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--up, #ef5350) 28%, transparent);
color: var(--gc-trade-buy);
background: color-mix(in srgb, var(--gc-trade-buy) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--gc-trade-buy) 28%, transparent);
}
.vtd-trade-side-badge--sell {
color: var(--down, #26a69a);
background: color-mix(in srgb, var(--down, #26a69a) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--down, #26a69a) 28%, transparent);
color: var(--gc-trade-sell);
background: color-mix(in srgb, var(--gc-trade-sell) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--gc-trade-sell) 28%, transparent);
}
.vtd-trade-quote {
@@ -1322,9 +1322,13 @@
}
/* 매수·매도 타이밍 — 카드 테두리 강조 비활성 (신호등·패널에서만 표시) */
.vtd-card--timing-buy,
.vtd-card--timing-buy {
border-color: color-mix(in srgb, var(--gc-trade-buy) 40%, var(--se-border));
box-shadow: none;
}
.vtd-card--timing-sell {
border-color: color-mix(in srgb, var(--se-gold) 35%, var(--se-border));
border-color: color-mix(in srgb, var(--gc-trade-sell) 40%, var(--se-border));
box-shadow: none;
}
+3 -2
View File
@@ -25,6 +25,7 @@ import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive';
import { resolveBbBandBackground } from './bollingerConfig';
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types';
import { formatChartAxisPrice, formatIndicatorAxisPrice } from './dataGenerator';
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from './tradeSignalColors';
import {
DEFAULT_CHART_TIME_FORMAT,
formatUnixWithChartPattern,
@@ -1181,7 +1182,7 @@ export class ChartManager {
time: s.time,
position: buy ? ('belowBar' as const) : ('aboveBar' as const),
shape: buy ? ('arrowUp' as const) : ('arrowDown' as const),
color: s.type === 'PARTIAL_SELL' ? '#FF9800' : (buy ? '#26A69A' : '#EF5350'),
color: s.type === 'PARTIAL_SELL' ? '#FF9800' : (buy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR),
text,
};
});
@@ -1213,7 +1214,7 @@ export class ChartManager {
time: m.time,
position: buy ? ('belowBar' as const) : ('aboveBar' as const),
shape: buy ? ('arrowUp' as const) : ('arrowDown' as const),
color: buy ? '#00BCD4' : '#FF9800', // 백테스팅과 구별: 청록/주황
color: buy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR,
text,
};
});
@@ -1,3 +1,7 @@
/**
* DSL DB buy/sell condition JSON .
* flow_layout UI(·START ); Logic Expression .
*/
import { normalizeLogicRootForPersistence } from './strategyConditionNormalize';
import type { LogicNode, ConditionDSL } from './strategyTypes';
import { generateNodeId } from './strategyTypes';
+5
View File
@@ -0,0 +1,5 @@
/** 프로젝트 공통 — 매매 시그널 색 (매수: 빨강, 매도: 파랑) */
export const TRADE_BUY_COLOR = '#ef5350';
export const TRADE_SELL_COLOR = '#4dabf7';
export const TRADE_BUY_COLOR_HOVER = '#e53935';
export const TRADE_SELL_COLOR_HOVER = '#339af0';
+9 -108
View File
@@ -1,7 +1,6 @@
import { coerceFiniteNumber } from './safeFormat';
import {
formatIndicatorValue,
isConditionMet,
type VirtualConditionRow,
} from './virtualStrategyConditions';
@@ -10,7 +9,7 @@ export type ConditionStatus = 'match' | 'pending' | 'nomatch' | 'unknown';
export interface ConditionMetric {
row: VirtualConditionRow & { currentValue: number | null };
status: ConditionStatus;
/** 조건 매칭율 0~100 */
/** 조건 매칭율 0~100 — 백엔드 Ta4j satisfied 만 반영 */
matchRate: number;
/** @deprecated matchRate 와 동일 — 하위 호환 */
progress: number | null;
@@ -23,102 +22,28 @@ export function resolveHeatTier(matchRate: number, status: ConditionStatus): Hea
if (status === 'match' || matchRate >= 100) return 'match';
if (status === 'pending') return 'pending';
if (status === 'nomatch') return 'nomatch';
if (matchRate >= 45) return 'pending';
return 'nomatch';
}
export type TrafficLightState = 'red' | 'yellow' | 'blue';
const PENDING_PROGRESS = 72;
/** 목표값 대비 현재값 접근률 (0~100, 충족 시 100) */
export function computeConditionProgress(
conditionType: string,
current: number | null | unknown,
target: number | null | unknown,
): number | null {
const cur = coerceFiniteNumber(current);
const tgt = coerceFiniteNumber(target);
if (cur == null || tgt == null) return null;
const met = isConditionMet(conditionType, cur, tgt);
if (met === true) return 100;
const absTarget = Math.abs(tgt);
const scale = absTarget > 1e-9 ? absTarget : Math.max(Math.abs(cur), 1);
switch (conditionType) {
case 'LT':
case 'CROSS_DOWN':
case 'LTE': {
if (cur <= tgt) return 100;
const gap = cur - tgt;
return Math.min(99, Math.max(0, 100 - (gap / scale) * 100));
}
case 'GT':
case 'CROSS_UP':
case 'GTE': {
if (cur >= tgt) return 100;
if (tgt <= 0 && cur <= 0) return 0;
return Math.min(99, Math.max(0, (cur / scale) * 100));
}
case 'EQ': {
const diff = Math.abs(cur - tgt);
return Math.min(99, Math.max(0, 100 - (diff / scale) * 100));
}
default:
return Math.min(99, Math.max(0, 100 - (Math.abs(cur - tgt) / scale) * 100));
}
}
const CROSS_CONDITION_TYPES = new Set([
'CROSS_UP', 'CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN',
]);
/** 조건별 매칭율 (0~100) — 백엔드 Ta4j satisfied 우선 */
/** 조건별 매칭율 (0~100) — 백엔드 live-conditions 의 satisfied 만 사용 */
export function computeConditionMatchRate(
row: VirtualConditionRow & { currentValue: number | null },
): number {
if (row.satisfied === true) return 100;
if (row.satisfied === false) {
if (CROSS_CONDITION_TYPES.has(row.conditionType)) return 0;
const progress = computeConditionProgress(
row.conditionType,
row.currentValue,
row.targetValue,
);
if (progress == null) return 0;
return Math.min(99, Math.max(0, Math.round(progress)));
}
if (row.currentValue == null) return 0;
const progress = computeConditionProgress(
row.conditionType,
row.currentValue,
row.targetValue,
);
if (progress == null) return 0;
return Math.min(99, Math.max(0, Math.round(progress)));
if (row.satisfied === false) return 0;
return 0;
}
export function getConditionStatus(
conditionType: string,
current: number | null,
target: number | null,
_conditionType: string,
_current: number | null,
_target: number | null,
satisfied?: boolean | null,
): ConditionStatus {
if (satisfied === true) return 'match';
if (satisfied === false) {
if (CROSS_CONDITION_TYPES.has(conditionType)) return 'nomatch';
const progress = computeConditionProgress(conditionType, current, target);
if (progress != null && progress >= PENDING_PROGRESS) return 'pending';
return 'nomatch';
}
const met = isConditionMet(conditionType, current, target);
if (met === true) return 'match';
if (met === false) {
const progress = computeConditionProgress(conditionType, current, target);
if (progress != null && progress >= PENDING_PROGRESS) return 'pending';
return 'nomatch';
}
if (satisfied === false) return 'nomatch';
return 'unknown';
}
@@ -196,35 +121,11 @@ export function computeMatchRate(
export function getTrafficLightState(matchRate: number, metrics: ConditionMetric[]): TrafficLightState {
if (matchRate >= 100) return 'blue';
const hasPending = metrics.some(m => m.status === 'pending');
const hasPartial = metrics.some(m => m.status === 'match');
if (hasPending || hasPartial || matchRate >= 40) return 'yellow';
if (hasPartial || matchRate >= 40) return 'yellow';
return 'red';
}
/** 매수·매도 조건 충족률 기준 타이밍 (TradeAlertModal: 매수=블루, 매도=레드) */
export type VirtualTradeTiming = 'buy' | 'sell' | 'neutral';
function sideMatchRate(metrics: ConditionMetric[], side: 'buy' | 'sell'): number {
const rows = metrics.filter(m => m.row.side === side && m.status !== 'unknown');
if (rows.length === 0) return 0;
const met = rows.filter(m => m.status === 'match').length;
return Math.round((met / rows.length) * 100);
}
export function resolveVirtualTradeTiming(metrics: ConditionMetric[]): VirtualTradeTiming {
const buyRate = sideMatchRate(metrics, 'buy');
const sellRate = sideMatchRate(metrics, 'sell');
if (buyRate === 0 && sellRate === 0) return 'neutral';
if (buyRate >= 100 && buyRate > sellRate) return 'buy';
if (sellRate >= 100 && sellRate > buyRate) return 'sell';
if (buyRate >= 100 && sellRate < 100) return 'buy';
if (sellRate >= 100 && buyRate < 100) return 'sell';
if (buyRate > sellRate && buyRate >= 50) return 'buy';
if (sellRate > buyRate && sellRate >= 50) return 'sell';
return 'neutral';
}
export function formatUpdatedTime(ts: number | undefined): string {
if (!ts) return '—';
return new Date(ts).toLocaleTimeString('ko-KR', {
@@ -83,26 +83,6 @@ export function extractVirtualConditions(strategy: StrategyDto | null | undefine
return out;
}
/** 조건 충족 여부 (단순 임계값 비교) */
export function isConditionMet(
conditionType: string,
current: number | null | unknown,
target: number | null | unknown,
): boolean | null {
const cur = coerceFiniteNumber(current);
const tgt = coerceFiniteNumber(target);
if (cur == null || tgt == null) return null;
switch (conditionType) {
case 'GT': case 'CROSS_UP': return cur > tgt;
case 'LT': case 'CROSS_DOWN': return cur < tgt;
case 'GTE': return cur >= tgt;
case 'LTE': return cur <= tgt;
case 'EQ': return Math.abs(cur - tgt) < 1e-6;
case 'NEQ': return Math.abs(cur - tgt) >= 1e-6;
default: return null;
}
}
export function formatIndicatorValue(v: number | null | unknown): string {
const n = coerceFiniteNumber(v);
if (n == null) return '—';
+25 -2
View File
@@ -18,6 +18,26 @@ goldenChart **전략편집기(Strategy Editor)** 의 화면 구성, 편집·저
전략편집기에서 저장하는 것은 **조건 트리 JSON**(`buyCondition`, `sellCondition`)이며, 노드 좌표·팔레트 사용자 정의 항목 등은 **브라우저 localStorage**에만 둘 수 있습니다.
### 1.1 아키텍처 원칙 (단일 평가 원천)
| 계층 | 역할 |
|------|------|
| **전략편집기** | `buy_condition_json` / `sell_condition_json` 만 DB에 저장. 그래프·목록 빌더는 **동일 DSL** 공유, 표현만 다름 |
| **Logic Expression** | 저장된 DSL의 **설명용 미리보기** — 별도 조건 컬럼 없음 |
| **`flow_layout_json`** | 캔버스 좌표·START 메타 등 **UI** — Ta4j 평가 입력이 아님 (DSL과 동기 저장) |
| **백엔드** | Ta4j로 **유일한** BUY/SELL·조건 충족 평가 |
| **프론트 가상투자 UI** | `GET /strategy/live-conditions` 표시·일치율만. **프론트에서 조건 재평가하지 않음** |
| **가상 자동매매** | 백엔드 STOMP `signal` (BUY/SELL) 수신 시에만 모의 체결 |
### 1.2 봉 마감별 전략 체크 (스킵 방지)
1. 업비트 틱 → `BarBuilder.commitBar` — 1m 확정 시 전략 DSL에 포함된 상위봉(3m·5m·10m·15m …)까지 전파
2. 각 **상위봉 마감**`BarCloseStrategyEvaluationService.onMaturedBarClose``StrategyConditionTimeframeService.usesTimeframe` 이 true 인 분봉만 평가
3. WS 갭 → `CandleGapBackfillService` REST 병합 후 `evaluateRecentMaturedBarsAfterBackfill` 로 병합 구간 마감봉 보정
4. `REALTIME_TICK` 실행 방식은 추가로 `LiveStrategyScheduler`(3초)가 **동일 DSL 분봉**만 순회
예: 비트코인 전략 START에 `3m,5m,10m,15m` 이면 각 분봉 **마감봉마다** 백엔드 평가가 1회씩 실행되어야 하며, 평가 분봉은 DSL에 없는 `1h` 등에서는 실행되지 않음.
---
## 2. 화면 구성 (`StrategyEditorPage`)
@@ -581,7 +601,8 @@ flowchart TB
| `backend/.../service/BacktestingService.java` | 백테스트 |
| `backend/.../service/LiveConditionStatusService.java` | 조건 현황 API |
| `backend/.../service/StrategyConditionTimeframeService.java` | DSL 분봉 수집 |
| `backend/.../websocket/BarBuilder.java` | 봉 조립·마감 평가 |
| `backend/.../websocket/BarBuilder.java` | 봉 조립·마감 시 평가 트리거 |
| `backend/.../service/BarCloseStrategyEvaluationService.java` | 봉 마감 전략 평가 단일 진입점 |
| `backend/.../websocket/LiveStrategyScheduler.java` | 3초 REALTIME_TICK |
| `backend/.../websocket/TradingWebSocketBroker.java` | STOMP 발행 |
| `backend/.../storage/Ta4jStorage.java` | BarSeries 저장소 |
@@ -597,7 +618,9 @@ flowchart TB
5. **데이터**`Ta4jStorage.exists(market, candleType)`, warm-up(`pinCandleWatch`)
6. **포지션 모드**`LONG_ONLY`에서 이미 포지션일 때 BUY 무시
7. **STOMP** — 구독 토픽 `/sub/charts/{market}/{candleType}` 와 평가 분봉 일치
8. **UI 현황 vs 시그널**`live-conditions` matchRate ≠ 실제 BUY/SELL 발화 조건
8. **UI 현황 vs 시그널**`live-conditions` matchRate ≠ 실제 BUY/SELL 발화 조건 (UI는 리프별 Rule, 시그널은 트리 전체)
9. **마감봉 평가**`BarCloseStrategyEvaluationService` + DSL `usesTimeframe` 게이트; 갭은 `CandleGapBackfillService` 후 보정
10. **프론트 평가 금지** — 가상투자 일치율·자동매매는 백엔드 API/STOMP 만 사용
---