종목변경 시 캔들차트 사라지는 현상 수정

This commit is contained in:
Macbook
2026-05-24 21:51:27 +09:00
parent af230a4233
commit 3c66c562d8
14 changed files with 288 additions and 219 deletions
@@ -1,10 +1,13 @@
package com.goldenchart.config; package com.goldenchart.config;
import com.goldenchart.entity.GcAppSettings; import com.goldenchart.entity.GcAppSettings;
import com.goldenchart.entity.GcLiveStrategySettings;
import com.goldenchart.entity.GcWatchlist; import com.goldenchart.entity.GcWatchlist;
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
import com.goldenchart.repository.GcWatchlistRepository; import com.goldenchart.repository.GcWatchlistRepository;
import com.goldenchart.service.AppSettingsService; import com.goldenchart.service.AppSettingsService;
import com.goldenchart.service.LiveStrategyEvaluator; import com.goldenchart.service.LiveStrategyEvaluator;
import com.goldenchart.service.LiveStrategyTimeframeService;
import com.goldenchart.websocket.DynamicSubscriptionManager; import com.goldenchart.websocket.DynamicSubscriptionManager;
import com.goldenchart.websocket.UpbitWebSocketClient; import com.goldenchart.websocket.UpbitWebSocketClient;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -23,9 +26,10 @@ import org.springframework.stereotype.Component;
public class LiveStrategyStartupRunner implements ApplicationRunner { public class LiveStrategyStartupRunner implements ApplicationRunner {
private final AppSettingsService appSettingsService; private final AppSettingsService appSettingsService;
private final GcWatchlistRepository watchlistRepo; private final GcWatchlistRepository watchlistRepo;
private final DynamicSubscriptionManager subscriptionManager; private final GcLiveStrategySettingsRepository liveSettingsRepo;
private final LiveStrategyEvaluator liveStrategyEvaluator; private final DynamicSubscriptionManager subscriptionManager;
private final LiveStrategyEvaluator liveStrategyEvaluator;
private final UpbitWebSocketClient upbitWebSocketClient; private final UpbitWebSocketClient upbitWebSocketClient;
@Override @Override
@@ -33,13 +37,18 @@ public class LiveStrategyStartupRunner implements ApplicationRunner {
// 디바이스별 전역 설정이 켜져 있고 전략이 지정된 경우, DB 관심종목 전체 고정 구독 // 디바이스별 전역 설정이 켜져 있고 전략이 지정된 경우, DB 관심종목 전체 고정 구독
int pinned = 0; int pinned = 0;
for (GcWatchlist w : watchlistRepo.findAll()) { for (GcWatchlist w : watchlistRepo.findAll()) {
Long userId = w.getUserId();
String deviceId = w.getDeviceId(); String deviceId = w.getDeviceId();
if (deviceId == null) continue; if (userId == null && deviceId == null) continue;
GcAppSettings app = appSettingsService.getEntity(null, deviceId); GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) { if (!Boolean.TRUE.equals(app.getLiveStrategyCheck()) || app.getLiveStrategyId() == null) {
continue; continue;
} }
String ct = resolvePinnedCandleType(w);
subscriptionManager.ensureMarketPinned(w.getSymbol(), "1m"); subscriptionManager.ensureMarketPinned(w.getSymbol(), "1m");
if (!"1m".equals(ct)) {
subscriptionManager.ensureMarketPinned(w.getSymbol(), ct);
}
liveStrategyEvaluator.invalidateCache(w.getSymbol()); liveStrategyEvaluator.invalidateCache(w.getSymbol());
pinned++; pinned++;
} }
@@ -49,4 +58,20 @@ public class LiveStrategyStartupRunner implements ApplicationRunner {
// WS 연결 직후 addMarket 이 구독 패킷을 놓치는 경우 대비 // WS 연결 직후 addMarket 이 구독 패킷을 놓치는 경우 대비
upbitWebSocketClient.resubscribeAll(); 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";
}
} }
@@ -75,11 +75,20 @@ public class LiveStrategySettingsService {
/** 관심종목 등록 시 — DB 관심종목 = 전략 체크 대상 행 생성/갱신 */ /** 관심종목 등록 시 — DB 관심종목 = 전략 체크 대상 행 생성/갱신 */
public void enableForWatchlistMarket(Long userId, String deviceId, String market) { public void enableForWatchlistMarket(Long userId, String deviceId, String market) {
GcAppSettings app = appSettingsService.getEntity(userId, deviceId); 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); template.setLiveCheck(true);
upsertEntity(userId, deviceId, template); upsertEntity(userId, deviceId, template);
pinIfReady(market, 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; && app.getLiveStrategyId() != null;
String candleType = template != null && template.getCandleType() != null String candleType = template != null && template.getCandleType() != null
? LiveStrategyTimeframeService.normalize(template.getCandleType()) ? LiveStrategyTimeframeService.normalize(template.getCandleType())
: null; : resolveSharedCandleType(userId, deviceId);
for (String symbol : listWatchlistSymbols(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.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) { if (candleType != null) {
row.setCandleType(candleType); row.setCandleType(candleType);
} }
@@ -177,6 +193,17 @@ public class LiveStrategySettingsService {
} }
} }
/** 관심종목 중 이미 저장된 평가 분봉 (전역 템플릿 동기화용) */
private String resolveSharedCandleType(Long userId, String deviceId) {
for (String symbol : listWatchlistSymbols(userId, deviceId)) {
Optional<GcLiveStrategySettings> 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, private LiveStrategySettingsDto upsertEntity(Long userId, String deviceId,
LiveStrategySettingsDto dto) { LiveStrategySettingsDto dto) {
String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC"; 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) { if (Boolean.TRUE.equals(dto.isLiveCheck()) && dto.getStrategyId() != null) {
String ct = LiveStrategyTimeframeService.normalize( String ct = LiveStrategyTimeframeService.normalize(
dto.getCandleType() != null ? dto.getCandleType() : "1m"); dto.getCandleType() != null ? dto.getCandleType() : "1m");
subscriptionManager.ensureMarketPinned(market, ct); subscriptionManager.ensureMarketPinned(market, "1m");
if (!"1m".equals(ct)) {
subscriptionManager.ensureMarketPinned(market, ct);
}
} }
} }
@@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.Comparator;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@@ -44,9 +45,12 @@ public class LiveStrategyTimeframeService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public String resolveCandleTypeForMarket(String market) { public String resolveCandleTypeForMarket(String market) {
return repo.findActiveByMarket(market).stream() 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(GcLiveStrategySettings::getCandleType)
.map(LiveStrategyTimeframeService::normalize) .map(LiveStrategyTimeframeService::normalize)
.findFirst()
.orElse("1m"); .orElse("1m");
} }
@@ -55,24 +55,6 @@ public class BarBuilder {
/** 진행 중인 1분봉 상태 (market → PartialBar) */ /** 진행 중인 1분봉 상태 (market → PartialBar) */
private final ConcurrentHashMap<String, PartialBar> partialBars = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, PartialBar> partialBars = new ConcurrentHashMap<>();
/**
* 봉 마감 직후 전략 판정 시그널 임시 보관 (market → "BUY"|"SELL"|"NONE").
* publishCurrentBar 에서 단 1회 실어 보낸 뒤 제거된다.
*/
private final ConcurrentHashMap<String, String> signalOnClose = new ConcurrentHashMap<>();
/**
* 시그널이 발생한 봉의 barStart epoch-second 임시 보관.
* publishCurrentBar 에서 signal 과 함께 사용되고 제거된다.
*/
private final ConcurrentHashMap<String, Long> signalBarTimeEpoch = new ConcurrentHashMap<>();
/**
* 시그널이 발생한 봉의 최종 close 가격 임시 보관.
* publishCurrentBar 에서 시그널 메시지의 price 로 사용된다.
*/
private final ConcurrentHashMap<String, Double> signalBarClose = new ConcurrentHashMap<>();
// ── 공개 API ────────────────────────────────────────────────────────────── // ── 공개 API ──────────────────────────────────────────────────────────────
/** /**
@@ -136,7 +118,7 @@ public class BarBuilder {
String evalCandleType = timeframeService.resolveCandleTypeForMarket(market); String evalCandleType = timeframeService.resolveCandleTypeForMarket(market);
BarSeries series1m = ta4jStorage.getOrCreate(market, "1m"); BarSeries series1m = ta4jStorage.getOrCreate(market, "1m");
if ("1m".equals(evalCandleType)) { 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); BarSeries upperAfter = ta4jStorage.getOrCreate(market, type);
if (type.equals(evalCandleType)) { if (type.equals(evalCandleType)) {
int maturedUpper = Math.max(upperAfter.getBeginIndex(), upperAfter.getEndIndex() - 1); 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 {
} }
/** /**
* 봉 마감 시점 전략 평가 + 시그널 저장 + 주문 큐 적재. * 봉 마감 시점 전략 평가 + 시그널 STOMP 발행(평가 분봉 채널) + DB/주문 큐 적재.
*
* @param publishStompSignal 1m 만 STOMP 시그널 필드에 실어 보냄
*/ */
private void evaluateStrategyOnBarClose(String market, String candleType, int maturedIndex, 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 closeSignal = liveStrategyEvaluator.evaluateCandleClose(market, candleType, maturedIndex);
String signalExecType = "CANDLE_CLOSE"; String signalExecType = "CANDLE_CLOSE";
@@ -189,10 +170,8 @@ public class BarBuilder {
signalExecType = "REALTIME_TICK"; signalExecType = "REALTIME_TICK";
} }
if (publishStompSignal) { if ("BUY".equals(closeSignal) || "SELL".equals(closeSignal)) {
signalOnClose.put(market, closeSignal); publishStrategySignal(market, candleType, signalBar, closeSignal);
signalBarTimeEpoch.put(market, partial.barStart.toEpochSecond());
signalBarClose.put(market, partial.close);
} }
if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) return; if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) return;
@@ -202,70 +181,52 @@ public class BarBuilder {
GcLiveStrategySettings activeSetting = liveSettingsRepo.findActiveByMarket(market) GcLiveStrategySettings activeSetting = liveSettingsRepo.findActiveByMarket(market)
.stream().filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck()) .stream().filter(s -> Boolean.TRUE.equals(s.getIsLiveCheck())
&& execType.equals(s.getExecutionType())).findFirst().orElse(null); && 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; String devId = activeSetting != null ? activeSetting.getDeviceId() : null;
Long uid = activeSetting != null ? activeSetting.getUserId() : null; Long uid = activeSetting != null ? activeSetting.getUserId() : null;
Long stratId = activeSetting != null ? activeSetting.getStrategyId() : null; Long stratId = activeSetting != null ? activeSetting.getStrategyId() : null;
tradeSignalService.save( tradeSignalService.save(
devId, uid, market, stratId, null, devId, uid, market, stratId, null,
closeSignal, partial.close, candleTimeEpoch, candleType, execType); closeSignal, signalBar.getClosePrice().doubleValue(),
candleTimeEpoch, candleType, execType);
if (devId != null) { 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) { } catch (Exception e) {
log.warn("[BarBuilder] 시그널 처리 실패 ({}/{}): {}", market, candleType, e.getMessage()); log.warn("[BarBuilder] 시그널 처리 실패 ({}/{}): {}", market, candleType, e.getMessage());
} }
} }
/** /** 평가 분봉 STOMP 채널로 봉 마감 시그널 발행 */
* 현재 진행 중인 1분봉을 STOMP 로 발행. private void publishStrategySignal(String market, String candleType, Bar bar, String signal) {
* long barStartEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
* <p><b>핵심 버그 수정 (백테스팅 데이터 오염 방지)</b><br> CandleBarDto dto = CandleBarDto.builder()
* 분 전환 시점에 {@code commitBar} 가 {@code signalOnClose = "NONE"} 을 항상 설정하므로, .time(barStartEpoch)
* 이전 코드에서는 {@code signal != null} 조건만으로 {@code closedBarTime} 을 결정했다. .open(bar.getOpenPrice().doubleValue())
* 그 결과 분 전환 첫 틱에 발행되는 STOMP 메시지가: .high(bar.getHighPrice().doubleValue())
* - time = 직전 확정봉 barStart (잘못된 타임스탬프) .low(bar.getLowPrice().doubleValue())
* - close = 새 provisional 봉 첫 틱 가격 (잘못된 close) .close(bar.getClosePrice().doubleValue())
* 가 되어 프론트엔드 차트에서 직전 확정봉의 close 를 새 봉의 첫 틱으로 덮어쓰게 된다. .volume(bar.getVolume().doubleValue())
* 이 오염된 close 값은 백테스팅 전송 데이터로도 활용되어 CCI 계산이 왜곡된다. .signal(signal)
* .build();
* <p>수정 원칙: broker.publish(market, candleType, dto);
* <ul> log.info("[BarBuilder] bar-close signal={} market={} candleType={}", signal, market, candleType);
* <li>실제 BUY/SELL 시그널 → 마감봉 시각(closedBarTime)과 마감봉 close 를 사용</li> }
* <li>"NONE" 또는 null → 현재 진행 중인 봉 시각(p.barStart)과 실시간 close 를 사용</li>
* </ul> /** 현재 진행 중인 1분봉을 STOMP 로 발행 (실시간 틱 렌더링용, 시그널 없음). */
*/
private void publishCurrentBar(String market) { private void publishCurrentBar(String market) {
PartialBar p = partialBars.get(market); PartialBar p = partialBars.get(market);
if (p == null) return; 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() CandleBarDto dto = CandleBarDto.builder()
.time(barTime) .time(p.barStart.toEpochSecond())
.open(p.open) .open(p.open)
.high(p.high) .high(p.high)
.low(p.low) .low(p.low)
.close(barClose) .close(p.close)
.volume(p.volume) .volume(p.volume)
.signal(isRealSignal ? signal : null) // null 이면 @JsonInclude(NON_NULL) 로 직렬화 제외
.build(); .build();
broker.publish(market, "1m", dto); broker.publish(market, "1m", dto);
publishUpperTimeframes(market); publishUpperTimeframes(market);
@@ -42,7 +42,6 @@ public class LiveStrategyScheduler {
private final BarBuilder barBuilder; private final BarBuilder barBuilder;
private final TradeSignalService tradeSignalService; private final TradeSignalService tradeSignalService;
private final OrderExecutionQueue orderExecutionQueue; private final OrderExecutionQueue orderExecutionQueue;
private final LiveStrategyTimeframeService timeframeService;
/** /**
* 3초마다 REALTIME_TICK 설정 종목을 순회하여 전략 판정 후 시그널 발행. * 3초마다 REALTIME_TICK 설정 종목을 순회하여 전략 판정 후 시그널 발행.
@@ -58,8 +57,8 @@ public class LiveStrategyScheduler {
if (s.getStrategyId() == null) continue; if (s.getStrategyId() == null) continue;
String market = s.getMarket(); String market = s.getMarket();
String candleType = timeframeService.resolveCandleType( String candleType = LiveStrategyTimeframeService.normalize(
market, s.getUserId(), s.getDeviceId()); s.getCandleType() != null ? s.getCandleType() : "1m");
if (!ta4jStorage.exists(market, candleType)) continue; if (!ta4jStorage.exists(market, candleType)) continue;
Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

+10 -4
View File
@@ -654,6 +654,7 @@ function App() {
/** applyBacktestMarkersWhenReady rAF 재시도 시 최신 차트 종목·타임프레임 검증용 */ /** applyBacktestMarkersWhenReady rAF 재시도 시 최신 차트 종목·타임프레임 검증용 */
const chartContextRef = useRef({ symbol, timeframe }); const chartContextRef = useRef({ symbol, timeframe });
chartContextRef.current = { symbol, timeframe }; chartContextRef.current = { symbol, timeframe };
const barsMarketRef = useRef<string | null>(null);
const syncBacktestMarkersRef = useRef<() => void>(() => {}); const syncBacktestMarkersRef = useRef<() => void>(() => {});
@@ -730,8 +731,9 @@ function App() {
const liveStompSubscriptions = useMemo(() => { const liveStompSubscriptions = useMemo(() => {
if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return []; if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return [];
if (marketSubscriptions.length > 0) return marketSubscriptions; if (marketSubscriptions.length > 0) return marketSubscriptions;
return monitoredMarkets.map(m => ({ market: m, candleType: '1m' as const })); const ct = liveCandleType || '1m';
}, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets]); return monitoredMarkets.map(m => ({ market: m, candleType: ct }));
}, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets, liveCandleType]);
useEffect(() => { useEffect(() => {
if (!appDefaults.liveStrategyCheck) { if (!appDefaults.liveStrategyCheck) {
@@ -942,6 +944,7 @@ function App() {
// ── Upbit 실시간 데이터 ───────────────────────────────────────────────── // ── Upbit 실시간 데이터 ─────────────────────────────────────────────────
// 틱 수신: 동일 캔들 업데이트 → React state 변경 없이 차트만 직접 갱신 // 틱 수신: 동일 캔들 업데이트 → React state 변경 없이 차트만 직접 갱신
const handleTickUpdate = useCallback((bar: OHLCVBar) => { const handleTickUpdate = useCallback((bar: OHLCVBar) => {
if (barsMarketRef.current !== chartContextRef.current.symbol) return;
if (!managerRef.current) { if (!managerRef.current) {
pendingRealtimeBarRef.current = bar; pendingRealtimeBarRef.current = bar;
flashWsDot(); flashWsDot();
@@ -953,6 +956,7 @@ function App() {
// 새 캔들: bars state를 변경하지 않고 차트에 바만 추가 + 인디케이터 last point 갱신 // 새 캔들: bars state를 변경하지 않고 차트에 바만 추가 + 인디케이터 last point 갱신
const handleNewCandle = useCallback((newBar: OHLCVBar) => { const handleNewCandle = useCallback((newBar: OHLCVBar) => {
if (barsMarketRef.current !== chartContextRef.current.symbol) return;
if (!managerRef.current) { if (!managerRef.current) {
pendingRealtimeBarRef.current = newBar; pendingRealtimeBarRef.current = newBar;
flashWsDot(); flashWsDot();
@@ -963,7 +967,7 @@ function App() {
}, []); }, []);
const chartRealtimeSource = appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP'; 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, symbol,
timeframe, timeframe,
useMemo(() => ({ useMemo(() => ({
@@ -976,6 +980,8 @@ function App() {
// 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음) // 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음)
const bars = useUpbit ? upbitBars : simBars; const bars = useUpbit ? upbitBars : simBars;
const barsMarket = useUpbit ? upbitBarsMarket : symbol;
barsMarketRef.current = barsMarket;
// Upbit 초기 로딩 완료 시 stats 업데이트 // Upbit 초기 로딩 완료 시 stats 업데이트
useEffect(() => { useEffect(() => {
@@ -1955,7 +1961,7 @@ function App() {
<TradingChart <TradingChart
key={chartMountKey} key={chartMountKey}
bars={bars} market={symbol} timeframe={timeframe} displayTimezone={displayTimezone} bars={bars} barsMarket={barsMarket} market={symbol} timeframe={timeframe} displayTimezone={displayTimezone}
chartType={chartType} theme={theme} mode={mode} chartType={chartType} theme={theme} mode={mode}
indicators={effectiveIndicators} drawingTool={drawingTool} indicators={effectiveIndicators} drawingTool={drawingTool}
drawings={drawings} logScale={logScale} drawings={drawings} logScale={logScale}
+7 -1
View File
@@ -423,8 +423,10 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
}, [symbol, timeframe, useUpbit]); }, [symbol, timeframe, useUpbit]);
const pendingRealtimeBarRef = useRef<OHLCVBar | null>(null); const pendingRealtimeBarRef = useRef<OHLCVBar | null>(null);
const barsMarketRef = useRef<string | null>(null);
const handleTickUpdate = useCallback((bar: OHLCVBar) => { const handleTickUpdate = useCallback((bar: OHLCVBar) => {
if (barsMarketRef.current !== symbolRef.current) return;
if (!managerRef.current) { if (!managerRef.current) {
pendingRealtimeBarRef.current = bar; pendingRealtimeBarRef.current = bar;
return; return;
@@ -432,6 +434,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
managerRef.current.updateBar(bar); managerRef.current.updateBar(bar);
}, []); }, []);
const handleNewCandle = useCallback((bar: OHLCVBar) => { const handleNewCandle = useCallback((bar: OHLCVBar) => {
if (barsMarketRef.current !== symbolRef.current) return;
if (!managerRef.current) { if (!managerRef.current) {
pendingRealtimeBarRef.current = bar; pendingRealtimeBarRef.current = bar;
return; return;
@@ -439,7 +442,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
managerRef.current.appendBar(bar); managerRef.current.appendBar(bar);
}, []); }, []);
const { bars: upbitBars, latestBar, wsStatus, isLoading } = useChartRealtimeData( const { bars: upbitBars, barsMarket: upbitBarsMarket, latestBar, wsStatus, isLoading } = useChartRealtimeData(
symbol, timeframe, symbol, timeframe,
useMemo(() => ({ useMemo(() => ({
onTickUpdate: handleTickUpdate, onTickUpdate: handleTickUpdate,
@@ -450,6 +453,8 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
); );
const bars = useUpbit ? upbitBars : simBars; const bars = useUpbit ? upbitBars : simBars;
const barsMarket = useUpbit ? upbitBarsMarket : symbol;
barsMarketRef.current = barsMarket;
const lastKnownBar = useUpbit const lastKnownBar = useUpbit
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null)) ? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
@@ -623,6 +628,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
<TradingChart <TradingChart
key={reloadTick} key={reloadTick}
bars={bars} bars={bars}
barsMarket={barsMarket}
market={symbol} market={symbol}
timeframe={timeframe} timeframe={timeframe}
displayTimezone={displayTimezone} displayTimezone={displayTimezone}
+2 -7
View File
@@ -3,7 +3,6 @@
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { loginUser, type LoginResponse } from '../utils/backendApi'; import { loginUser, type LoginResponse } from '../utils/backendApi';
import SplashChartBackground from './splash/SplashChartBackground';
import '../styles/splashScreen.css'; import '../styles/splashScreen.css';
const DEFAULT_USERNAME = 'admin'; const DEFAULT_USERNAME = 'admin';
@@ -37,12 +36,8 @@ const SplashScreen: React.FC<Props> = ({ onLoginSuccess, onGuest }) => {
return ( return (
<div className="splash"> <div className="splash">
<div className="splash-bg" aria-hidden /> <div className="splash-photo-bg" aria-hidden />
<div className="splash-photo-overlay" aria-hidden />
<div className="splash-chart-layer" aria-hidden>
<SplashChartBackground />
</div>
<div className="splash-chart-fade" aria-hidden />
<div className="splash-center"> <div className="splash-center">
<div className="splash-glass"> <div className="splash-glass">
+90 -16
View File
@@ -15,6 +15,8 @@ import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone';
interface TradingChartProps { interface TradingChartProps {
bars: OHLCVBar[]; bars: OHLCVBar[];
/** bars 가 현재 market 과 일치할 때만 전달 (없으면 bars.length>0 이면 준비된 것으로 간주) */
barsMarket?: string | null;
/** BB 등 다른 심볼 계산용 */ /** BB 등 다른 심볼 계산용 */
market?: string; market?: string;
timeframe?: Timeframe; timeframe?: Timeframe;
@@ -78,7 +80,7 @@ interface TradingChartProps {
} }
const TradingChart: React.FC<TradingChartProps> = ({ const TradingChart: React.FC<TradingChartProps> = ({
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, logScale, drawingsLocked = false, drawingsVisible = true,
onCrosshair, onManagerReady, onAddDrawing, onCrosshair, onManagerReady, onAddDrawing,
onSeriesClick, onSeriesDoubleClick, onSeriesClick, onSeriesDoubleClick,
@@ -255,6 +257,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
// ── 전체 재로드 (데이터 + 인디케이터) ───────────────────────────────────── // ── 전체 재로드 (데이터 + 인디케이터) ─────────────────────────────────────
const reloadSafetyTimers = useRef<ReturnType<typeof setTimeout>[]>([]); const reloadSafetyTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
const reloadGenRef = useRef(0);
const reloadRetryRef = useRef(0);
const reloadAll = useCallback(async ( const reloadAll = useCallback(async (
mgr: ChartManager, mgr: ChartManager,
@@ -266,10 +270,50 @@ const TradingChart: React.FC<TradingChartProps> = ({
) => { ) => {
if (newBars.length === 0) return; if (newBars.length === 0) return;
const gen = ++reloadGenRef.current;
reloadSafetyTimers.current.forEach(clearTimeout); reloadSafetyTimers.current.forEach(clearTimeout);
reloadSafetyTimers.current = []; 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; prevChartType.current = ct;
prevTheme.current = th; prevTheme.current = th;
prevLogScale.current = ls; prevLogScale.current = ls;
@@ -277,15 +321,6 @@ const TradingChart: React.FC<TradingChartProps> = ({
prevIndKey.current = indKey(inds); prevIndKey.current = indKey(inds);
prevSortedPKRef.current = sortedParamKey(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<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
applyPaneLayout(mgr); applyPaneLayout(mgr);
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -295,6 +330,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
}); });
}))); })));
if (gen !== reloadGenRef.current) {
prevBarsKey.current = '';
return;
}
onDataLoaded?.(); onDataLoaded?.();
reloadSafetyTimers.current = [300, 700, 1400].map(delay => reloadSafetyTimers.current = [300, 700, 1400].map(delay =>
@@ -611,6 +651,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
prevMarket.current = market; prevMarket.current = market;
prevMarketTf.current = timeframe; prevMarketTf.current = timeframe;
prevBarsKey.current = ''; prevBarsKey.current = '';
prevIndKey.current = '';
reloadRetryRef.current = 0;
reloadGenRef.current += 1;
reloadSafetyTimers.current.forEach(clearTimeout);
reloadSafetyTimers.current = [];
managerRef.current?.cancelPendingIndicatorUpdates();
}, [market, timeframe]); }, [market, timeframe]);
// ── 데이터/인디케이터 동기화 ───────────────────────────────────────────── // ── 데이터/인디케이터 동기화 ─────────────────────────────────────────────
@@ -618,6 +664,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
const mgr = managerRef.current; const mgr = managerRef.current;
if (!mgr || !chartMgr || bars.length === 0) return; if (!mgr || !chartMgr || bars.length === 0) return;
const barsReady = barsMarket === undefined
? true
: barsMarket === market;
if (!barsReady) return;
// manager 준비 전 bars 가 먼저 도착한 경우(로그인 직후 등) 메인 시리즈 누락 방지 // manager 준비 전 bars 가 먼저 도착한 경우(로그인 직후 등) 메인 시리즈 누락 방지
if (!mgr.hasMainSeries()) { if (!mgr.hasMainSeries()) {
void reloadAll(mgr, bars, chartType, theme, logScale, indicators); void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
@@ -631,12 +682,16 @@ const TradingChart: React.FC<TradingChartProps> = ({
const ctChanged = chartType !== prevChartType.current; const ctChanged = chartType !== prevChartType.current;
const thChanged = theme !== prevTheme.current; const thChanged = theme !== prevTheme.current;
const lsChanged = logScale !== prevLogScale.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) { if (barsChanged || ctChanged || thChanged || indicatorsIncomplete) {
// 데이터 또는 차트 형식 변경 → 전체 재로드 // 데이터·차트 형식 변경 또는 지표 누락 → 전체 재로드
reloadAll(mgr, bars, chartType, theme, logScale, indicators); void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
} else if (lsChanged) { } else if (lsChanged) {
prevLogScale.current = logScale; prevLogScale.current = logScale;
mgr.setLogScale(logScale); mgr.setLogScale(logScale);
@@ -715,7 +770,26 @@ const TradingChart: React.FC<TradingChartProps> = ({
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 커서로 변경 // cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경
const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => { const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => {
+19 -5
View File
@@ -53,6 +53,8 @@ interface Options {
interface Return { interface Return {
bars: OHLCVBar[]; bars: OHLCVBar[];
/** bars 가 어느 종목 기준인지 (로딩 중·전환 직후 null) */
barsMarket: string | null;
latestBar: OHLCVBar | null; latestBar: OHLCVBar | null;
wsStatus: WsStatus; wsStatus: WsStatus;
isLoading: boolean; isLoading: boolean;
@@ -105,6 +107,7 @@ export function useStompChartData(
): Return { ): Return {
const candleType = timeframeToCandleType(timeframe); const candleType = timeframeToCandleType(timeframe);
const [bars, setBars] = useState<OHLCVBar[]>([]); const [bars, setBars] = useState<OHLCVBar[]>([]);
const [barsMarket, setBarsMarket] = useState<string | null>(null);
const [latestBar, setLatestBar] = useState<OHLCVBar | null>(null); const [latestBar, setLatestBar] = useState<OHLCVBar | null>(null);
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting'); const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -117,13 +120,17 @@ export function useStompChartData(
/** 1m 봉 volume 스냅샷 — 상위 분봉 volume 델타 계산용 */ /** 1m 봉 volume 스냅샷 — 상위 분봉 volume 델타 계산용 */
const last1mVolRef = useRef<number>(0); const last1mVolRef = useRef<number>(0);
const last1mTimeRef = useRef<number>(0); const last1mTimeRef = useRef<number>(0);
/** 초기 히스토리 로드 완료 전에는 차트 직접 갱신 콜백을 호출하지 않음 */
const historyReadyRef = useRef(false);
useEffect(() => { useEffect(() => {
if (opts.enabled === false) return; if (opts.enabled === false) return;
let cancelled = false; let cancelled = false;
historyReadyRef.current = false;
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setBars([]); setBars([]);
setBarsMarket(null);
barsRef.current = []; barsRef.current = [];
last1mVolRef.current = 0; last1mVolRef.current = 0;
last1mTimeRef.current = 0; last1mTimeRef.current = 0;
@@ -134,6 +141,8 @@ export function useStompChartData(
.then(newBars => { .then(newBars => {
if (cancelled) return; if (cancelled) return;
barsRef.current = newBars; barsRef.current = newBars;
historyReadyRef.current = newBars.length > 0;
setBarsMarket(market);
setBars(newBars); setBars(newBars);
setLatestBar(newBars[newBars.length - 1] ?? null); setLatestBar(newBars[newBars.length - 1] ?? null);
lastTimeRef.current = newBars[newBars.length - 1]?.time ?? 0; lastTimeRef.current = newBars[newBars.length - 1]?.time ?? 0;
@@ -159,10 +168,11 @@ export function useStompChartData(
const pushBar = (bar: OHLCVBar) => { const pushBar = (bar: OHLCVBar) => {
const current = barsRef.current; const current = barsRef.current;
const notifyChart = historyReadyRef.current;
if (current.length === 0) { if (current.length === 0) {
barsRef.current = [bar]; barsRef.current = [bar];
setLatestBar(bar); setLatestBar(bar);
optsRef.current.onNewCandle(bar); if (notifyChart) optsRef.current.onNewCandle(bar);
lastTimeRef.current = bar.time; lastTimeRef.current = bar.time;
return; return;
} }
@@ -170,16 +180,16 @@ export function useStompChartData(
if (bar.time === last.time) { if (bar.time === last.time) {
barsRef.current[current.length - 1] = bar; barsRef.current[current.length - 1] = bar;
setLatestBar(bar); setLatestBar(bar);
optsRef.current.onTickUpdate(bar); if (notifyChart) optsRef.current.onTickUpdate(bar);
} else if (bar.time > last.time) { } else if (bar.time > last.time) {
barsRef.current = [...current, bar]; barsRef.current = [...current, bar];
setLatestBar(bar); setLatestBar(bar);
optsRef.current.onNewCandle(bar); if (notifyChart) optsRef.current.onNewCandle(bar);
} else { } else {
const patched = { ...bar, time: last.time }; const patched = { ...bar, time: last.time };
barsRef.current[current.length - 1] = patched; barsRef.current[current.length - 1] = patched;
setLatestBar(patched); setLatestBar(patched);
optsRef.current.onTickUpdate(patched); if (notifyChart) optsRef.current.onTickUpdate(patched);
} }
lastTimeRef.current = bar.time; lastTimeRef.current = bar.time;
}; };
@@ -255,12 +265,15 @@ export function useStompChartData(
setWsStatus('connecting'); setWsStatus('connecting');
setError(null); setError(null);
historyReadyRef.current = false;
last1mVolRef.current = 0; last1mVolRef.current = 0;
last1mTimeRef.current = 0; last1mTimeRef.current = 0;
void pinChartWatch(market, STOMP_TICK_CANDLE_TYPE); void pinChartWatch(market, STOMP_TICK_CANDLE_TYPE);
const topic = `/sub/charts/${market}/${STOMP_TICK_CANDLE_TYPE}`; const topic = `/sub/charts/${market}/${STOMP_TICK_CANDLE_TYPE}`;
let alive = true;
const unsub = subscribeStompTopic(topic, msg => { const unsub = subscribeStompTopic(topic, msg => {
if (!alive) return;
setWsStatus('connected'); setWsStatus('connected');
const data = parseStompJson<StompCandlePayload>(msg); const data = parseStompJson<StompCandlePayload>(msg);
if (data?.time == null || typeof data.time !== 'number') return; if (data?.time == null || typeof data.time !== 'number') return;
@@ -270,11 +283,12 @@ export function useStompChartData(
const t = window.setTimeout(() => setWsStatus('connected'), 800); const t = window.setTimeout(() => setWsStatus('connected'), 800);
return () => { return () => {
alive = false;
window.clearTimeout(t); window.clearTimeout(t);
unsub(); unsub();
setWsStatus('disconnected'); setWsStatus('disconnected');
}; };
}, [market, timeframe, opts.enabled, applyCandle]); }, [market, timeframe, opts.enabled, applyCandle]);
return { bars, latestBar, wsStatus, isLoading, error }; return { bars, barsMarket, latestBar, wsStatus, isLoading, error };
} }
+6 -1
View File
@@ -31,6 +31,8 @@ interface UpbitDataOptions {
interface UseUpbitDataReturn { interface UseUpbitDataReturn {
/** 초기 200개 캔들 (심볼/타임프레임 변경 시에만 갱신) */ /** 초기 200개 캔들 (심볼/타임프레임 변경 시에만 갱신) */
bars: OHLCVBar[]; bars: OHLCVBar[];
/** bars 가 어느 종목 기준인지 (로딩 중·전환 직후 null) */
barsMarket: string | null;
/** 현재 진행 중인 캔들의 최신 스냅샷 (UI 표시용) */ /** 현재 진행 중인 캔들의 최신 스냅샷 (UI 표시용) */
latestBar: OHLCVBar | null; latestBar: OHLCVBar | null;
wsStatus: WsStatus; wsStatus: WsStatus;
@@ -56,6 +58,7 @@ export function useUpbitData(
): UseUpbitDataReturn { ): UseUpbitDataReturn {
// bars = 초기 FETCH_COUNT개 (REST), 실시간 수신 시 변경 안 함 → TradingChart setData 재호출 방지 // bars = 초기 FETCH_COUNT개 (REST), 실시간 수신 시 변경 안 함 → TradingChart setData 재호출 방지
const [bars, setBars] = useState<OHLCVBar[]>([]); const [bars, setBars] = useState<OHLCVBar[]>([]);
const [barsMarket, setBarsMarket] = useState<string | null>(null);
const [latestBar, setLatestBar] = useState<OHLCVBar | null>(null); const [latestBar, setLatestBar] = useState<OHLCVBar | null>(null);
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting'); const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -76,6 +79,7 @@ export function useUpbitData(
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setBars([]); setBars([]);
setBarsMarket(null);
setLatestBar(null); setLatestBar(null);
barsRef.current = []; barsRef.current = [];
@@ -92,6 +96,7 @@ export function useUpbitData(
.then(newBars => { .then(newBars => {
if (cancelled) return; if (cancelled) return;
barsRef.current = newBars; barsRef.current = newBars;
setBarsMarket(market);
setBars(newBars); setBars(newBars);
setLatestBar(newBars[newBars.length - 1] ?? null); setLatestBar(newBars[newBars.length - 1] ?? null);
setIsLoading(false); setIsLoading(false);
@@ -169,5 +174,5 @@ export function useUpbitData(
return () => client.destroy(); return () => client.destroy();
}, [market, timeframe, handleMessage, opts.enabled]); }, [market, timeframe, handleMessage, opts.enabled]);
return { bars, latestBar, wsStatus, isLoading, error }; return { bars, barsMarket, latestBar, wsStatus, isLoading, error };
} }
+12 -80
View File
@@ -1,4 +1,4 @@
/* GoldenChart splash — glassmorphism login + 은은한 차트 배경 */ /* GoldenChart splash — glassmorphism login + 차트 콜라주 배경 이미지 */
.splash { .splash {
position: fixed; position: fixed;
@@ -7,104 +7,36 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #0a0c10; background: #06080d;
color: #e8eaed; color: #e8eaed;
overflow: hidden; overflow: hidden;
font-family: var(--font, 'Noto Sans KR', 'Inter', sans-serif); font-family: var(--font, 'Noto Sans KR', 'Inter', sans-serif);
} }
.splash-bg { .splash-photo-bg {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 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; overflow: hidden;
pointer-events: none; pointer-events: none;
transform: translate(3%, -4%) scale(1.08);
transform-origin: 75% 35%;
} }
.splash-chart-bg { .splash-photo-bg::before {
display: block; content: '';
width: 100%; position: absolute;
height: 100%; inset: 0;
opacity: 0.62; 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; position: absolute;
inset: 0; inset: 0;
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;
background: background:
radial-gradient(ellipse 42% 38% at 50% 52%, rgba(10, 12, 16, 0.55) 0%, rgba(10, 12, 16, 0.82) 72%), radial-gradient(ellipse 108% 92% at 50% 50%, transparent 36%, rgba(2, 4, 8, 0.48) 100%),
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 bottom, rgba(2, 4, 8, 0.28) 0%, rgba(2, 4, 8, 0.06) 44%, rgba(2, 4, 8, 0.3) 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);
} }
.splash-center { .splash-center {
+38 -20
View File
@@ -164,6 +164,18 @@ export class ChartManager {
private drawingLines: IPriceLine[] = []; private drawingLines: IPriceLine[] = [];
private _clickHandler: ((p: MouseEventParams<Time>) => void) | null = null; private _clickHandler: ((p: MouseEventParams<Time>) => void) | null = null;
private rawBars: OHLCVBar[] = []; private rawBars: OHLCVBar[] = [];
/** setData 호출 세대 — addIndicator 중 stale rawBars 적용 방지 */
private _dataGeneration = 0;
/** 진행 중인 보조지표 스로틀 갱신 취소 (종목 전환·재로드 시) */
cancelPendingIndicatorUpdates(): void {
if (this._indUpdateTimer) {
clearTimeout(this._indUpdateTimer);
this._indUpdateTimer = null;
}
this._pendingIndUpdate = false;
this._indRunning = false;
}
private currentTheme: Theme = 'dark'; private currentTheme: Theme = 'dark';
private currentChartType: ChartType = 'candlestick'; private currentChartType: ChartType = 'candlestick';
private displayTimezone = DEFAULT_DISPLAY_TIMEZONE; private displayTimezone = DEFAULT_DISPLAY_TIMEZONE;
@@ -258,12 +270,8 @@ export class ChartManager {
setData(bars: OHLCVBar[], chartType: ChartType, theme: Theme): void { setData(bars: OHLCVBar[], chartType: ChartType, theme: Theme): void {
// 전체 재로드 전: 진행 중인 지표 갱신 타이머·플래그를 초기화해 // 전체 재로드 전: 진행 중인 지표 갱신 타이머·플래그를 초기화해
// 이전 rawBars 기반의 stale 계산이 새 지표에 적용되는 것을 방지한다. // 이전 rawBars 기반의 stale 계산이 새 지표에 적용되는 것을 방지한다.
if (this._indUpdateTimer) { this.cancelPendingIndicatorUpdates();
clearTimeout(this._indUpdateTimer); this._dataGeneration += 1;
this._indUpdateTimer = null;
}
this._pendingIndUpdate = false;
this._indRunning = false;
this.rawBars = bars; this.rawBars = bars;
this.currentTheme = theme; this.currentTheme = theme;
@@ -407,6 +415,7 @@ export class ChartManager {
async addIndicator(config: IndicatorConfig): Promise<void> { async addIndicator(config: IndicatorConfig): Promise<void> {
config = enrichIndicatorConfig(config); config = enrichIndicatorConfig(config);
if (this.indicators.has(config.id) || this.rawBars.length === 0) return; if (this.indicators.has(config.id) || this.rawBars.length === 0) return;
const dataGenAtStart = this._dataGeneration;
const indicatorHidden = config.hidden === true; const indicatorHidden = config.hidden === true;
const def = getIndicatorDef(config.type); const def = getIndicatorDef(config.type);
const seriesList: ISeriesApi<SeriesType>[] = []; const seriesList: ISeriesApi<SeriesType>[] = [];
@@ -416,6 +425,8 @@ export class ChartManager {
// 스냅샷 전달: await 중 rawBars 가 변경돼도 이 지표는 일관된 초기 데이터로 setData // 스냅샷 전달: await 중 rawBars 가 변경돼도 이 지표는 일관된 초기 데이터로 setData
const result = await calculateIndicator(config.type, this.rawBars.slice(), config.params); const result = await calculateIndicator(config.type, this.rawBars.slice(), config.params);
if (dataGenAtStart !== this._dataGeneration) return;
if (def?.returnsMarkers && result.markers && result.markers.length > 0) { if (def?.returnsMarkers && result.markers && result.markers.length > 0) {
this.patternMarkers.push({ id: config.id, markers: result.markers }); this.patternMarkers.push({ id: config.id, markers: result.markers });
this._reapplyAllPatternMarkers(); this._reapplyAllPatternMarkers();
@@ -427,10 +438,7 @@ export class ChartManager {
// ─── 일목균형표 특별 처리 ──────────────────────────────────────────── // ─── 일목균형표 특별 처리 ────────────────────────────────────────────
if (config.type === 'IchimokuCloud') { if (config.type === 'IchimokuCloud') {
const cloudPlugin = await this._addIchimokuSeries(result, pane, config, seriesList); const { cloudPlugin, seriesMeta: ichiMeta } = await this._addIchimokuSeries(result, pane, config, seriesList);
// 일목균형표 5라인 plotId: plot0~plot4 순서 고정
const ichiPlotIds = ['plot0', 'plot1', 'plot2', 'plot3', 'plot4'];
const ichiMeta = seriesList.map((_, idx) => ({ plotId: ichiPlotIds[idx] ?? `plot${idx}`, isHistogram: false, color: '' }));
this.indicators.set(config.id, { id: config.id, type: config.type, seriesList, seriesMeta: ichiMeta, paneIndex: pane, alertLines: [], hlineRefs: [], config, cloudPlugin }); this.indicators.set(config.id, { id: config.id, type: config.type, seriesList, seriesMeta: ichiMeta, paneIndex: pane, alertLines: [], hlineRefs: [], config, cloudPlugin });
this.applyIndicatorStyle(config); this.applyIndicatorStyle(config);
return; return;
@@ -717,9 +725,10 @@ export class ChartManager {
pane: number, pane: number,
_config: IndicatorConfig, _config: IndicatorConfig,
seriesList: ISeriesApi<SeriesType>[], seriesList: ISeriesApi<SeriesType>[],
): Promise<IchimokuCloudPlugin> { ): Promise<{ cloudPlugin: IchimokuCloudPlugin; seriesMeta: IndicatorEntry['seriesMeta'] }> {
const displacement = (_config.params?.displacement as number) ?? 26; const displacement = (_config.params?.displacement as number) ?? 26;
const laggingPeriod = (_config.params?.laggingSpan2Periods as number) ?? 52; const laggingPeriod = (_config.params?.laggingSpan2Periods as number) ?? 52;
const seriesMeta: IndicatorEntry['seriesMeta'] = [];
const plotInfo = [ const plotInfo = [
{ id: 'plot0', title: getIchimokuPlotTitle('plot0'), color: '#26c6da', lastVal: true }, { id: 'plot0', title: getIchimokuPlotTitle('plot0'), color: '#26c6da', lastVal: true },
@@ -763,6 +772,7 @@ export class ChartManager {
}, pane); }, pane);
series.setData(seriesData); series.setData(seriesData);
seriesList.push(series); seriesList.push(series);
seriesMeta.push({ plotId: info.id, isHistogram: false, color: info.color });
if (info.id === 'plot3') spanASeriesRef = series; if (info.id === 'plot3') spanASeriesRef = series;
} }
@@ -775,7 +785,7 @@ export class ChartManager {
host.attachPrimitive(cloudPlugin); host.attachPrimitive(cloudPlugin);
this._applyIchimokuCloudPlugin(cloudPlugin, result.plots, _config); this._applyIchimokuCloudPlugin(cloudPlugin, result.plots, _config);
} }
return cloudPlugin; return { cloudPlugin, seriesMeta };
} }
/** 선행스팬A의 미래 26봉: (전환선 + 기준선) / 2, 미래 타임스탬프로 매핑 */ /** 선행스팬A의 미래 26봉: (전환선 + 기준선) / 2, 미래 타임스탬프로 매핑 */
@@ -989,20 +999,24 @@ export class ChartManager {
/** 모든 보조지표 시리즈의 마지막 데이터 포인트를 현재 rawBars 기준으로 재계산·갱신 */ /** 모든 보조지표 시리즈의 마지막 데이터 포인트를 현재 rawBars 기준으로 재계산·갱신 */
private async _updateIndicatorLastPoints(): Promise<void> { private async _updateIndicatorLastPoints(): Promise<void> {
if (this.rawBars.length === 0) return; if (this.rawBars.length === 0) return;
const dataGenAtStart = this._dataGeneration;
// 루프 시작 전 스냅샷 고정: 루프 실행 중 rawBars 가 변경돼도 모든 지표가 // 루프 시작 전 스냅샷 고정: 루프 실행 중 rawBars 가 변경돼도 모든 지표가
// 동일한 데이터 기준으로 계산되어 지표 간 시각적 불일치를 방지한다. // 동일한 데이터 기준으로 계산되어 지표 간 시각적 불일치를 방지한다.
const barsSnapshot = this.rawBars.slice(); const barsSnapshot = this.rawBars.slice();
for (const [, entry] of this.indicators.entries()) { for (const [, entry] of this.indicators.entries()) {
if (dataGenAtStart !== this._dataGeneration) return;
if (!entry.config || entry.seriesList.length === 0) continue; if (!entry.config || entry.seriesList.length === 0) continue;
try { try {
const { plots } = await calculateIndicator( const { plots } = await calculateIndicator(
entry.config.type, barsSnapshot, entry.config.params ?? {} entry.config.type, barsSnapshot, entry.config.params ?? {}
); );
// 일목균형표: 구름 플러그인 + 미래 프로젝션 라인 모두 갱신 if (dataGenAtStart !== this._dataGeneration) return;
if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) { // 일목균형표: 선행스팬 미래 구간 포함 전체 setData (틱 갱신 시에도 유지)
this._applyIchimokuCloudPlugin(entry.cloudPlugin, plots, entry.config); if (entry.type === 'IchimokuCloud') {
this._refreshIchimokuSeriesData(entry, plots);
continue;
} }
this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ false); this._applyLastPointsToSeries(entry, plots, /* updateFutureSpans */ false);
} catch { /* 계산 실패 무시 */ } } catch { /* 계산 실패 무시 */ }
@@ -1134,15 +1148,12 @@ export class ChartManager {
*/ */
async appendBar(bar: OHLCVBar): Promise<void> { async appendBar(bar: OHLCVBar): Promise<void> {
if (!this.mainSeries) return; if (!this.mainSeries) return;
const dataGenAtStart = this._dataGeneration;
const t = getTheme(this.currentTheme); const t = getTheme(this.currentTheme);
// 새 캔들 추가 시 이 함수 내부에서 모든 지표를 직접 재계산하므로, // 새 캔들 추가 시 이 함수 내부에서 모든 지표를 직접 재계산하므로,
// updateBar 가 예약한 스로틀 타이머를 취소해 중복 계산을 방지합니다. // updateBar 가 예약한 스로틀 타이머를 취소해 중복 계산을 방지합니다.
if (this._indUpdateTimer) { this.cancelPendingIndicatorUpdates();
clearTimeout(this._indUpdateTimer);
this._indUpdateTimer = null;
this._pendingIndUpdate = false;
}
// rawBars에 신규 바 추가 (중복 방지) // rawBars에 신규 바 추가 (중복 방지)
const last = this.rawBars[this.rawBars.length - 1]; const last = this.rawBars[this.rawBars.length - 1];
@@ -1181,11 +1192,13 @@ export class ChartManager {
// rawBars 스냅샷을 찍어 루프 도중 rawBars 가 변경돼도 일관된 계산 보장 // rawBars 스냅샷을 찍어 루프 도중 rawBars 가 변경돼도 일관된 계산 보장
const barsSnapshot = this.rawBars.slice(); const barsSnapshot = this.rawBars.slice();
for (const [, entry] of this.indicators.entries()) { for (const [, entry] of this.indicators.entries()) {
if (dataGenAtStart !== this._dataGeneration) return;
if (!entry.config || entry.seriesList.length === 0) continue; if (!entry.config || entry.seriesList.length === 0) continue;
try { try {
const { plots } = await calculateIndicator( const { plots } = await calculateIndicator(
entry.config.type, barsSnapshot, entry.config.params ?? {} entry.config.type, barsSnapshot, entry.config.params ?? {}
); );
if (dataGenAtStart !== this._dataGeneration) return;
// 일목균형표: 구름 플러그인 + SpanA/B 미래 라인 갱신 // 일목균형표: 구름 플러그인 + SpanA/B 미래 라인 갱신
if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) { if (entry.type === 'IchimokuCloud' && entry.cloudPlugin) {
this._applyIchimokuCloudPlugin(entry.cloudPlugin, plots, entry.config); this._applyIchimokuCloudPlugin(entry.cloudPlugin, plots, entry.config);
@@ -2356,6 +2369,11 @@ export class ChartManager {
return this.mainSeries !== null && this.rawBars.length > 0; return this.mainSeries !== null && this.rawBars.length > 0;
} }
/** 로드된 보조지표 수 (종목 전환 후 캔들만 그려진 상태 감지용) */
getIndicatorCount(): number {
return this.indicators.size;
}
/** 현재 로드된 rawBars 개수 반환 (데이터 로드 여부 확인용) */ /** 현재 로드된 rawBars 개수 반환 (데이터 로드 여부 확인용) */
getRawBarsLength(): number { getRawBarsLength(): number {
return this.rawBars.length; return this.rawBars.length;