diff --git a/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java b/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java index be05d50..89ca38a 100644 --- a/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java +++ b/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java @@ -1,10 +1,13 @@ package com.goldenchart.config; import com.goldenchart.entity.GcAppSettings; +import com.goldenchart.entity.GcLiveStrategySettings; import com.goldenchart.entity.GcWatchlist; +import com.goldenchart.repository.GcLiveStrategySettingsRepository; import com.goldenchart.repository.GcWatchlistRepository; import com.goldenchart.service.AppSettingsService; import com.goldenchart.service.LiveStrategyEvaluator; +import com.goldenchart.service.LiveStrategyTimeframeService; import com.goldenchart.websocket.DynamicSubscriptionManager; import com.goldenchart.websocket.UpbitWebSocketClient; import lombok.RequiredArgsConstructor; @@ -23,9 +26,10 @@ import org.springframework.stereotype.Component; public class LiveStrategyStartupRunner implements ApplicationRunner { private final AppSettingsService appSettingsService; - private final GcWatchlistRepository watchlistRepo; - private final DynamicSubscriptionManager subscriptionManager; - private final LiveStrategyEvaluator liveStrategyEvaluator; + private final GcWatchlistRepository watchlistRepo; + private final GcLiveStrategySettingsRepository liveSettingsRepo; + private final DynamicSubscriptionManager subscriptionManager; + private final LiveStrategyEvaluator liveStrategyEvaluator; private final UpbitWebSocketClient upbitWebSocketClient; @Override @@ -33,13 +37,18 @@ public class LiveStrategyStartupRunner implements ApplicationRunner { // 디바이스별 전역 설정이 켜져 있고 전략이 지정된 경우, DB 관심종목 전체 고정 구독 int pinned = 0; for (GcWatchlist w : watchlistRepo.findAll()) { + Long userId = w.getUserId(); String deviceId = w.getDeviceId(); - if (deviceId == null) continue; - GcAppSettings app = appSettingsService.getEntity(null, deviceId); + if (userId == null && deviceId == null) continue; + GcAppSettings app = appSettingsService.getEntity(userId, deviceId); if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) { continue; } + String ct = resolvePinnedCandleType(w); subscriptionManager.ensureMarketPinned(w.getSymbol(), "1m"); + if (!"1m".equals(ct)) { + subscriptionManager.ensureMarketPinned(w.getSymbol(), ct); + } liveStrategyEvaluator.invalidateCache(w.getSymbol()); pinned++; } @@ -49,4 +58,20 @@ public class LiveStrategyStartupRunner implements ApplicationRunner { // WS 연결 직후 addMarket 이 구독 패킷을 놓치는 경우 대비 upbitWebSocketClient.resubscribeAll(); } + + private String resolvePinnedCandleType(GcWatchlist w) { + if (w.getUserId() != null) { + return liveSettingsRepo.findByUserIdAndMarket(w.getUserId(), w.getSymbol()) + .map(GcLiveStrategySettings::getCandleType) + .map(LiveStrategyTimeframeService::normalize) + .orElse("1m"); + } + if (w.getDeviceId() != null) { + return liveSettingsRepo.findByDeviceIdAndMarket(w.getDeviceId(), w.getSymbol()) + .map(GcLiveStrategySettings::getCandleType) + .map(LiveStrategyTimeframeService::normalize) + .orElse("1m"); + } + return "1m"; + } } diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java index 05a65fa..29d5b4a 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java @@ -75,11 +75,20 @@ public class LiveStrategySettingsService { /** 관심종목 등록 시 — DB 관심종목 = 전략 체크 대상 행 생성/갱신 */ public void enableForWatchlistMarket(Long userId, String deviceId, String market) { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); - LiveStrategySettingsDto template = defaultDtoFromApp(market, app); + LiveStrategySettingsDto template = findEntity(userId, deviceId, market) + .map(e -> mergeGlobalTemplate(toDto(e), app, market)) + .orElseGet(() -> { + LiveStrategySettingsDto dto = defaultDtoFromApp(market, app); + String inherited = resolveSharedCandleType(userId, deviceId); + if (inherited != null) { + dto.setCandleType(inherited); + } + return mergeGlobalTemplate(dto, app, market); + }); template.setLiveCheck(true); upsertEntity(userId, deviceId, template); pinIfReady(market, template); - log.debug("[LiveStrategy] watchlist add → enabled {}", market); + log.debug("[LiveStrategy] watchlist add → enabled {} candleType={}", market, template.getCandleType()); } /** 관심종목 해제 시 — 해당 종목 전략 체크 비활성화 */ @@ -163,10 +172,17 @@ public class LiveStrategySettingsService { && app.getLiveStrategyId() != null; String candleType = template != null && template.getCandleType() != null ? LiveStrategyTimeframeService.normalize(template.getCandleType()) - : null; + : resolveSharedCandleType(userId, deviceId); for (String symbol : listWatchlistSymbols(userId, deviceId)) { - LiveStrategySettingsDto row = defaultDtoFromApp(symbol, app); + LiveStrategySettingsDto row = findEntity(userId, deviceId, symbol) + .map(e -> mergeGlobalTemplate(toDto(e), app, symbol)) + .orElseGet(() -> defaultDtoFromApp(symbol, app)); row.setLiveCheck(active); + row.setStrategyId(app.getLiveStrategyId()); + row.setExecutionType(app.getLiveExecutionType() != null + ? app.getLiveExecutionType() : row.getExecutionType()); + row.setPositionMode(app.getLivePositionMode() != null + ? app.getLivePositionMode() : row.getPositionMode()); if (candleType != null) { row.setCandleType(candleType); } @@ -177,6 +193,17 @@ public class LiveStrategySettingsService { } } + /** 관심종목 중 이미 저장된 평가 분봉 (전역 템플릿 동기화용) */ + private String resolveSharedCandleType(Long userId, String deviceId) { + for (String symbol : listWatchlistSymbols(userId, deviceId)) { + Optional row = findEntity(userId, deviceId, symbol); + if (row.isPresent() && row.get().getCandleType() != null) { + return LiveStrategyTimeframeService.normalize(row.get().getCandleType()); + } + } + return null; + } + private LiveStrategySettingsDto upsertEntity(Long userId, String deviceId, LiveStrategySettingsDto dto) { String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC"; @@ -207,7 +234,10 @@ public class LiveStrategySettingsService { if (Boolean.TRUE.equals(dto.isLiveCheck()) && dto.getStrategyId() != null) { String ct = LiveStrategyTimeframeService.normalize( dto.getCandleType() != null ? dto.getCandleType() : "1m"); - subscriptionManager.ensureMarketPinned(market, ct); + subscriptionManager.ensureMarketPinned(market, "1m"); + if (!"1m".equals(ct)) { + subscriptionManager.ensureMarketPinned(market, ct); + } } } diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategyTimeframeService.java b/backend/src/main/java/com/goldenchart/service/LiveStrategyTimeframeService.java index 9a59877..cdd0f15 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategyTimeframeService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategyTimeframeService.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Comparator; import java.util.Optional; import java.util.Set; @@ -44,9 +45,12 @@ public class LiveStrategyTimeframeService { @Transactional(readOnly = true) public String resolveCandleTypeForMarket(String market) { return repo.findActiveByMarket(market).stream() + .filter(s -> s.getCandleType() != null && !s.getCandleType().isBlank()) + .max(Comparator.comparing( + GcLiveStrategySettings::getUpdatedAt, + Comparator.nullsLast(Comparator.naturalOrder()))) .map(GcLiveStrategySettings::getCandleType) .map(LiveStrategyTimeframeService::normalize) - .findFirst() .orElse("1m"); } diff --git a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java index e4633ea..43470c4 100644 --- a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java +++ b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java @@ -55,24 +55,6 @@ public class BarBuilder { /** 진행 중인 1분봉 상태 (market → PartialBar) */ private final ConcurrentHashMap partialBars = new ConcurrentHashMap<>(); - /** - * 봉 마감 직후 전략 판정 시그널 임시 보관 (market → "BUY"|"SELL"|"NONE"). - * publishCurrentBar 에서 단 1회 실어 보낸 뒤 제거된다. - */ - private final ConcurrentHashMap signalOnClose = new ConcurrentHashMap<>(); - - /** - * 시그널이 발생한 봉의 barStart epoch-second 임시 보관. - * publishCurrentBar 에서 signal 과 함께 사용되고 제거된다. - */ - private final ConcurrentHashMap signalBarTimeEpoch = new ConcurrentHashMap<>(); - - /** - * 시그널이 발생한 봉의 최종 close 가격 임시 보관. - * publishCurrentBar 에서 시그널 메시지의 price 로 사용된다. - */ - private final ConcurrentHashMap signalBarClose = new ConcurrentHashMap<>(); - // ── 공개 API ────────────────────────────────────────────────────────────── /** @@ -136,7 +118,7 @@ public class BarBuilder { String evalCandleType = timeframeService.resolveCandleTypeForMarket(market); BarSeries series1m = ta4jStorage.getOrCreate(market, "1m"); if ("1m".equals(evalCandleType)) { - evaluateStrategyOnBarClose(market, "1m", series1m.getEndIndex(), partial, bar, true); + evaluateStrategyOnBarClose(market, "1m", series1m.getEndIndex(), bar); } // 상위 타임프레임 전파 @@ -167,7 +149,8 @@ public class BarBuilder { BarSeries upperAfter = ta4jStorage.getOrCreate(market, type); if (type.equals(evalCandleType)) { int maturedUpper = Math.max(upperAfter.getBeginIndex(), upperAfter.getEndIndex() - 1); - evaluateStrategyOnBarClose(market, type, maturedUpper, partial, bar, false); + Bar maturedUpperBar = upperAfter.getBar(maturedUpper); + evaluateStrategyOnBarClose(market, type, maturedUpper, maturedUpperBar); } } } @@ -175,12 +158,10 @@ public class BarBuilder { } /** - * 봉 마감 시점 전략 평가 + 시그널 저장 + 주문 큐 적재. - * - * @param publishStompSignal 1m 만 STOMP 시그널 필드에 실어 보냄 + * 봉 마감 시점 전략 평가 + 시그널 STOMP 발행(평가 분봉 채널) + DB/주문 큐 적재. */ private void evaluateStrategyOnBarClose(String market, String candleType, int maturedIndex, - PartialBar partial, Bar bar, boolean publishStompSignal) { + Bar signalBar) { String closeSignal = liveStrategyEvaluator.evaluateCandleClose(market, candleType, maturedIndex); String signalExecType = "CANDLE_CLOSE"; @@ -189,10 +170,8 @@ public class BarBuilder { signalExecType = "REALTIME_TICK"; } - if (publishStompSignal) { - signalOnClose.put(market, closeSignal); - signalBarTimeEpoch.put(market, partial.barStart.toEpochSecond()); - signalBarClose.put(market, partial.close); + if ("BUY".equals(closeSignal) || "SELL".equals(closeSignal)) { + publishStrategySignal(market, candleType, signalBar, closeSignal); } if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) return; @@ -202,70 +181,52 @@ public class BarBuilder { GcLiveStrategySettings activeSetting = liveSettingsRepo.findActiveByMarket(market) .stream().filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) && execType.equals(s.getExecutionType())).findFirst().orElse(null); - long candleTimeEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); + long candleTimeEpoch = signalBar.getEndTime().getEpochSecond() + - signalBar.getTimePeriod().getSeconds(); String devId = activeSetting != null ? activeSetting.getDeviceId() : null; Long uid = activeSetting != null ? activeSetting.getUserId() : null; Long stratId = activeSetting != null ? activeSetting.getStrategyId() : null; tradeSignalService.save( devId, uid, market, stratId, null, - closeSignal, partial.close, candleTimeEpoch, candleType, execType); + closeSignal, signalBar.getClosePrice().doubleValue(), + candleTimeEpoch, candleType, execType); if (devId != null) { - orderExecutionQueue.submitSignal(devId, uid, market, stratId, closeSignal, partial.close); + orderExecutionQueue.submitSignal(devId, uid, market, stratId, closeSignal, + signalBar.getClosePrice().doubleValue()); } } catch (Exception e) { log.warn("[BarBuilder] 시그널 처리 실패 ({}/{}): {}", market, candleType, e.getMessage()); } } - /** - * 현재 진행 중인 1분봉을 STOMP 로 발행. - * - *

핵심 버그 수정 (백테스팅 데이터 오염 방지)
- * 분 전환 시점에 {@code commitBar} 가 {@code signalOnClose = "NONE"} 을 항상 설정하므로, - * 이전 코드에서는 {@code signal != null} 조건만으로 {@code closedBarTime} 을 결정했다. - * 그 결과 분 전환 첫 틱에 발행되는 STOMP 메시지가: - * - time = 직전 확정봉 barStart (잘못된 타임스탬프) - * - close = 새 provisional 봉 첫 틱 가격 (잘못된 close) - * 가 되어 프론트엔드 차트에서 직전 확정봉의 close 를 새 봉의 첫 틱으로 덮어쓰게 된다. - * 이 오염된 close 값은 백테스팅 전송 데이터로도 활용되어 CCI 계산이 왜곡된다. - * - *

수정 원칙: - *

    - *
  • 실제 BUY/SELL 시그널 → 마감봉 시각(closedBarTime)과 마감봉 close 를 사용
  • - *
  • "NONE" 또는 null → 현재 진행 중인 봉 시각(p.barStart)과 실시간 close 를 사용
  • - *
- */ + /** 평가 분봉 STOMP 채널로 봉 마감 시그널 발행 */ + private void publishStrategySignal(String market, String candleType, Bar bar, String signal) { + 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) + .build(); + broker.publish(market, candleType, dto); + log.info("[BarBuilder] bar-close signal={} market={} candleType={}", signal, market, candleType); + } + + /** 현재 진행 중인 1분봉을 STOMP 로 발행 (실시간 틱 렌더링용, 시그널 없음). */ private void publishCurrentBar(String market) { PartialBar p = partialBars.get(market); if (p == null) return; - String signal = signalOnClose.remove(market); - - // ★ "NONE" 은 실제 시그널이 아니므로 closedBarTime 을 사용하지 않는다. - // 이전에는 signal != null 로만 판단해서 "NONE" 도 시각 보정 대상이 되었고, - // 결과적으로 새 봉의 첫 틱 데이터가 직전 확정봉 시각으로 발행되어 차트 데이터가 오염됐다. - boolean isRealSignal = "BUY".equals(signal) || "SELL".equals(signal); - - Long closedBarTime = isRealSignal ? signalBarTimeEpoch.remove(market) : null; - Double closedBarClose = isRealSignal ? signalBarClose.remove(market) : null; - - // 잔여 맵 엔트리 정리 (실제 시그널 없는 경우) - if (!isRealSignal) { - signalBarTimeEpoch.remove(market); - signalBarClose.remove(market); - } - - long barTime = (closedBarTime != null) ? closedBarTime : p.barStart.toEpochSecond(); - double barClose = (closedBarClose != null) ? closedBarClose : p.close; - CandleBarDto dto = CandleBarDto.builder() - .time(barTime) + .time(p.barStart.toEpochSecond()) .open(p.open) .high(p.high) .low(p.low) - .close(barClose) + .close(p.close) .volume(p.volume) - .signal(isRealSignal ? signal : null) // null 이면 @JsonInclude(NON_NULL) 로 직렬화 제외 .build(); broker.publish(market, "1m", dto); publishUpperTimeframes(market); diff --git a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java index 7799acd..7043e03 100644 --- a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java +++ b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java @@ -42,7 +42,6 @@ public class LiveStrategyScheduler { private final BarBuilder barBuilder; private final TradeSignalService tradeSignalService; private final OrderExecutionQueue orderExecutionQueue; - private final LiveStrategyTimeframeService timeframeService; /** * 3초마다 REALTIME_TICK 설정 종목을 순회하여 전략 판정 후 시그널 발행. @@ -58,8 +57,8 @@ public class LiveStrategyScheduler { if (s.getStrategyId() == null) continue; String market = s.getMarket(); - String candleType = timeframeService.resolveCandleType( - market, s.getUserId(), s.getDeviceId()); + String candleType = LiveStrategyTimeframeService.normalize( + s.getCandleType() != null ? s.getCandleType() : "1m"); if (!ta4jStorage.exists(market, candleType)) continue; diff --git a/frontend/public/splash-bg.png b/frontend/public/splash-bg.png new file mode 100644 index 0000000..55e08f7 Binary files /dev/null and b/frontend/public/splash-bg.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 58f61f9..64bcfa7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -654,6 +654,7 @@ function App() { /** applyBacktestMarkersWhenReady rAF 재시도 시 최신 차트 종목·타임프레임 검증용 */ const chartContextRef = useRef({ symbol, timeframe }); chartContextRef.current = { symbol, timeframe }; + const barsMarketRef = useRef(null); const syncBacktestMarkersRef = useRef<() => void>(() => {}); @@ -730,8 +731,9 @@ function App() { const liveStompSubscriptions = useMemo(() => { if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return []; if (marketSubscriptions.length > 0) return marketSubscriptions; - return monitoredMarkets.map(m => ({ market: m, candleType: '1m' as const })); - }, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets]); + const ct = liveCandleType || '1m'; + return monitoredMarkets.map(m => ({ market: m, candleType: ct })); + }, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets, liveCandleType]); useEffect(() => { if (!appDefaults.liveStrategyCheck) { @@ -942,6 +944,7 @@ function App() { // ── Upbit 실시간 데이터 ───────────────────────────────────────────────── // 틱 수신: 동일 캔들 업데이트 → React state 변경 없이 차트만 직접 갱신 const handleTickUpdate = useCallback((bar: OHLCVBar) => { + if (barsMarketRef.current !== chartContextRef.current.symbol) return; if (!managerRef.current) { pendingRealtimeBarRef.current = bar; flashWsDot(); @@ -953,6 +956,7 @@ function App() { // 새 캔들: bars state를 변경하지 않고 차트에 바만 추가 + 인디케이터 last point 갱신 const handleNewCandle = useCallback((newBar: OHLCVBar) => { + if (barsMarketRef.current !== chartContextRef.current.symbol) return; if (!managerRef.current) { pendingRealtimeBarRef.current = newBar; flashWsDot(); @@ -963,7 +967,7 @@ function App() { }, []); const chartRealtimeSource = appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP'; - const { bars: upbitBars, latestBar, wsStatus, isLoading, error } = useChartRealtimeData( + const { bars: upbitBars, barsMarket: upbitBarsMarket, latestBar, wsStatus, isLoading, error } = useChartRealtimeData( symbol, timeframe, useMemo(() => ({ @@ -976,6 +980,8 @@ function App() { // 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음) const bars = useUpbit ? upbitBars : simBars; + const barsMarket = useUpbit ? upbitBarsMarket : symbol; + barsMarketRef.current = barsMarket; // Upbit 초기 로딩 완료 시 stats 업데이트 useEffect(() => { @@ -1955,7 +1961,7 @@ function App() { (function ChartSlot }, [symbol, timeframe, useUpbit]); const pendingRealtimeBarRef = useRef(null); + const barsMarketRef = useRef(null); const handleTickUpdate = useCallback((bar: OHLCVBar) => { + if (barsMarketRef.current !== symbolRef.current) return; if (!managerRef.current) { pendingRealtimeBarRef.current = bar; return; @@ -432,6 +434,7 @@ const ChartSlot = forwardRef(function ChartSlot managerRef.current.updateBar(bar); }, []); const handleNewCandle = useCallback((bar: OHLCVBar) => { + if (barsMarketRef.current !== symbolRef.current) return; if (!managerRef.current) { pendingRealtimeBarRef.current = bar; return; @@ -439,7 +442,7 @@ const ChartSlot = forwardRef(function ChartSlot managerRef.current.appendBar(bar); }, []); - const { bars: upbitBars, latestBar, wsStatus, isLoading } = useChartRealtimeData( + const { bars: upbitBars, barsMarket: upbitBarsMarket, latestBar, wsStatus, isLoading } = useChartRealtimeData( symbol, timeframe, useMemo(() => ({ onTickUpdate: handleTickUpdate, @@ -450,6 +453,8 @@ const ChartSlot = forwardRef(function ChartSlot ); const bars = useUpbit ? upbitBars : simBars; + const barsMarket = useUpbit ? upbitBarsMarket : symbol; + barsMarketRef.current = barsMarket; const lastKnownBar = useUpbit ? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null)) @@ -623,6 +628,7 @@ const ChartSlot = forwardRef(function ChartSlot = ({ onLoginSuccess, onGuest }) => { return (
-
- -
- -
-
+
+
diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 713207b..4f261b2 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -15,6 +15,8 @@ import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone'; interface TradingChartProps { bars: OHLCVBar[]; + /** bars 가 현재 market 과 일치할 때만 전달 (없으면 bars.length>0 이면 준비된 것으로 간주) */ + barsMarket?: string | null; /** BB 등 다른 심볼 계산용 */ market?: string; timeframe?: Timeframe; @@ -78,7 +80,7 @@ interface TradingChartProps { } const TradingChart: React.FC = ({ - bars, market = '', timeframe = '1D', chartType, theme, mode, indicators, drawingTool, drawings, + bars, barsMarket, market = '', timeframe = '1D', chartType, theme, mode, indicators, drawingTool, drawings, logScale, drawingsLocked = false, drawingsVisible = true, onCrosshair, onManagerReady, onAddDrawing, onSeriesClick, onSeriesDoubleClick, @@ -255,6 +257,8 @@ const TradingChart: React.FC = ({ // ── 전체 재로드 (데이터 + 인디케이터) ───────────────────────────────────── const reloadSafetyTimers = useRef[]>([]); + const reloadGenRef = useRef(0); + const reloadRetryRef = useRef(0); const reloadAll = useCallback(async ( mgr: ChartManager, @@ -266,10 +270,50 @@ const TradingChart: React.FC = ({ ) => { if (newBars.length === 0) return; + const gen = ++reloadGenRef.current; + reloadSafetyTimers.current.forEach(clearTimeout); reloadSafetyTimers.current = []; - barsRef.current = newBars; + barsRef.current = newBars; + + mgr.setData(newBars, ct, th); + if (gen !== reloadGenRef.current) { + prevBarsKey.current = ''; + return; + } + mgr.setTheme(th); + mgr.setLogScale(ls); + mgr.setDisplayTimezone(displayTimezone, timeframe); + + for (const ind of inds) { + if (gen !== reloadGenRef.current) { + prevBarsKey.current = ''; + return; + } + await mgr.addIndicator(ind); + } + + if (gen !== reloadGenRef.current) { + prevBarsKey.current = ''; + return; + } + + const expectIndicators = inds.some(i => i.hidden !== true); + if (expectIndicators && mgr.getIndicatorCount() === 0) { + prevBarsKey.current = ''; + if (reloadRetryRef.current < 3) { + reloadRetryRef.current += 1; + requestAnimationFrame(() => { + if (managerRef.current) { + void reloadAll(managerRef.current, newBars, ct, th, ls, inds); + } + }); + } + return; + } + + reloadRetryRef.current = 0; prevChartType.current = ct; prevTheme.current = th; prevLogScale.current = ls; @@ -277,15 +321,6 @@ const TradingChart: React.FC = ({ prevIndKey.current = indKey(inds); prevSortedPKRef.current = sortedParamKey(inds); - mgr.setData(newBars, ct, th); - mgr.setTheme(th); - mgr.setLogScale(ls); - mgr.setDisplayTimezone(displayTimezone, timeframe); - - for (const ind of inds) { - await mgr.addIndicator(ind); - } - await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { applyPaneLayout(mgr); requestAnimationFrame(() => { @@ -295,6 +330,11 @@ const TradingChart: React.FC = ({ }); }))); + if (gen !== reloadGenRef.current) { + prevBarsKey.current = ''; + return; + } + onDataLoaded?.(); reloadSafetyTimers.current = [300, 700, 1400].map(delay => @@ -611,6 +651,12 @@ const TradingChart: React.FC = ({ prevMarket.current = market; prevMarketTf.current = timeframe; prevBarsKey.current = ''; + prevIndKey.current = ''; + reloadRetryRef.current = 0; + reloadGenRef.current += 1; + reloadSafetyTimers.current.forEach(clearTimeout); + reloadSafetyTimers.current = []; + managerRef.current?.cancelPendingIndicatorUpdates(); }, [market, timeframe]); // ── 데이터/인디케이터 동기화 ───────────────────────────────────────────── @@ -618,6 +664,11 @@ const TradingChart: React.FC = ({ const mgr = managerRef.current; if (!mgr || !chartMgr || bars.length === 0) return; + const barsReady = barsMarket === undefined + ? true + : barsMarket === market; + if (!barsReady) return; + // manager 준비 전 bars 가 먼저 도착한 경우(로그인 직후 등) 메인 시리즈 누락 방지 if (!mgr.hasMainSeries()) { void reloadAll(mgr, bars, chartType, theme, logScale, indicators); @@ -631,12 +682,16 @@ const TradingChart: React.FC = ({ const ctChanged = chartType !== prevChartType.current; const thChanged = theme !== prevTheme.current; const lsChanged = logScale !== prevLogScale.current; + const expectIndicators = indicators.some(i => i.hidden !== true); + const indicatorsIncomplete = expectIndicators + && mgr.hasMainSeries() + && mgr.getIndicatorCount() === 0; - if (!barsChanged && !indChanged && !ctChanged && !thChanged && !lsChanged) return; + if (!barsChanged && !indChanged && !ctChanged && !thChanged && !lsChanged && !indicatorsIncomplete) return; - if (barsChanged || ctChanged || thChanged) { - // 데이터 또는 차트 형식 변경 → 전체 재로드 - reloadAll(mgr, bars, chartType, theme, logScale, indicators); + if (barsChanged || ctChanged || thChanged || indicatorsIncomplete) { + // 데이터·차트 형식 변경 또는 지표 누락 → 전체 재로드 + void reloadAll(mgr, bars, chartType, theme, logScale, indicators); } else if (lsChanged) { prevLogScale.current = logScale; mgr.setLogScale(logScale); @@ -715,7 +770,26 @@ const TradingChart: React.FC = ({ } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chartMgr, bars, chartType, theme, logScale, indicators]); + }, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators]); + + // 종목 전환 직후 reloadAll 이 중단되어 캔들만 남은 경우 자동 복구 + useEffect(() => { + const mgr = managerRef.current; + if (!mgr || !chartMgr || bars.length === 0) return; + if (barsMarket !== undefined && barsMarket !== market) return; + + const expectIndicators = indicators.some(i => i.hidden !== true); + if (!expectIndicators) return; + + const t = window.setTimeout(() => { + const m = managerRef.current; + if (!m || !m.hasMainSeries() || m.getIndicatorCount() > 0) return; + prevBarsKey.current = ''; + void reloadAll(m, bars, chartType, theme, logScale, indicators); + }, 120); + + return () => window.clearTimeout(t); + }, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, reloadAll]); // cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경 const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => { diff --git a/frontend/src/hooks/useStompChartData.ts b/frontend/src/hooks/useStompChartData.ts index e392122..6f0dd71 100644 --- a/frontend/src/hooks/useStompChartData.ts +++ b/frontend/src/hooks/useStompChartData.ts @@ -53,6 +53,8 @@ interface Options { interface Return { bars: OHLCVBar[]; + /** bars 가 어느 종목 기준인지 (로딩 중·전환 직후 null) */ + barsMarket: string | null; latestBar: OHLCVBar | null; wsStatus: WsStatus; isLoading: boolean; @@ -105,6 +107,7 @@ export function useStompChartData( ): Return { const candleType = timeframeToCandleType(timeframe); const [bars, setBars] = useState([]); + const [barsMarket, setBarsMarket] = useState(null); const [latestBar, setLatestBar] = useState(null); const [wsStatus, setWsStatus] = useState('connecting'); const [isLoading, setIsLoading] = useState(true); @@ -117,13 +120,17 @@ export function useStompChartData( /** 1m 봉 volume 스냅샷 — 상위 분봉 volume 델타 계산용 */ const last1mVolRef = useRef(0); const last1mTimeRef = useRef(0); + /** 초기 히스토리 로드 완료 전에는 차트 직접 갱신 콜백을 호출하지 않음 */ + const historyReadyRef = useRef(false); useEffect(() => { if (opts.enabled === false) return; let cancelled = false; + historyReadyRef.current = false; setIsLoading(true); setError(null); setBars([]); + setBarsMarket(null); barsRef.current = []; last1mVolRef.current = 0; last1mTimeRef.current = 0; @@ -134,6 +141,8 @@ export function useStompChartData( .then(newBars => { if (cancelled) return; barsRef.current = newBars; + historyReadyRef.current = newBars.length > 0; + setBarsMarket(market); setBars(newBars); setLatestBar(newBars[newBars.length - 1] ?? null); lastTimeRef.current = newBars[newBars.length - 1]?.time ?? 0; @@ -159,10 +168,11 @@ export function useStompChartData( const pushBar = (bar: OHLCVBar) => { const current = barsRef.current; + const notifyChart = historyReadyRef.current; if (current.length === 0) { barsRef.current = [bar]; setLatestBar(bar); - optsRef.current.onNewCandle(bar); + if (notifyChart) optsRef.current.onNewCandle(bar); lastTimeRef.current = bar.time; return; } @@ -170,16 +180,16 @@ export function useStompChartData( if (bar.time === last.time) { barsRef.current[current.length - 1] = bar; setLatestBar(bar); - optsRef.current.onTickUpdate(bar); + if (notifyChart) optsRef.current.onTickUpdate(bar); } else if (bar.time > last.time) { barsRef.current = [...current, bar]; setLatestBar(bar); - optsRef.current.onNewCandle(bar); + if (notifyChart) optsRef.current.onNewCandle(bar); } else { const patched = { ...bar, time: last.time }; barsRef.current[current.length - 1] = patched; setLatestBar(patched); - optsRef.current.onTickUpdate(patched); + if (notifyChart) optsRef.current.onTickUpdate(patched); } lastTimeRef.current = bar.time; }; @@ -255,12 +265,15 @@ export function useStompChartData( setWsStatus('connecting'); setError(null); + historyReadyRef.current = false; last1mVolRef.current = 0; last1mTimeRef.current = 0; void pinChartWatch(market, STOMP_TICK_CANDLE_TYPE); const topic = `/sub/charts/${market}/${STOMP_TICK_CANDLE_TYPE}`; + let alive = true; const unsub = subscribeStompTopic(topic, msg => { + if (!alive) return; setWsStatus('connected'); const data = parseStompJson(msg); if (data?.time == null || typeof data.time !== 'number') return; @@ -270,11 +283,12 @@ export function useStompChartData( const t = window.setTimeout(() => setWsStatus('connected'), 800); return () => { + alive = false; window.clearTimeout(t); unsub(); setWsStatus('disconnected'); }; }, [market, timeframe, opts.enabled, applyCandle]); - return { bars, latestBar, wsStatus, isLoading, error }; + return { bars, barsMarket, latestBar, wsStatus, isLoading, error }; } diff --git a/frontend/src/hooks/useUpbitData.ts b/frontend/src/hooks/useUpbitData.ts index 8daad18..357a98c 100644 --- a/frontend/src/hooks/useUpbitData.ts +++ b/frontend/src/hooks/useUpbitData.ts @@ -31,6 +31,8 @@ interface UpbitDataOptions { interface UseUpbitDataReturn { /** 초기 200개 캔들 (심볼/타임프레임 변경 시에만 갱신) */ bars: OHLCVBar[]; + /** bars 가 어느 종목 기준인지 (로딩 중·전환 직후 null) */ + barsMarket: string | null; /** 현재 진행 중인 캔들의 최신 스냅샷 (UI 표시용) */ latestBar: OHLCVBar | null; wsStatus: WsStatus; @@ -56,6 +58,7 @@ export function useUpbitData( ): UseUpbitDataReturn { // bars = 초기 FETCH_COUNT개 (REST), 실시간 수신 시 변경 안 함 → TradingChart setData 재호출 방지 const [bars, setBars] = useState([]); + const [barsMarket, setBarsMarket] = useState(null); const [latestBar, setLatestBar] = useState(null); const [wsStatus, setWsStatus] = useState('connecting'); const [isLoading, setIsLoading] = useState(true); @@ -76,6 +79,7 @@ export function useUpbitData( setIsLoading(true); setError(null); setBars([]); + setBarsMarket(null); setLatestBar(null); barsRef.current = []; @@ -92,6 +96,7 @@ export function useUpbitData( .then(newBars => { if (cancelled) return; barsRef.current = newBars; + setBarsMarket(market); setBars(newBars); setLatestBar(newBars[newBars.length - 1] ?? null); setIsLoading(false); @@ -169,5 +174,5 @@ export function useUpbitData( return () => client.destroy(); }, [market, timeframe, handleMessage, opts.enabled]); - return { bars, latestBar, wsStatus, isLoading, error }; + return { bars, barsMarket, latestBar, wsStatus, isLoading, error }; } diff --git a/frontend/src/styles/splashScreen.css b/frontend/src/styles/splashScreen.css index 7d96e52..10077e2 100644 --- a/frontend/src/styles/splashScreen.css +++ b/frontend/src/styles/splashScreen.css @@ -1,4 +1,4 @@ -/* GoldenChart splash — glassmorphism login + 은은한 차트 배경 */ +/* GoldenChart splash — glassmorphism login + 차트 콜라주 배경 이미지 */ .splash { position: fixed; @@ -7,104 +7,36 @@ display: flex; align-items: center; justify-content: center; - background: #0a0c10; + background: #06080d; color: #e8eaed; overflow: hidden; font-family: var(--font, 'Noto Sans KR', 'Inter', sans-serif); } -.splash-bg { +.splash-photo-bg { position: absolute; inset: 0; z-index: 0; - background: - radial-gradient(ellipse 70% 50% at 78% 35%, rgba(45, 212, 191, 0.07), transparent 58%), - linear-gradient(165deg, #0c0e13 0%, #0a0c10 55%, #08090d 100%); - pointer-events: none; -} - -/* 전체 화면 차트 — 우상향 캔들 + 일목 구름 */ -.splash-chart-layer { - position: absolute; - inset: -4% -6% -4% -2%; - z-index: 0; overflow: hidden; pointer-events: none; - transform: translate(3%, -4%) scale(1.08); - transform-origin: 75% 35%; } -.splash-chart-bg { - display: block; - width: 100%; - height: 100%; - opacity: 0.62; +.splash-photo-bg::before { + content: ''; + position: absolute; + inset: 0; + background: url('/splash-bg.png?v=3') center center / cover no-repeat; + filter: saturate(1.06) brightness(0.94); } -.splash-chart-fade { +.splash-photo-overlay { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: - radial-gradient(ellipse 42% 38% at 50% 52%, rgba(10, 12, 16, 0.55) 0%, rgba(10, 12, 16, 0.82) 72%), - linear-gradient(125deg, rgba(10, 12, 16, 0.72) 0%, rgba(10, 12, 16, 0.12) 38%, rgba(10, 12, 16, 0.45) 100%), - linear-gradient(to top, rgba(10, 12, 16, 0.35) 0%, transparent 28%); -} - -.splash-chart-grid { - stroke: rgba(255, 255, 255, 0.05); - stroke-width: 1; -} - -.splash-chart-kumo { - opacity: 0.85; -} - -.splash-ichi { - fill: none; - stroke-linecap: round; - stroke-linejoin: round; -} -.splash-ichi--tenkan { - stroke: rgba(56, 189, 248, 0.35); - stroke-width: 1.4; -} -.splash-ichi--kijun { - stroke: rgba(244, 114, 182, 0.32); - stroke-width: 1.4; -} -.splash-ichi--senkou-a { - stroke: rgba(34, 197, 94, 0.28); - stroke-width: 1; - stroke-dasharray: 4 4; -} -.splash-ichi--senkou-b { - stroke: rgba(239, 68, 68, 0.25); - stroke-width: 1; - stroke-dasharray: 4 4; -} - -.splash-candle-wick { - stroke: rgba(203, 213, 225, 0.45); - stroke-width: 1.3; -} -.splash-candle-up { - fill: rgba(45, 212, 191, 0.55); - stroke: rgba(94, 234, 212, 0.75); - stroke-width: 1; -} -.splash-candle-down { - fill: rgba(100, 116, 139, 0.5); - stroke: rgba(148, 163, 184, 0.45); - stroke-width: 1; -} - -.splash-chart-volume { - opacity: 0.35; -} -.splash-vol-bar { - fill: rgba(100, 116, 139, 0.35); + radial-gradient(ellipse 108% 92% at 50% 50%, transparent 36%, rgba(2, 4, 8, 0.48) 100%), + linear-gradient(to bottom, rgba(2, 4, 8, 0.28) 0%, rgba(2, 4, 8, 0.06) 44%, rgba(2, 4, 8, 0.3) 100%); } .splash-center { diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index d3527d0..7eb0740 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -164,6 +164,18 @@ export class ChartManager { private drawingLines: IPriceLine[] = []; private _clickHandler: ((p: MouseEventParams