종목변경 시 캔들차트 사라지는 현상 수정
This commit is contained in:
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user