알림 시간봉 오류 수정
This commit is contained in:
@@ -5,6 +5,7 @@ import com.goldenchart.entity.GcWatchlist;
|
|||||||
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.LiveStrategySettingsService;
|
||||||
import com.goldenchart.service.StrategyConditionTimeframeService;
|
import com.goldenchart.service.StrategyConditionTimeframeService;
|
||||||
import com.goldenchart.websocket.DynamicSubscriptionManager;
|
import com.goldenchart.websocket.DynamicSubscriptionManager;
|
||||||
import com.goldenchart.websocket.UpbitWebSocketClient;
|
import com.goldenchart.websocket.UpbitWebSocketClient;
|
||||||
@@ -27,11 +28,13 @@ public class LiveStrategyStartupRunner implements ApplicationRunner {
|
|||||||
private final GcWatchlistRepository watchlistRepo;
|
private final GcWatchlistRepository watchlistRepo;
|
||||||
private final DynamicSubscriptionManager subscriptionManager;
|
private final DynamicSubscriptionManager subscriptionManager;
|
||||||
private final LiveStrategyEvaluator liveStrategyEvaluator;
|
private final LiveStrategyEvaluator liveStrategyEvaluator;
|
||||||
|
private final LiveStrategySettingsService liveStrategySettingsService;
|
||||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||||
private final UpbitWebSocketClient upbitWebSocketClient;
|
private final UpbitWebSocketClient upbitWebSocketClient;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run(ApplicationArguments args) {
|
public void run(ApplicationArguments args) {
|
||||||
|
liveStrategySettingsService.reconcileOrphanAndStaleSettings();
|
||||||
// 디바이스별 전역 설정이 켜져 있고 전략이 지정된 경우, DB 관심종목 전체 고정 구독
|
// 디바이스별 전역 설정이 켜져 있고 전략이 지정된 경우, DB 관심종목 전체 고정 구독
|
||||||
int pinned = 0;
|
int pinned = 0;
|
||||||
for (GcWatchlist w : watchlistRepo.findAll()) {
|
for (GcWatchlist w : watchlistRepo.findAll()) {
|
||||||
|
|||||||
@@ -44,4 +44,11 @@ public class CandleBarDto {
|
|||||||
|
|
||||||
/** 시그널이 발생한 평가 분봉 (예: 1m, 3m, 5m) */
|
/** 시그널이 발생한 평가 분봉 (예: 1m, 3m, 5m) */
|
||||||
private String candleType;
|
private String candleType;
|
||||||
|
|
||||||
|
/** 시그널 소유 — STOMP 수신 클라이언트가 본인 알림만 표시할 때 사용 */
|
||||||
|
private Long strategyId;
|
||||||
|
private Long userId;
|
||||||
|
private String deviceId;
|
||||||
|
/** CANDLE_CLOSE | REALTIME_TICK */
|
||||||
|
private String executionType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public interface GcTradeSignalRepository extends JpaRepository<GcTradeSignal, Lo
|
|||||||
List<GcTradeSignal> findByDeviceIdOrderByCreatedAtDesc(String deviceId);
|
List<GcTradeSignal> findByDeviceIdOrderByCreatedAtDesc(String deviceId);
|
||||||
List<GcTradeSignal> findByUserIdOrderByCreatedAtDesc(Long userId);
|
List<GcTradeSignal> findByUserIdOrderByCreatedAtDesc(Long userId);
|
||||||
List<GcTradeSignal> findByDeviceIdAndMarketOrderByCreatedAtDesc(String deviceId, String market);
|
List<GcTradeSignal> findByDeviceIdAndMarketOrderByCreatedAtDesc(String deviceId, String market);
|
||||||
|
List<GcTradeSignal> findByUserIdAndMarketOrderByCreatedAtDesc(Long userId, String market);
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query("DELETE FROM GcTradeSignal e WHERE e.userId = :userId")
|
@Query("DELETE FROM GcTradeSignal e WHERE e.userId = :userId")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.goldenchart.entity.GcAppSettings;
|
|||||||
import com.goldenchart.entity.GcLiveStrategySettings;
|
import com.goldenchart.entity.GcLiveStrategySettings;
|
||||||
import com.goldenchart.entity.GcWatchlist;
|
import com.goldenchart.entity.GcWatchlist;
|
||||||
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
import com.goldenchart.repository.GcLiveStrategySettingsRepository;
|
||||||
|
import com.goldenchart.repository.GcStrategyRepository;
|
||||||
import com.goldenchart.repository.GcWatchlistRepository;
|
import com.goldenchart.repository.GcWatchlistRepository;
|
||||||
import com.goldenchart.websocket.DynamicSubscriptionManager;
|
import com.goldenchart.websocket.DynamicSubscriptionManager;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -26,6 +27,7 @@ import java.util.Optional;
|
|||||||
public class LiveStrategySettingsService {
|
public class LiveStrategySettingsService {
|
||||||
|
|
||||||
private final GcLiveStrategySettingsRepository repo;
|
private final GcLiveStrategySettingsRepository repo;
|
||||||
|
private final GcStrategyRepository strategyRepo;
|
||||||
private final GcWatchlistRepository watchlistRepo;
|
private final GcWatchlistRepository watchlistRepo;
|
||||||
private final AppSettingsService appSettingsService;
|
private final AppSettingsService appSettingsService;
|
||||||
private final DynamicSubscriptionManager subscriptionManager;
|
private final DynamicSubscriptionManager subscriptionManager;
|
||||||
@@ -35,10 +37,11 @@ public class LiveStrategySettingsService {
|
|||||||
|
|
||||||
// ── 조회 ──────────────────────────────────────────────────────────────────
|
// ── 조회 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional
|
||||||
public LiveStrategySettingsDto get(Long userId, String deviceId, String market) {
|
public LiveStrategySettingsDto get(Long userId, String deviceId, String market) {
|
||||||
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
GcAppSettings app = appSettingsService.getEntity(userId, deviceId);
|
||||||
Optional<GcLiveStrategySettings> entity = findEntity(userId, deviceId, market);
|
Optional<GcLiveStrategySettings> entity = findEntity(userId, deviceId, market);
|
||||||
|
entity.ifPresent(this::reconcileOneRow);
|
||||||
LiveStrategySettingsDto dto = entity.isPresent()
|
LiveStrategySettingsDto dto = entity.isPresent()
|
||||||
? toDto(entity.get())
|
? toDto(entity.get())
|
||||||
: defaultDtoFromApp(market, app);
|
: defaultDtoFromApp(market, app);
|
||||||
@@ -48,12 +51,15 @@ public class LiveStrategySettingsService {
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional
|
||||||
public List<LiveStrategySettingsDto> listActive(Long userId, String deviceId) {
|
public List<LiveStrategySettingsDto> listActive(Long userId, String deviceId) {
|
||||||
List<GcLiveStrategySettings> rows = userId != null
|
List<GcLiveStrategySettings> rows = userId != null
|
||||||
? repo.findByUserIdAndIsLiveCheckTrue(userId)
|
? repo.findByUserIdAndIsLiveCheckTrue(userId)
|
||||||
: repo.findByDeviceIdAndIsLiveCheckTrue(
|
: repo.findByDeviceIdAndIsLiveCheckTrue(
|
||||||
deviceId != null && !deviceId.isBlank() ? deviceId : "anonymous");
|
deviceId != null && !deviceId.isBlank() ? deviceId : "anonymous");
|
||||||
|
for (GcLiveStrategySettings row : rows) {
|
||||||
|
reconcileOneRow(row);
|
||||||
|
}
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
@@ -116,6 +122,61 @@ public class LiveStrategySettingsService {
|
|||||||
|
|
||||||
// ── 저장/갱신 — 전역 템플릿 + 관심종목 전체 동기화 ─────────────────────────
|
// ── 저장/갱신 — 전역 템플릿 + 관심종목 전체 동기화 ─────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기동·설정 조회 시 — strategy_id 누락·삭제 전략·회원 전역 전략 불일치 보정.
|
||||||
|
*
|
||||||
|
* @return 수정된 행 수
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int reconcileOrphanAndStaleSettings() {
|
||||||
|
int fixed = 0;
|
||||||
|
for (GcLiveStrategySettings row : repo.findAllByIsLiveCheckTrue()) {
|
||||||
|
if (reconcileOneRow(row)) {
|
||||||
|
fixed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fixed > 0) {
|
||||||
|
log.info("[LiveStrategySettings] reconcile fixed {} active row(s)", fixed);
|
||||||
|
}
|
||||||
|
return fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean reconcileOneRow(GcLiveStrategySettings row) {
|
||||||
|
boolean changed = false;
|
||||||
|
GcAppSettings app = appSettingsService.getEntity(row.getUserId(), row.getDeviceId());
|
||||||
|
|
||||||
|
if (row.getStrategyId() == null) {
|
||||||
|
row.setIsLiveCheck(false);
|
||||||
|
changed = true;
|
||||||
|
} else if (!strategyRepo.existsById(row.getStrategyId())) {
|
||||||
|
if (row.getUserId() != null
|
||||||
|
&& Boolean.TRUE.equals(app.getLiveStrategyCheck())
|
||||||
|
&& app.getLiveStrategyId() != null
|
||||||
|
&& strategyRepo.existsById(app.getLiveStrategyId())) {
|
||||||
|
row.setStrategyId(app.getLiveStrategyId());
|
||||||
|
} else {
|
||||||
|
row.setIsLiveCheck(false);
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
} else if (row.getUserId() != null
|
||||||
|
&& Boolean.TRUE.equals(app.getLiveStrategyCheck())
|
||||||
|
&& app.getLiveStrategyId() != null
|
||||||
|
&& !app.getLiveStrategyId().equals(row.getStrategyId())
|
||||||
|
&& strategyRepo.existsById(app.getLiveStrategyId())) {
|
||||||
|
row.setStrategyId(app.getLiveStrategyId());
|
||||||
|
conditionTimeframes.invalidate(app.getLiveStrategyId());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
repo.save(row);
|
||||||
|
if (row.getMarket() != null) {
|
||||||
|
liveStrategyEvaluator.invalidateCache(row.getMarket());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
public LiveStrategySettingsDto save(Long userId, String deviceId,
|
public LiveStrategySettingsDto save(Long userId, String deviceId,
|
||||||
LiveStrategySettingsDto dto) {
|
LiveStrategySettingsDto dto) {
|
||||||
if (!Boolean.TRUE.equals(dto.getSkipGlobalTemplate())) {
|
if (!Boolean.TRUE.equals(dto.getSkipGlobalTemplate())) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.goldenchart.service;
|
|||||||
|
|
||||||
import com.goldenchart.dto.TradeSignalDto;
|
import com.goldenchart.dto.TradeSignalDto;
|
||||||
import com.goldenchart.entity.GcTradeSignal;
|
import com.goldenchart.entity.GcTradeSignal;
|
||||||
|
import com.goldenchart.repository.GcStrategyRepository;
|
||||||
import com.goldenchart.repository.GcTradeSignalRepository;
|
import com.goldenchart.repository.GcTradeSignalRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -22,6 +23,7 @@ import java.util.List;
|
|||||||
public class TradeSignalService {
|
public class TradeSignalService {
|
||||||
|
|
||||||
private final GcTradeSignalRepository repo;
|
private final GcTradeSignalRepository repo;
|
||||||
|
private final GcStrategyRepository strategyRepo;
|
||||||
private final FcmPushService fcmPushService;
|
private final FcmPushService fcmPushService;
|
||||||
|
|
||||||
// ── 저장 ─────────────────────────────────────────────────────────────────
|
// ── 저장 ─────────────────────────────────────────────────────────────────
|
||||||
@@ -31,12 +33,13 @@ public class TradeSignalService {
|
|||||||
String signalType, double price,
|
String signalType, double price,
|
||||||
long candleTime, String candleType,
|
long candleTime, String candleType,
|
||||||
String executionType) {
|
String executionType) {
|
||||||
|
String resolvedName = resolveStrategyName(strategyId, strategyName);
|
||||||
GcTradeSignal entity = GcTradeSignal.builder()
|
GcTradeSignal entity = GcTradeSignal.builder()
|
||||||
.deviceId(deviceId)
|
.deviceId(deviceId)
|
||||||
.userId(userId)
|
.userId(userId)
|
||||||
.market(market)
|
.market(market)
|
||||||
.strategyId(strategyId)
|
.strategyId(strategyId)
|
||||||
.strategyName(strategyName)
|
.strategyName(resolvedName)
|
||||||
.signalType(signalType)
|
.signalType(signalType)
|
||||||
.price(price)
|
.price(price)
|
||||||
.candleTime(candleTime)
|
.candleTime(candleTime)
|
||||||
@@ -49,7 +52,7 @@ public class TradeSignalService {
|
|||||||
// 로그인 사용자는 live 설정에 userId만 있고 deviceId가 null — FCM은 userId 기준으로 발송
|
// 로그인 사용자는 live 설정에 userId만 있고 deviceId가 null — FCM은 userId 기준으로 발송
|
||||||
if (userId != null || (deviceId != null && !deviceId.isBlank())) {
|
if (userId != null || (deviceId != null && !deviceId.isBlank())) {
|
||||||
fcmPushService.sendTradeSignalIfEnabled(
|
fcmPushService.sendTradeSignalIfEnabled(
|
||||||
deviceId, userId, market, signalType, price, strategyName, saved.getId());
|
deviceId, userId, market, signalType, price, resolvedName, saved.getId());
|
||||||
}
|
}
|
||||||
return toDto(saved);
|
return toDto(saved);
|
||||||
}
|
}
|
||||||
@@ -66,9 +69,11 @@ public class TradeSignalService {
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<TradeSignalDto> listByMarket(Long userId, String deviceId, String market) {
|
public List<TradeSignalDto> listByMarket(Long userId, String deviceId, String market) {
|
||||||
return repo.findByDeviceIdAndMarketOrderByCreatedAtDesc(
|
List<GcTradeSignal> list = userId != null
|
||||||
deviceId != null ? deviceId : "", market)
|
? repo.findByUserIdAndMarketOrderByCreatedAtDesc(userId, market)
|
||||||
.stream().map(this::toDto).toList();
|
: repo.findByDeviceIdAndMarketOrderByCreatedAtDesc(
|
||||||
|
deviceId != null ? deviceId : "", market);
|
||||||
|
return list.stream().map(this::toDto).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 삭제 ─────────────────────────────────────────────────────────────────
|
// ── 삭제 ─────────────────────────────────────────────────────────────────
|
||||||
@@ -117,6 +122,14 @@ public class TradeSignalService {
|
|||||||
return deviceId.equals(entity.getDeviceId());
|
return deviceId.equals(entity.getDeviceId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String resolveStrategyName(Long strategyId, String strategyName) {
|
||||||
|
if (strategyName != null && !strategyName.isBlank()) return strategyName;
|
||||||
|
if (strategyId == null) return null;
|
||||||
|
return strategyRepo.findById(strategyId)
|
||||||
|
.map(s -> s.getName())
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 변환 ─────────────────────────────────────────────────────────────────
|
// ── 변환 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private TradeSignalDto toDto(GcTradeSignal e) {
|
private TradeSignalDto toDto(GcTradeSignal e) {
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ public class BarBuilder {
|
|||||||
|
|
||||||
if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) continue;
|
if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) continue;
|
||||||
|
|
||||||
publishStrategySignal(market, candleType, signalBar, closeSignal);
|
publishStrategySignal(market, candleType, signalBar, closeSignal, s, signalExecType);
|
||||||
try {
|
try {
|
||||||
tradeSignalService.save(
|
tradeSignalService.save(
|
||||||
s.getDeviceId(), s.getUserId(),
|
s.getDeviceId(), s.getUserId(),
|
||||||
@@ -216,8 +216,9 @@ public class BarBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 평가 분봉 STOMP 채널로 봉 마감 시그널 발행 */
|
/** 평가 분봉 STOMP 채널로 봉 마감 시그널 발행 (소유자·전략 메타 포함) */
|
||||||
private void publishStrategySignal(String market, String candleType, Bar bar, String signal) {
|
private void publishStrategySignal(String market, String candleType, Bar bar, String signal,
|
||||||
|
GcLiveStrategySettings setting, String executionType) {
|
||||||
long barStartEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
|
long barStartEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds();
|
||||||
CandleBarDto dto = CandleBarDto.builder()
|
CandleBarDto dto = CandleBarDto.builder()
|
||||||
.time(barStartEpoch)
|
.time(barStartEpoch)
|
||||||
@@ -228,9 +229,14 @@ public class BarBuilder {
|
|||||||
.volume(bar.getVolume().doubleValue())
|
.volume(bar.getVolume().doubleValue())
|
||||||
.signal(signal)
|
.signal(signal)
|
||||||
.candleType(candleType)
|
.candleType(candleType)
|
||||||
|
.strategyId(setting.getStrategyId())
|
||||||
|
.userId(setting.getUserId())
|
||||||
|
.deviceId(setting.getDeviceId())
|
||||||
|
.executionType(executionType)
|
||||||
.build();
|
.build();
|
||||||
broker.publish(market, candleType, dto);
|
broker.publish(market, candleType, dto);
|
||||||
log.info("[BarBuilder] bar-close signal={} market={} candleType={}", signal, market, candleType);
|
log.info("[BarBuilder] bar-close signal={} market={} candleType={} strategyId={} userId={}",
|
||||||
|
signal, market, candleType, setting.getStrategyId(), setting.getUserId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 현재 진행 중인 1분봉을 STOMP 로 발행 (실시간 틱 렌더링용, 시그널 없음). */
|
/** 현재 진행 중인 1분봉을 STOMP 로 발행 (실시간 틱 렌더링용, 시그널 없음). */
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ public class LiveStrategyScheduler {
|
|||||||
.volume(lastBar.getVolume().doubleValue())
|
.volume(lastBar.getVolume().doubleValue())
|
||||||
.signal(signal)
|
.signal(signal)
|
||||||
.candleType(candleType)
|
.candleType(candleType)
|
||||||
|
.strategyId(s.getStrategyId())
|
||||||
|
.userId(s.getUserId())
|
||||||
|
.deviceId(s.getDeviceId())
|
||||||
|
.executionType("REALTIME_TICK")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
broker.publish(market, candleType, dto);
|
broker.publish(market, candleType, dto);
|
||||||
|
|||||||
@@ -838,8 +838,12 @@ function App() {
|
|||||||
const filtered = activeLiveSettings.filter(s => monitoredMarkets.includes(s.market));
|
const filtered = activeLiveSettings.filter(s => monitoredMarkets.includes(s.market));
|
||||||
if (filtered.length > 0) return expandLiveStrategySubscriptions(filtered);
|
if (filtered.length > 0) return expandLiveStrategySubscriptions(filtered);
|
||||||
if (marketSubscriptions.length > 0) return marketSubscriptions;
|
if (marketSubscriptions.length > 0) return marketSubscriptions;
|
||||||
return monitoredMarkets.map(m => ({ market: m, candleType: '1m' }));
|
const types = liveStrategyCandleTypes?.length ? liveStrategyCandleTypes : [];
|
||||||
}, [monitoredMarkets, activeLiveSettings, marketSubscriptions]);
|
if (types.length > 0) {
|
||||||
|
return monitoredMarkets.flatMap(m => types.map(candleType => ({ market: m, candleType })));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [monitoredMarkets, activeLiveSettings, marketSubscriptions, liveStrategyCandleTypes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (monitoredMarkets.length === 0) {
|
if (monitoredMarkets.length === 0) {
|
||||||
@@ -1683,6 +1687,7 @@ function App() {
|
|||||||
popupEnabled={appDefaults.tradeAlertPopup ?? true}
|
popupEnabled={appDefaults.tradeAlertPopup ?? true}
|
||||||
soundEnabled={appDefaults.tradeAlertSoundEnabled ?? true}
|
soundEnabled={appDefaults.tradeAlertSoundEnabled ?? true}
|
||||||
soundId={appDefaults.tradeAlertSound ?? 'bell'}
|
soundId={appDefaults.tradeAlertSound ?? 'bell'}
|
||||||
|
settingsSessionKey={authUser?.userId ?? 0}
|
||||||
>
|
>
|
||||||
<VerificationIssueNotificationProvider
|
<VerificationIssueNotificationProvider
|
||||||
enabled={canMenu('verification-board')}
|
enabled={canMenu('verification-board')}
|
||||||
@@ -1696,13 +1701,6 @@ function App() {
|
|||||||
activeMarket={symbol}
|
activeMarket={symbol}
|
||||||
enabled={monitoredMarkets.length > 0}
|
enabled={monitoredMarkets.length > 0}
|
||||||
popupEnabled={appDefaults.tradeAlertPopup ?? true}
|
popupEnabled={appDefaults.tradeAlertPopup ?? true}
|
||||||
strategyId={liveStrategySettings.strategyId ?? undefined}
|
|
||||||
executionType={liveStrategySettings.executionType ?? 'CANDLE_CLOSE'}
|
|
||||||
strategyName={liveStrategyName}
|
|
||||||
strategyNameByMarket={liveStrategyNameByMarket}
|
|
||||||
executionTypeByMarket={Object.fromEntries(
|
|
||||||
activeLiveSettings.map(s => [s.market, s.executionType ?? 'CANDLE_CLOSE']),
|
|
||||||
)}
|
|
||||||
chartMarkersActive={menuPage === 'chart'}
|
chartMarkersActive={menuPage === 'chart'}
|
||||||
onMarkersChange={markers => {
|
onMarkersChange={markers => {
|
||||||
managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice);
|
managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice);
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ interface Props {
|
|||||||
activeMarket: string;
|
activeMarket: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
popupEnabled: boolean;
|
popupEnabled: boolean;
|
||||||
strategyId: number | undefined;
|
|
||||||
executionType: string;
|
|
||||||
strategyName: string | null;
|
|
||||||
strategyNameByMarket?: Record<string, string>;
|
|
||||||
executionTypeByMarket?: Record<string, string>;
|
|
||||||
onMarkersChange: (markers: LiveMarker[]) => void;
|
onMarkersChange: (markers: LiveMarker[]) => void;
|
||||||
/** 차트 화면일 때만 마커를 차트에 반영 */
|
/** 차트 화면일 때만 마커를 차트에 반영 */
|
||||||
chartMarkersActive: boolean;
|
chartMarkersActive: boolean;
|
||||||
@@ -32,10 +27,6 @@ export const LiveSignalNotifier = forwardRef<LiveSignalNotifierHandle, Props>(fu
|
|||||||
activeMarket,
|
activeMarket,
|
||||||
enabled,
|
enabled,
|
||||||
popupEnabled: _popupEnabled,
|
popupEnabled: _popupEnabled,
|
||||||
strategyName,
|
|
||||||
executionType,
|
|
||||||
strategyNameByMarket,
|
|
||||||
executionTypeByMarket,
|
|
||||||
onMarkersChange,
|
onMarkersChange,
|
||||||
chartMarkersActive,
|
chartMarkersActive,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
@@ -63,11 +54,10 @@ export const LiveSignalNotifier = forwardRef<LiveSignalNotifierHandle, Props>(fu
|
|||||||
signalType: marker.signal,
|
signalType: marker.signal,
|
||||||
price: marker.price,
|
price: marker.price,
|
||||||
candleTime: marker.time,
|
candleTime: marker.time,
|
||||||
strategyName: strategyNameByMarket?.[marker.market] ?? strategyName,
|
strategyId: marker.strategyId ?? undefined,
|
||||||
executionType: executionTypeByMarket?.[marker.market] ?? executionType,
|
executionType: marker.executionType ?? undefined,
|
||||||
candleType: marker.candleType ?? '1m',
|
candleType: marker.candleType ?? '1m',
|
||||||
});
|
});
|
||||||
// STOMP만 수신·DB 저장 누락 시 배지 동기화
|
|
||||||
window.setTimeout(() => { void refreshHistory(); }, 800);
|
window.setTimeout(() => { void refreshHistory(); }, 800);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
|||||||
}
|
}
|
||||||
for (const n of prev) {
|
for (const n of prev) {
|
||||||
if (hidden.has(n.id)) continue;
|
if (hidden.has(n.id)) continue;
|
||||||
|
// 서버 이력에 없는 STOMP-only 항목은 세션 전환 후 제거 (serverOnly가 아닐 때만 병합)
|
||||||
if (!map.has(n.id)) map.set(n.id, n);
|
if (!map.has(n.id)) map.set(n.id, n);
|
||||||
else if (n.isRead) map.set(n.id, { ...map.get(n.id)!, isRead: true });
|
else if (n.isRead) map.set(n.id, { ...map.get(n.id)!, isRead: true });
|
||||||
}
|
}
|
||||||
@@ -213,6 +214,7 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settingsLoaded) return;
|
if (!settingsLoaded) return;
|
||||||
|
setToastNotifications([]);
|
||||||
void refreshHistory({ serverOnly: true, rawServerList: true });
|
void refreshHistory({ serverOnly: true, rawServerList: true });
|
||||||
}, [refreshHistory, settingsLoaded, settingsSessionKey]);
|
}, [refreshHistory, settingsLoaded, settingsSessionKey]);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { parseStompJson } from '../utils/stompMessage';
|
import { parseStompJson } from '../utils/stompMessage';
|
||||||
import { subscribeStompTopic } from '../utils/stompChartBroker';
|
import { subscribeStompTopic } from '../utils/stompChartBroker';
|
||||||
|
import { signalBelongsToCurrentSession } from '../utils/tradeSignalOwnership';
|
||||||
|
|
||||||
export interface LiveMarker {
|
export interface LiveMarker {
|
||||||
time: number;
|
time: number;
|
||||||
@@ -16,6 +17,10 @@ export interface LiveMarker {
|
|||||||
price: number;
|
price: number;
|
||||||
market: string;
|
market: string;
|
||||||
candleType: string;
|
candleType: string;
|
||||||
|
strategyId?: number | null;
|
||||||
|
userId?: number | null;
|
||||||
|
deviceId?: string | null;
|
||||||
|
executionType?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarketCandleSub {
|
export interface MarketCandleSub {
|
||||||
@@ -57,9 +62,17 @@ export function useLiveStrategyMarkers({
|
|||||||
|
|
||||||
const pushMarker = useCallback((market: string, candleType: string, data: {
|
const pushMarker = useCallback((market: string, candleType: string, data: {
|
||||||
time: number; close: number; signal: string;
|
time: number; close: number; signal: string;
|
||||||
|
strategyId?: number | null;
|
||||||
|
userId?: number | null;
|
||||||
|
deviceId?: string | null;
|
||||||
|
executionType?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
if (data.signal !== 'BUY' && data.signal !== 'SELL') return;
|
if (data.signal !== 'BUY' && data.signal !== 'SELL') return;
|
||||||
|
|
||||||
|
if (!signalBelongsToCurrentSession({ userId: data.userId, deviceId: data.deviceId })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const key = `${market}:${candleType}:${data.time}:${data.signal}`;
|
const key = `${market}:${candleType}:${data.time}:${data.signal}`;
|
||||||
if (seenRef.current.has(key)) return;
|
if (seenRef.current.has(key)) return;
|
||||||
seenRef.current.add(key);
|
seenRef.current.add(key);
|
||||||
@@ -70,6 +83,10 @@ export function useLiveStrategyMarkers({
|
|||||||
price: data.close,
|
price: data.close,
|
||||||
market,
|
market,
|
||||||
candleType,
|
candleType,
|
||||||
|
strategyId: data.strategyId,
|
||||||
|
userId: data.userId,
|
||||||
|
deviceId: data.deviceId,
|
||||||
|
executionType: data.executionType,
|
||||||
};
|
};
|
||||||
|
|
||||||
const prev = markersByMarketRef.current[market] ?? [];
|
const prev = markersByMarketRef.current[market] ?? [];
|
||||||
@@ -120,6 +137,10 @@ export function useLiveStrategyMarkers({
|
|||||||
close: number;
|
close: number;
|
||||||
signal?: string;
|
signal?: string;
|
||||||
candleType?: string;
|
candleType?: string;
|
||||||
|
strategyId?: number | null;
|
||||||
|
userId?: number | null;
|
||||||
|
deviceId?: string | null;
|
||||||
|
executionType?: string | null;
|
||||||
}>(msg);
|
}>(msg);
|
||||||
if (!data?.signal || data.signal === 'NONE') return;
|
if (!data?.signal || data.signal === 'NONE') return;
|
||||||
const resolvedCandleType = data.candleType ?? ct;
|
const resolvedCandleType = data.candleType ?? ct;
|
||||||
@@ -127,6 +148,10 @@ export function useLiveStrategyMarkers({
|
|||||||
time: data.time,
|
time: data.time,
|
||||||
close: data.close,
|
close: data.close,
|
||||||
signal: data.signal,
|
signal: data.signal,
|
||||||
|
strategyId: data.strategyId,
|
||||||
|
userId: data.userId,
|
||||||
|
deviceId: data.deviceId,
|
||||||
|
executionType: data.executionType,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ export function updateStartCandleTypes(
|
|||||||
candleTypes: string[],
|
candleTypes: string[],
|
||||||
): EditorConditionState {
|
): EditorConditionState {
|
||||||
const types = normalizeCandleTypesList(candleTypes);
|
const types = normalizeCandleTypesList(candleTypes);
|
||||||
const primary = types[0];
|
// 조건 노드 left/rightCandleType 동기화는 "대표 분봉"에만 사용되므로,
|
||||||
|
// multi 분봉에서 stale 1m 이 앞에 오는 경우 1m 로 동기화되지 않도록 non-1m 을 우선한다.
|
||||||
|
const primary = types.find(ct => normalizeStartCandleType(ct) !== '1m') ?? types[0];
|
||||||
const nextMeta = {
|
const nextMeta = {
|
||||||
...state.startMeta,
|
...state.startMeta,
|
||||||
[startId]: { candleTypes: types, candleType: primary },
|
[startId]: { candleTypes: types, candleType: primary },
|
||||||
@@ -197,7 +199,13 @@ function normalizeCandleTypesList(types: string[]): string[] {
|
|||||||
out.push(ct);
|
out.push(ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out.length > 0 ? out : [DEFAULT_START_CANDLE];
|
if (out.length === 0) return [DEFAULT_START_CANDLE];
|
||||||
|
// 레거시/불일치로 multi 분봉에 stale 1m 이 섞인 경우가 있어,
|
||||||
|
// multi 인데 non-1m 이 있으면 1m 을 기본적으로 제외한다.
|
||||||
|
if (out.length > 1 && out.includes('1m') && out.some(ct => ct !== '1m')) {
|
||||||
|
return out.filter(ct => ct !== '1m');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTimeframeCandleTypes(node: LogicNode): string[] {
|
function readTimeframeCandleTypes(node: LogicNode): string[] {
|
||||||
@@ -467,7 +475,16 @@ export function mergeStartMetaForLoad(
|
|||||||
};
|
};
|
||||||
for (const [startId, meta] of Object.entries(decoded)) {
|
for (const [startId, meta] of Object.entries(decoded)) {
|
||||||
if (stored[startId]) {
|
if (stored[startId]) {
|
||||||
const types = normalizeCandleTypesList(getStartCandleTypes(stored[startId]));
|
const storedTypes = normalizeCandleTypesList(getStartCandleTypes(stored[startId]));
|
||||||
|
const decodedTypes = normalizeCandleTypesList(getStartCandleTypes(meta));
|
||||||
|
// flow_layout_json 에 stale 1m 이 남아 있는 경우가 있어,
|
||||||
|
// DSL 에 1m 이 없고 non-1m 이 있으면 DSL 쪽을 우선한다.
|
||||||
|
const shouldPreferDecoded =
|
||||||
|
storedTypes.includes('1m')
|
||||||
|
&& storedTypes.some(ct => ct !== '1m')
|
||||||
|
&& !decodedTypes.includes('1m')
|
||||||
|
&& decodedTypes.some(ct => ct !== '1m');
|
||||||
|
const types = shouldPreferDecoded ? decodedTypes : storedTypes;
|
||||||
merged[startId] = { candleTypes: types, candleType: types[0] };
|
merged[startId] = { candleTypes: types, candleType: types[0] };
|
||||||
} else {
|
} else {
|
||||||
const types = normalizeCandleTypesList(getStartCandleTypes(meta));
|
const types = normalizeCandleTypesList(getStartCandleTypes(meta));
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* STOMP·실시간 시그널 소유자 검증 — 로그인 사용자는 user_id, 비로그인은 device_id 기준.
|
||||||
|
*/
|
||||||
|
export interface SignalOwnership {
|
||||||
|
userId?: number | null;
|
||||||
|
deviceId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDeviceId(): string {
|
||||||
|
return localStorage.getItem('gc_device_id') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUserId(): number | null {
|
||||||
|
const raw = localStorage.getItem('gc_user_id');
|
||||||
|
if (!raw) return null;
|
||||||
|
const id = Number(raw);
|
||||||
|
return Number.isFinite(id) && id > 0 ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentSignalSession(): { userId: number | null; deviceId: string } {
|
||||||
|
return { userId: readUserId(), deviceId: readDeviceId() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** STOMP 페이로드·DB 시그널이 현재 세션 소유인지 */
|
||||||
|
export function signalBelongsToCurrentSession(owner: SignalOwnership): boolean {
|
||||||
|
const { userId, deviceId } = getCurrentSignalSession();
|
||||||
|
|
||||||
|
if (owner.userId != null && owner.userId > 0) {
|
||||||
|
return userId != null && userId === owner.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (owner.deviceId != null && owner.deviceId !== '') {
|
||||||
|
return userId == null && owner.deviceId === deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 소유자 메타 없음(레거시 브로드캐스트) — 타 사용자 시그널 유입 방지
|
||||||
|
return false;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user